diff --git a/.github/workflows/auto-tag-test-deployments.yml b/.github/workflows/auto-tag-test-deployments.yml index eb8ab9b8dc..600cd7a5cd 100644 --- a/.github/workflows/auto-tag-test-deployments.yml +++ b/.github/workflows/auto-tag-test-deployments.yml @@ -48,6 +48,17 @@ jobs: echo "Created tag: $TAG_NAME" + - name: Create Social Work Test Tag + run: | + SHORT_SHA=$(git rev-parse --short ${{ github.sha }}) + TIMESTAMP=$(date +%Y%m%d-%H%M%S) + TAG_NAME="sw-test-${TIMESTAMP}-${SHORT_SHA}" + + git tag -a "$TAG_NAME" -m "Auto-generated test deployment tag for social work service (commit: ${{ github.sha }})" + git push origin "$TAG_NAME" + + echo "Created tag: $TAG_NAME" + # We're deploying the backend and UI in parallel here, since that is _usually_ fine. However, # the UI deployment does have some dependencies on the backend, in the form of parameters like # S3 bucket urls, cognito domains, etc. If those change, or if new parameters are introduced, diff --git a/.github/workflows/check-common-cdk.yml b/.github/workflows/check-common-cdk.yml index 48af701309..4f6c5cbfb6 100644 --- a/.github/workflows/check-common-cdk.yml +++ b/.github/workflows/check-common-cdk.yml @@ -60,6 +60,4 @@ jobs: run: "pip install -r backend/common-cdk/requirements.in" - name: Test backend - # Start at 14%, because that's what our 'unit' coverage is, while we convert this to more stand-alone - # We will raise this up to 90 over time, to support these constructs more like a library - run: "cd backend/common-cdk; pytest tests --cov=common_constructs --cov-fail-under 50" + run: "cd backend/common-cdk; pytest tests --cov=common_constructs --cov-fail-under 90" diff --git a/.github/workflows/check-social-work-app.yml b/.github/workflows/check-social-work-app.yml new file mode 100644 index 0000000000..5be8925b80 --- /dev/null +++ b/.github/workflows/check-social-work-app.yml @@ -0,0 +1,116 @@ +name: Check-Social-Work-Application + +on: + pull_request: + paths: + - backend/social-work-app/** + +env: + AWS_REGION : "us-east-1" + +# Permission can be added at job level or workflow level +permissions: + id-token: write # This is required for requesting the JWT + contents: read # This is required for actions/checkout + +jobs: + LintPython: + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@v6 + with: + python-version: '3.14' + + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v5 + + - name: Upgrade pip + # Runner image ships pip 25.3; Upgrade to 26.1+ to include fix for CVE-2026-3219. + run: pip install --upgrade 'pip>=26.1' + + - name: Install dev dependencies + run: "pip install -r backend/social-work-app/requirements-dev.txt" + + - name: Lint Code + run: "cd backend/social-work-app; ruff check $(git ls-files '*.py')" + + - name: Check Dependencies + run: "pip-audit" + + LintNode: + runs-on: ubuntu-latest + + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v5 + + - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." + - run: echo "🖥️ The workflow is now ready to test your code on the runner." + + # Setup Node + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '24.11.1' + + # Use any cached yarn dependencies (saves build time) + - uses: actions/cache@v4 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + + # Install Yarn Dependencies + - name: Install Node.js dependencies + run: yarn install + working-directory: ./backend/social-work-app/lambdas/nodejs + + # Run Linter Checks + - name: Run linter + run: yarn run lint + working-directory: ./backend/social-work-app/lambdas/nodejs + + # Audit dependencies for vulnerabilities + - name: Audit dependencies + run: yarn run audit:dependencies + working-directory: ./backend/social-work-app/lambdas/nodejs + + TestApp: + runs-on: ubuntu-latest + steps: + # Checks-out the repository under $GITHUB_WORKSPACE + - uses: actions/checkout@v5 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: '3.14' + + - name: Upgrade pip + # Runner image ships pip 25.3; Upgrade to 26.1+ to include fix for CVE-2026-3219. + run: pip install --upgrade 'pip>=26.1' + + # Setup Node + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '24.11.1' + + # Use any cached yarn dependencies (saves build time) + - uses: actions/cache@v4 + with: + path: '**/node_modules' + key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} + + # Install Yarn Dependencies + - name: Install Node.js dependencies + run: yarn install + working-directory: ./backend/social-work-app/lambdas/nodejs + + - name: Install dev dependencies + run: "pip install -r backend/social-work-app/requirements-dev.txt" + + - name: Install all Python dependencies + run: "cd backend/social-work-app; bin/sync_deps.sh" + + - name: Test backend + run: "cd backend/social-work-app; bin/run_tests.sh -l all -no" diff --git a/backend/compact-connect/common_constructs/backup_plan.py b/backend/common-cdk/common_constructs/backup_plan.py similarity index 100% rename from backend/compact-connect/common_constructs/backup_plan.py rename to backend/common-cdk/common_constructs/backup_plan.py diff --git a/backend/common-cdk/common_constructs/base_pipeline_stack.py b/backend/common-cdk/common_constructs/base_pipeline_stack.py index d315615ebb..7af6133b3b 100644 --- a/backend/common-cdk/common_constructs/base_pipeline_stack.py +++ b/backend/common-cdk/common_constructs/base_pipeline_stack.py @@ -21,6 +21,7 @@ class CCPipelineType(StrEnum): BACKEND = 'Backend' FRONTEND = 'Frontend' COSMETOLOGY = 'Cosmetology' + SOCIAL_WORK = 'SocialWork' class BasePipelineStack(Stack): diff --git a/backend/common-cdk/common_constructs/compact_connect_api.py b/backend/common-cdk/common_constructs/compact_connect_api.py new file mode 100644 index 0000000000..8f5e81d17a --- /dev/null +++ b/backend/common-cdk/common_constructs/compact_connect_api.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import json +from functools import cached_property + +import jsii +from aws_cdk import Aspects, CfnOutput, Duration, IAspect +from aws_cdk.aws_apigateway import ( + AccessLogFormat, + Cors, + CorsOptions, + DomainNameOptions, + JsonSchema, + JsonSchemaType, + LogGroupLogDestination, + Method, + MethodLoggingLevel, + ResponseType, + RestApi, + SecurityPolicy, + StageOptions, +) +from aws_cdk.aws_certificatemanager import Certificate, CertificateValidation +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_cognito import IUserPool +from aws_cdk.aws_logs import LogGroup, QueryDefinition, QueryString, RetentionDays +from aws_cdk.aws_route53 import ARecord, IHostedZone, RecordTarget +from aws_cdk.aws_route53_targets import ApiGateway +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from constructs import Construct + +from common_constructs.security_profile import SecurityProfile +from common_constructs.stack import AppStack +from common_constructs.webacl import WebACL, WebACLScope + +MD_FORMAT = r'^[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$' +YMD_FORMAT = r'^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$' +ISO8601_DATETIME_FORMAT = r'^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])T([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](\.[0-9]{1,3})?Z$' # noqa: E501 +SSN_FORMAT = r'^[0-9]{3}-[0-9]{2}-[0-9]{4}$' +UUID4_FORMAT = r'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}' +PHONE_NUMBER_FORMAT = r'^\+[0-9]{8,15}$' + + +@jsii.implements(IAspect) +class NagSuppressOptionsNotAuthorized: + """ + This Aspect will be called over every node in the construct tree from where it is added, through all children: + https://docs.aws.amazon.com/cdk/v2/guide/aspects.html + + Because OPTIONS methods do not include authorization for CORS preflight, we'll suppress the authorization + findings for just these, handling other methods on a case-by-case basis. + """ + + def visit(self, node: Method): + if isinstance(node, Method): + if node.http_method == 'OPTIONS': + NagSuppressions.add_resource_suppressions( + node, + suppressions=[ + {'id': 'AwsSolutions-APIG4', 'reason': 'OPTIONS methods will not be authorized in this API'}, + {'id': 'AwsSolutions-COG4', 'reason': 'OPTIONS methods will not be authorized in this API'}, + ], + ) + + +class CompactConnectApi(RestApi): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + security_profile: SecurityProfile = SecurityProfile.RECOMMENDED, + alarm_topic: ITopic, + staff_users_user_pool: IUserPool, + domain_name: str | None = None, + stage_name_suffix: str = 'blue', + **kwargs, + ): + if not stage_name_suffix or not stage_name_suffix.strip(): + raise ValueError('stage_name_suffix must be a non-empty string') + + stack: AppStack = AppStack.of(scope) + # add the ENVIRONMENT_NAME to the common lambda environment variables + self.environment_name = environment_name + stack.common_env_vars['ENVIRONMENT_NAME'] = environment_name + # For developer convenience, we will allow for the case where there is no domain name configured + domain_kwargs = {} + if stack.hosted_zone is not None: + certificate = Certificate( + scope, + 'ApiCert', + domain_name=domain_name, + validation=CertificateValidation.from_dns(hosted_zone=stack.hosted_zone), + subject_alternative_names=[stack.hosted_zone.zone_name], + ) + domain_kwargs = { + 'domain_name': DomainNameOptions( + certificate=certificate, + domain_name=domain_name, + # this resource defaults to TLS_1_2, but we will explicitly set this anyway + security_policy=SecurityPolicy.TLS_1_2, + ) + } + + access_log_group = LogGroup(scope, 'ApiAccessLogGroup', retention=RetentionDays.ONE_MONTH) + NagSuppressions.add_resource_suppressions( + access_log_group, + suppressions=[ + { + 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', + 'reason': 'This group will contain no PII or PHI and should be accessible by anyone with access' + ' to the AWS account for basic operational support visibility. Encrypting is not appropriate here.', + } + ], + ) + + # Disable the default execute-api endpoint for all pipeline environments so traffic must use the custom domain. + disable_execute_api_endpoint = environment_name in ('test', 'beta', 'prod') + + super().__init__( + scope, + construct_id, + cloud_watch_role=True, + disable_execute_api_endpoint=disable_execute_api_endpoint, + deploy_options=StageOptions( + stage_name=f'{environment_name}-{stage_name_suffix}', + logging_level=MethodLoggingLevel.INFO, + access_log_destination=LogGroupLogDestination(access_log_group), + access_log_format=AccessLogFormat.custom( + json.dumps( + { + 'source_ip': '$context.identity.sourceIp', + 'identity': { + 'user': '$context.authorizer.claims.sub', + 'user_agent': '$context.identity.userAgent', + }, + 'level': 'INFO', + 'message': 'API Access log', + 'request_time': '[$context.requestTime]', + 'method': '$context.httpMethod', + 'domain_name': '$context.domainName', + 'resource_path': '$context.resourcePath', + 'path': '$context.path', + 'protocol': '$context.protocol', + 'status': '$context.status', + 'response_length': '$context.responseLength', + 'request_id': '$context.requestId', + 'xray_trace_id': '$context.xrayTraceId', + 'waf_evaluation': '$context.wafResponseCode', + 'waf_status': '$context.waf.status', + } + ) + ), + tracing_enabled=True, + metrics_enabled=True, + ), + # This API is for a variety of integrations including any state IT integrations, so we will + # allow all origins + default_cors_preflight_options=CorsOptions( + allow_origins=stack.allowed_origins, + allow_methods=Cors.ALL_METHODS, + allow_headers=Cors.DEFAULT_HEADERS + ['cache-control'], + ), + **domain_kwargs, + **kwargs, + ) + # Suppresses Nag findings about OPTIONS methods not being configured with an authorizer + Aspects.of(self).add(NagSuppressOptionsNotAuthorized()) + + if stack.hosted_zone is not None: + self._add_domain_name( + hosted_zone=stack.hosted_zone, + api_domain_name=domain_name, + ) + + self.alarm_topic = alarm_topic + self.staff_users = staff_users_user_pool + + self.web_acl = WebACL(self, 'WebACL', acl_scope=WebACLScope.REGIONAL, security_profile=security_profile) + self.web_acl.associate_stage(self.deployment_stage) + self._configure_alarms() + + # These canned Gateway Response headers do not support dynamic values, so we have to set a static value for the + # Access-Control-Allow-Origin header. If we need to support multiple origins, we will have to just set + # allow origin '*'. + gateway_response_origin = stack.allowed_origins[0] if len(stack.allowed_origins) == 1 else '*' + self.add_gateway_response( + 'BadBodyResponse', + type=ResponseType.BAD_REQUEST_BODY, + response_headers={'Access-Control-Allow-Origin': f"'{gateway_response_origin}'"}, + templates={'application/json': '{"message": "$context.error.validationErrorString"}'}, + ) + self.add_gateway_response( + 'UnauthorizedResponse', + type=ResponseType.UNAUTHORIZED, + status_code='401', + response_headers={'Access-Control-Allow-Origin': f"'{gateway_response_origin}'"}, + templates={'application/json': '{"message": "Unauthorized"}'}, + ) + self.add_gateway_response( + 'AccessDeniedResponse', + type=ResponseType.ACCESS_DENIED, + status_code='403', + response_headers={'Access-Control-Allow-Origin': f"'{gateway_response_origin}'"}, + templates={'application/json': '{"message": "Access denied"}'}, + ) + + stack = AppStack.of(self) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.node.path}/CloudWatchRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs' + ], + 'reason': 'This policy is crafted specifically for the account-level role created here.', + } + ], + ) + NagSuppressions.add_resource_suppressions( + self.deployment_stage, + suppressions=[ + { + 'id': 'HIPAA.Security-APIGWCacheEnabledAndEncrypted', + 'reason': 'We will assess need for API caching after the API is built out', + }, + { + 'id': 'HIPAA.Security-APIGWSSLEnabled', + 'reason': 'Client TLS certificates are not appropriate for this API, since it is not proxying ' + 'HTTP requests to backend systems.', + }, + ], + ) + + QueryDefinition( + self, + 'APILogs', + query_definition_name=f'{self.node.id}/API', + query_string=QueryString( + fields=['@timestamp', 'level', 'status', 'message', 'method', 'path', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=[access_log_group, self.web_acl.log_group], + ) + + @cached_property + def parameter_body_validator(self): + return self.add_request_validator('BodyValidator', validate_request_body=True, validate_request_parameters=True) + + @cached_property + def parameter_only_validator(self): + """ + Validates the query parameters but not the actual request body. Only use if you want to bypass APIGW + schema body validation. + """ + return self.add_request_validator( + 'ParameterValidator', validate_request_body=False, validate_request_parameters=True + ) + + @cached_property + def message_response_model(self): + return self.add_model( + 'MessageResponseModel', + description='Basic message response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['message'], + additional_properties=False, + properties={'message': JsonSchema(type=JsonSchemaType.STRING)}, + ), + ) + + def _add_domain_name(self, api_domain_name: str, hosted_zone: IHostedZone): + self.record = ARecord( + self, + 'ApiARecord', + zone=hosted_zone, + record_name=api_domain_name, + target=RecordTarget(alias_target=ApiGateway(self)), + ) + self.base_url = f'https://{api_domain_name}' + + CfnOutput(self, 'APIBaseUrl', value=api_domain_name) + CfnOutput(self, 'APIId', value=self.rest_api_id) + + def _configure_alarms(self): + # Any time the API returns a 5XX + server_error_alarm = Alarm( + self, + 'ServerErrorAlarm', + metric=self.deployment_stage.metric_server_error(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{self.node.path} server error detected', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ) + server_error_alarm.add_alarm_action(SnsAction(self.alarm_topic)) + + # If the API returns a 4XX for more than half of its requests + client_error_alarm = Alarm( + self, + 'ClientErrorAlarm', + metric=self.deployment_stage.metric_client_error(statistic=Stats.AVERAGE, period=Duration.minutes(5)), + evaluation_periods=6, + threshold=0.5, + actions_enabled=True, + alarm_description=f'{self.node.path} excessive client errors', + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ) + client_error_alarm.add_alarm_action(SnsAction(self.alarm_topic)) + + # If the API latency p(95) is approaching its max timeout + latency_alarm = Alarm( + self, + 'LatencyAlarm', + metric=self.deployment_stage.metric_latency(statistic=Stats.percentile(95), period=Duration.minutes(5)), + evaluation_periods=3, + threshold=25_000, # 25 seconds + actions_enabled=True, + alarm_description=f'{self.node.path}', + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + evaluate_low_sample_count_percentile='evaluate', + ) + latency_alarm.add_alarm_action(SnsAction(self.alarm_topic)) diff --git a/backend/compact-connect/common_constructs/constants.py b/backend/common-cdk/common_constructs/constants.py similarity index 100% rename from backend/compact-connect/common_constructs/constants.py rename to backend/common-cdk/common_constructs/constants.py diff --git a/backend/common-cdk/common_constructs/frontend_app_config_utility.py b/backend/common-cdk/common_constructs/frontend_app_config_utility.py index 90bb5652bc..3fb4926131 100644 --- a/backend/common-cdk/common_constructs/frontend_app_config_utility.py +++ b/backend/common-cdk/common_constructs/frontend_app_config_utility.py @@ -15,6 +15,7 @@ class AppId(StrEnum): JCC = 'jcc' COSMETOLOGY = 'cosmetology' + SOCIAL_WORK = 'social-work' def _get_persistent_stack_parameter_name(app_id: AppId = AppId.JCC) -> str: diff --git a/backend/compact-connect/common_constructs/nodejs_function.py b/backend/common-cdk/common_constructs/nodejs_function.py similarity index 100% rename from backend/compact-connect/common_constructs/nodejs_function.py rename to backend/common-cdk/common_constructs/nodejs_function.py diff --git a/backend/compact-connect/common_constructs/python_common_layer_versions.py b/backend/common-cdk/common_constructs/python_common_layer_versions.py similarity index 100% rename from backend/compact-connect/common_constructs/python_common_layer_versions.py rename to backend/common-cdk/common_constructs/python_common_layer_versions.py diff --git a/backend/compact-connect/common_constructs/python_function.py b/backend/common-cdk/common_constructs/python_function.py similarity index 100% rename from backend/compact-connect/common_constructs/python_function.py rename to backend/common-cdk/common_constructs/python_function.py diff --git a/backend/compact-connect/common_constructs/queue_event_listener.py b/backend/common-cdk/common_constructs/queue_event_listener.py similarity index 100% rename from backend/compact-connect/common_constructs/queue_event_listener.py rename to backend/common-cdk/common_constructs/queue_event_listener.py diff --git a/backend/compact-connect/common_constructs/queued_lambda_processor.py b/backend/common-cdk/common_constructs/queued_lambda_processor.py similarity index 100% rename from backend/compact-connect/common_constructs/queued_lambda_processor.py rename to backend/common-cdk/common_constructs/queued_lambda_processor.py diff --git a/backend/compact-connect/common_constructs/ssm_parameter_utility.py b/backend/common-cdk/common_constructs/ssm_parameter_utility.py similarity index 100% rename from backend/compact-connect/common_constructs/ssm_parameter_utility.py rename to backend/common-cdk/common_constructs/ssm_parameter_utility.py diff --git a/backend/common-cdk/common_constructs/ssn_table.py b/backend/common-cdk/common_constructs/ssn_table.py new file mode 100644 index 0000000000..32e5aa41ce --- /dev/null +++ b/backend/common-cdk/common_constructs/ssn_table.py @@ -0,0 +1,516 @@ +import os + +from aws_cdk import ArnFormat, Duration, RemovalPolicy +from aws_cdk.aws_backup import BackupResource +from aws_cdk.aws_dynamodb import ( + Attribute, + AttributeType, + BillingMode, + PointInTimeRecoverySpecification, + ProjectionType, + Table, + TableEncryption, +) +from aws_cdk.aws_events import EventBus +from aws_cdk.aws_iam import ( + Effect, + ManagedPolicy, + PolicyDocument, + PolicyStatement, + Role, + ServicePrincipal, + StarPrincipal, +) +from aws_cdk.aws_kms import Key +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from constructs import Construct + +from common_constructs.backup_plan import CCBackupPlan +from common_constructs.python_function import PythonFunction +from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from common_constructs.stack import Stack +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack + +# Name for SSN disaster recovery sync table state machine for specific permissions +SSN_SYNC_STATE_MACHINE_NAME = 'SSNTable-SSNSyncTableData' +# Name prefix for all SSN tables recovered through disaster recovery process +# Used to grant read permissions on any restored table that follows this naming convention. +SSN_RESTORED_TABLE_NAME_PREFIX = 'DR-TEMP-SSN-' + + +SSN_TABLE_NAME = 'ssn-table-DataEventsLog' + + +class SSNTable(Table): + """DynamoDB table to house provider Social Security Numbers""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + removal_policy: RemovalPolicy, + data_event_bus: EventBus, + alarm_topic: ITopic, + backup_infrastructure_stack: BackupInfrastructureStack, + environment_context: dict, + **kwargs, + ): + self.key = Key( + scope, + 'SSNKey', + enable_key_rotation=True, + alias='ssn-key', + removal_policy=removal_policy, + ) + + # This role is used by the disaster recovery Lambda functions to copy data from a recovered + # table to the production table. It is defined up here so we can reference it in the table policy + # the table policy must be defined in the constructor due to an ongoing issue with CDK where any attempt + # to add policy statements outside the constructor will not actually be added. + # see https://github.com/aws/aws-cdk/issues/35062 + self.disaster_recovery_lambda_role = Role( + scope, + 'DisasterRecoveryLambdaRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='Dedicated role for SSN table disaster recovery Lambda operations', + managed_policies=[ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaBasicExecutionRole')], + ) + + super().__init__( + scope, + construct_id, + # Forcing this resource name to comply with a naming-pattern-based CloudTrail log, to be + # implemented in issue https://github.com/csg-org/CompactConnect/issues/397 + table_name=SSN_TABLE_NAME, + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=self.key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery_specification=PointInTimeRecoverySpecification(point_in_time_recovery_enabled=True), + deletion_protection=True if removal_policy == RemovalPolicy.RETAIN else False, + partition_key=Attribute(name='pk', type=AttributeType.STRING), + sort_key=Attribute(name='sk', type=AttributeType.STRING), + resource_policy=PolicyDocument( + statements=[ + PolicyStatement( + # No actions that involve manual backups of the SSN table. Developers should not + # be able to perform on-demand backups, to reduce replication of this + # sensitive information + effect=Effect.DENY, + actions=[ + 'dynamodb:CreateBackup', + ], + principals=[StarPrincipal()], + resources=['*'], + conditions={ + 'StringNotEquals': { + # We will allow DynamoDB itself, so it can do internal operations + 'aws:PrincipalServiceName': 'dynamodb.amazonaws.com', + } + }, + ), + PolicyStatement( + # No actions that involve reading/writing more than one record at a time. In the event of a + # compromise, this slows down a potential data extraction, since each record would need to be + # pulled, one at a time + effect=Effect.DENY, + actions=[ + 'dynamodb:BatchGetItem', + 'dynamodb:BatchWriteItem', + 'dynamodb:PartiQL*', + 'dynamodb:Scan', + ], + principals=[StarPrincipal()], + resources=['*'], + conditions={ + 'StringNotEquals': { + # We will allow DynamoDB itself, so it can do internal operations like backups + 'aws:PrincipalServiceName': 'dynamodb.amazonaws.com', + # We allow the DR lambda role as it restores the full table + 'aws:PrincipalArn': [self.disaster_recovery_lambda_role.role_arn], + } + }, + ), + ] + ), + **kwargs, + ) + + # This GSI will allow a reverse lookup of provider_id -> ssn, in addition to our current ssn -> provider_id + # pattern. + self.ssn_index_name = 'ssnIndex' + self.add_global_secondary_index( + index_name=self.ssn_index_name, + partition_key=Attribute(name='providerIdGSIpk', type=AttributeType.STRING), + sort_key=Attribute(name='sk', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + + # Restrict read access to only the ssnIndex GSI + # Because the primary keys include SSN and data events are recorded on a CloudTrail organization trail, + # queries outside the ssnIndex will result in SSNs being logged into the data events trail. To reduce + # sensitivity of the trail logs, we'll restrict read operations to only the ssnIndex, where queries + # by Key include provider ids, not SSNs. + stack = Stack.of(self) + self.add_to_resource_policy( + PolicyStatement( + # Deny GetItem and Query operations unless they're targeting the ssnIndex GSI + effect=Effect.DENY, + actions=[ + 'dynamodb:GetItem', + 'dynamodb:Query', + 'dynamodb:ConditionCheckItem', + ], + principals=[StarPrincipal()], + not_resources=[ + # arn:${Partition}:dynamodb:${Region}:${Account}:table/${TableName}/index/${IndexName} + stack.format_arn( + partition=stack.partition, + service='dynamodb', + region=stack.region, + account=stack.account, + resource='table', + # We have to use the constant here, because using `self.table_name` here creates a circular + # reference in the resulting template. + resource_name=f'{SSN_TABLE_NAME}/index/{self.ssn_index_name}', + ), + ], + ) + ) + + # Set up backup plan + backup_enabled = environment_context['backup_enabled'] + if backup_enabled and backup_infrastructure_stack is not None: + # Store backup service role for KMS key policy configuration + self.backup_service_role = backup_infrastructure_stack.ssn_backup_service_role + + self.backup_plan = CCBackupPlan( + self, + 'SSNTableBackup', + backup_plan_name_prefix=self.table_name, + backup_resources=[BackupResource.from_dynamo_db_table(self)], + backup_vault=backup_infrastructure_stack.local_ssn_backup_vault, + backup_service_role=backup_infrastructure_stack.ssn_backup_service_role, + cross_account_backup_vault=backup_infrastructure_stack.cross_account_ssn_backup_vault, + backup_policy=environment_context['backup_policies']['general_data'], + ) + else: + self.backup_plan = None + self.backup_service_role = None + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'This non-production environment has backups disabled intentionally', + }, + ], + ) + + self._configure_access() + + # Initialize the license preprocessor + self._setup_license_preprocessor_queue(data_event_bus, alarm_topic) + + def _configure_access(self): + self.ingest_role = Role( + self, + 'LicenseIngestRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='Dedicated role for license ingest, with access to full SSNs', + managed_policies=[ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaBasicExecutionRole')], + ) + self.grant_read_write_data(self.ingest_role) + self._role_suppressions(self.ingest_role) + + self.license_upload_role = Role( + self, + 'LicenseUploadRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='Dedicated role for lambdas that upload license records ' + 'into the preprocessing queue with full SSNs', + managed_policies=[ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaBasicExecutionRole')], + ) + # This role is used by both the bulk upload and post license lambdas, the bulk upload S3 bucket is encrypted + # with the same KMS key as the SSN table, so we must grant the role decrypt and encrypt to read/write the + # objects in the bucket. + # The role also needs the encrypt permission in order to put license data on the license preprocessing queue. + self.key.grant_encrypt_decrypt(self.license_upload_role) + self._role_suppressions(self.license_upload_role) + + stack = Stack.of(self) + + # Configure permissions for the Lambda role + # Add scan permissions for restored tables (needed by copy_records Lambda) + self.disaster_recovery_lambda_role.add_to_policy( + PolicyStatement( + actions=[ + 'dynamodb:Scan', + ], + resources=[ + stack.format_arn( + partition=stack.partition, + service='dynamodb', + region=stack.region, + account=stack.account, + resource='table', + resource_name=f'{SSN_RESTORED_TABLE_NAME_PREFIX}*', + arn_format=ArnFormat.SLASH_RESOURCE_NAME, + ), + self.table_arn, + ], + ) + ) + + self.disaster_recovery_lambda_role.add_to_policy( + PolicyStatement( + actions=[ + 'dynamodb:BatchWriteItem', + 'dynamodb:DeleteItem', + 'dynamodb:PutItem', + ], + resources=[ + self.table_arn, + ], + ) + ) + + # Add KMS permissions for Lambda role (needed for pagination key encryption/decryption) + self.key.grant_encrypt_decrypt(self.disaster_recovery_lambda_role) + + # Add specific suppressions for the Lambda role + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.disaster_recovery_lambda_role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'appliesTo': [ + f'Resource::arn:aws:dynamodb:{stack.region}:{stack.account}:table/{SSN_RESTORED_TABLE_NAME_PREFIX}*', + 'Action::kms:ReEncrypt*', + 'Action::kms:GenerateDataKey*', + ], + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the specific + DynamoDB table DR operations and KMS actions that this role needs for disaster recovery. + """, + }, + ], + ) + self._role_suppressions(self.disaster_recovery_lambda_role) + + # This role is used by the disaster recovery Step Functions to orchestrate the recovery process. + self.disaster_recovery_step_function_role = Role( + self, + 'DisasterRecoveryStepFunctionRole', + assumed_by=ServicePrincipal('states.amazonaws.com'), + description='Dedicated role for SSN table disaster recovery Step Function operations', + ) + # Configure permissions for the Step Function role + # Add permissions for the step function role to perform restore operations + self.disaster_recovery_step_function_role.add_to_policy( + PolicyStatement( + actions=[ + 'dynamodb:RestoreTableToPointInTime', # For creating table from PITR backup + 'dynamodb:DescribeTable', # For table status polling + # The following permissions are needed for restoring data into the PITR table + 'dynamodb:BatchWriteItem', + 'dynamodb:DeleteItem', + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:Query', + 'dynamodb:Scan', + 'dynamodb:UpdateItem', + ], + resources=[ + self.table_arn, # Table for backup operations + f'{self.table_arn}/backup/*', # Backup resources + f'arn:aws:dynamodb:{stack.region}:{stack.account}:table/{SSN_RESTORED_TABLE_NAME_PREFIX}*', + f'arn:aws:dynamodb:{stack.region}:{stack.account}:table/{SSN_RESTORED_TABLE_NAME_PREFIX}*/index/*', + ], + ) + ) + + # Add permissions for the step function role to start execution of the SSN sync table step function + self.disaster_recovery_step_function_role.add_to_policy( + PolicyStatement( + actions=['states:StartExecution'], + resources=[ + # Grant permission to start execution of the SSN sync table state machine specifically + stack.format_arn( + partition=stack.partition, + service='states', + region=stack.region, + account=stack.account, + resource='stateMachine', + resource_name=f'{SSN_SYNC_STATE_MACHINE_NAME}', + arn_format=ArnFormat.COLON_RESOURCE_NAME, + ), + ], + ) + ) + + # Add EventBridge permissions needed for step function to track synchronous events + self.disaster_recovery_step_function_role.add_to_policy( + PolicyStatement( + actions=[ + 'events:PutTargets', + 'events:PutRule', + 'events:DescribeRule', + ], + resources=[ + # rule used for tracking step function execution events + f'arn:aws:events:{stack.region}:{stack.account}:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule', + ], + ) + ) + + # Add KMS permissions needed for step function role to recover the encrypted table + self.disaster_recovery_step_function_role.add_to_policy( + PolicyStatement( + actions=[ + # this is needed to recover a table that is encrypted with a custom managed KMS key + 'kms:DescribeKey', + 'kms:CreateGrant', + 'kms:Decrypt', + 'kms:Encrypt', + 'kms:GenerateDataKey*', + 'kms:ReEncrypt*', + ], + resources=[self.key.key_arn], + ) + ) + # Add specific suppressions for the Step Function role + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.disaster_recovery_step_function_role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'appliesTo': [ + f'Resource::arn:aws:dynamodb:{stack.region}:{stack.account}:table/{SSN_RESTORED_TABLE_NAME_PREFIX}*', + f'Resource::arn:aws:dynamodb:{stack.region}:{stack.account}:table/{SSN_RESTORED_TABLE_NAME_PREFIX}*/index/*', + f'Resource::<{stack.get_logical_id(self.node.default_child)}.Arn>/backup/*', + 'Resource::*', + 'Action::kms:ReEncrypt*', + 'Action::kms:GenerateDataKey*', + ], + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the specific + DynamoDB table DR operations and KMS actions that this Step Function needs for disaster recovery. + """, + }, + ], + ) + + # This explicitly blocks any principals (including account admins) from reading data + # encrypted with this key other than our IAM roles declared here and dynamodb itself + allowed_principal_arns = [ + self.ingest_role.role_arn, + self.license_upload_role.role_arn, + self.disaster_recovery_lambda_role.role_arn, + self.disaster_recovery_step_function_role.role_arn, + ] + # Only include backup service role if backup is enabled + if self.backup_service_role is not None: + allowed_principal_arns.append(self.backup_service_role.role_arn) + + self.key.add_to_resource_policy( + PolicyStatement( + effect=Effect.DENY, + actions=['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + principals=[StarPrincipal()], + resources=['*'], + conditions={ + 'StringNotEquals': { + 'aws:PrincipalArn': allowed_principal_arns, + 'aws:PrincipalServiceName': ['dynamodb.amazonaws.com', 'events.amazonaws.com'], + } + }, + ) + ) + self.key.grant_encrypt_decrypt(self.ingest_role) + + def _setup_license_preprocessor_queue(self, data_event_bus: EventBus, alarm_topic: ITopic): + """Set up the license preprocessor queue and handler""" + stack: Stack = Stack.of(self) + + preprocess_handler = PythonFunction( + self, + 'LicensePreprocessHandler', + description='Preprocess license data to create SSN Dynamo records before sending licenses to the event bus', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'ingest.py'), + handler='preprocess_license_ingest', + role=self.ingest_role, + timeout=Duration.minutes(1), + environment={ + 'EVENT_BUS_NAME': data_event_bus.event_bus_name, + 'SSN_TABLE_NAME': self.table_name, + **stack.common_env_vars, + }, + alarm_topic=alarm_topic, + ) + + # Grant permissions to the preprocess handler + data_event_bus.grant_put_events_to(preprocess_handler) + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(preprocess_handler.role), + f'{preprocess_handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, KMS key and Table that this lambda specifically needs access to. + """, + }, + ], + ) + + # Create the queued lambda processor for license preprocessing + self.preprocessor_queue = QueuedLambdaProcessor( + self, + 'LicenseQueuePreprocessor', + process_function=preprocess_handler, + visibility_timeout=Duration.minutes(5), + retention_period=Duration.hours(12), + max_batching_window=Duration.minutes(5), + max_receive_count=3, + batch_size=50, + # Use the SSN key for encryption to protect sensitive data + encryption_key=self.key, + alarm_topic=alarm_topic, + ) + + def _role_suppressions(self, role: Role): + stack = Stack.of(role) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{role.node.path}/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], + 'reason': 'The BasicExecutionRole policy is appropriate for these lambdas', + }, + ], + ) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'appliesTo': [f'Resource::<{stack.get_logical_id(self.node.default_child)}.Arn>/index/*'], + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, KMS key and Table that this lambda specifically needs access to. + """, + }, + ], + ) diff --git a/backend/cosmetology-app/common_constructs/user_pool.py b/backend/common-cdk/common_constructs/user_pool.py similarity index 99% rename from backend/cosmetology-app/common_constructs/user_pool.py rename to backend/common-cdk/common_constructs/user_pool.py index b766143a40..8ae51c7301 100644 --- a/backend/cosmetology-app/common_constructs/user_pool.py +++ b/backend/common-cdk/common_constructs/user_pool.py @@ -35,9 +35,10 @@ from aws_cdk.aws_route53 import ARecord, IHostedZone, RecordTarget from aws_cdk.aws_route53_targets import UserPoolDomainTarget from cdk_nag import NagSuppressions +from constructs import Construct + from common_constructs.security_profile import SecurityProfile from common_constructs.stack import Stack -from constructs import Construct class UserPool(CdkUserPool): diff --git a/backend/common-cdk/common_constructs/webacl.py b/backend/common-cdk/common_constructs/webacl.py index 7a2f168dab..fa13245989 100644 --- a/backend/common-cdk/common_constructs/webacl.py +++ b/backend/common-cdk/common_constructs/webacl.py @@ -67,7 +67,7 @@ def __init__( # WARNING: THIS WILL NOT WORK IN GOVCLOUD # Global ACLs need a log group in us-east-1 # Regional ACLs need a log group in the matching region - if scope == WebACLScope.CLOUDFRONT and not stack.region == 'us-east-1': + if acl_scope == WebACLScope.CLOUDFRONT and not stack.region == 'us-east-1': raise RuntimeError( 'CLOUDFRONT scoped WebACLs must be in the "us-east-1" region to have logging enabled' ) diff --git a/backend/common-cdk/common_stacks/README.md b/backend/common-cdk/common_stacks/README.md new file mode 100644 index 0000000000..13bc987a7b --- /dev/null +++ b/backend/common-cdk/common_stacks/README.md @@ -0,0 +1,7 @@ +# Common Stacks + +This package holds shared CDK **stacks** used across CompactConnect backend apps. + +Import as `common_stacks.` after the app adds `../common-cdk` to `sys.path` (see each app's `app.py`). + +> **Note:** Do not add an `__init__.py` file to this package if you rely on namespace-package merging with app-local extensions in the future. \ No newline at end of file diff --git a/backend/common-cdk/common_stacks/backup_infrastructure_stack.py b/backend/common-cdk/common_stacks/backup_infrastructure_stack.py new file mode 100644 index 0000000000..398122177e --- /dev/null +++ b/backend/common-cdk/common_stacks/backup_infrastructure_stack.py @@ -0,0 +1,557 @@ +from aws_cdk import ArnFormat, Duration, NestedStack, RemovalPolicy +from aws_cdk.aws_backup import BackupVault, LockConfiguration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Metric, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import EventPattern, Rule +from aws_cdk.aws_events_targets import SnsTopic +from aws_cdk.aws_iam import ( + AccountPrincipal, + Effect, + ManagedPolicy, + PolicyDocument, + PolicyStatement, + Role, + ServicePrincipal, + StarPrincipal, +) +from aws_cdk.aws_kms import Alias, Key +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from constructs import Construct + + +class BackupInfrastructureStack(NestedStack): + """ + Stack that creates the backup infrastructure within each environment account. + + This stack provides the local backup infrastructure needed for CompactConnect + data retention, including backup vaults, KMS keys, and IAM roles. Each + environment account manages its own complete backup infrastructure, with + copy actions replicating backups to the environment-specific cross-account + destination vaults created by the backup account stack. + + Resources Created: + - Local backup vaults (general and SSN-specific) + - Local KMS keys for backup encryption + - IAM service roles for backup operations + - Cross-account destination vault references from context + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + environment_name: str, + backup_config: dict, + alarm_topic: ITopic, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + self.environment_name = environment_name + self.alarm_topic = alarm_topic + + # If we delete this stack, retain the resource (orphan but prevent data loss) or destroy it (clean up)? + self.removal_policy = RemovalPolicy.RETAIN if environment_name == 'prod' else RemovalPolicy.DESTROY + + self.backup_config = backup_config + + # Create local backup encryption keys + self._create_local_backup_encryption_key() + self._create_local_ssn_backup_encryption_key() + + # Create references to cross-account backup vaults (needed for SSN backup service role policy) + self._create_cross_account_vault_references() + + # Create IAM roles for backup operations + self._create_backup_service_role() + self._create_ssn_backup_service_role() + + # Create local backup vaults + self._create_local_backup_vault() + self._create_local_ssn_backup_vault() + + # Create backup monitoring alarms and EventBridge rules + self._create_backup_monitoring() + + # Add CDK NAG suppressions for expected AWS managed policies in backup service roles + self._add_cdk_nag_suppressions() + + def _create_local_backup_encryption_key(self) -> None: + """Create a local KMS key for general backup encryption.""" + self.local_backup_key = Key( + self, + 'LocalBackupEncryptionKey', + description=f'Local KMS key for CompactConnect {self.environment_name} backup encryption', + enable_key_rotation=True, + removal_policy=self.removal_policy, + ) + + # Add an alias for the local backup key + Alias( + self, + 'LocalBackupEncryptionKeyAlias', + alias_name=f'alias/compactconnect-{self.environment_name}-backup-key', + target_key=self.local_backup_key, + ) + + def _create_local_ssn_backup_encryption_key(self) -> None: + """Create a dedicated local KMS key for SSN backup encryption.""" + self.local_ssn_backup_key = Key( + self, + 'LocalSSNBackupEncryptionKey', + description=f'Local KMS key for CompactConnect {self.environment_name} SSN backup encryption', + enable_key_rotation=True, + removal_policy=self.removal_policy, + ) + + # Add an alias for the local SSN backup key + Alias( + self, + 'LocalSSNBackupEncryptionKeyAlias', + alias_name=f'alias/compactconnect-{self.environment_name}-ssn-backup-key', + target_key=self.local_ssn_backup_key, + ) + + def _create_backup_service_role(self) -> None: + """Create the standard AWS Backup service role for general backup operations.""" + self.backup_service_role = Role( + self, + 'BackupServiceRole', + role_name=f'CompactConnect-{self.environment_name}-BackupServiceRole', + assumed_by=ServicePrincipal('backup.amazonaws.com'), + managed_policies=[ + ManagedPolicy.from_aws_managed_policy_name('service-role/AWSBackupServiceRolePolicyForBackup'), + ManagedPolicy.from_aws_managed_policy_name('service-role/AWSBackupServiceRolePolicyForRestores'), + ManagedPolicy.from_aws_managed_policy_name('AWSBackupServiceRolePolicyForS3Backup'), + ManagedPolicy.from_aws_managed_policy_name('AWSBackupServiceRolePolicyForS3Restore'), + ], + # Create a policy that restricts cross-account copy operations to only our approved backup vault + # This provides security controls while allowing necessary backup and restore operations + inline_policies={ + 'BackupSecurityPolicy': PolicyDocument( + statements=[ + PolicyStatement( + sid='RestrictCrossAccountOperations', + effect=Effect.DENY, + actions=['backup:CopyIntoBackupVault', 'backup:StartCopyJob'], + resources=['*'], + conditions={ + 'ForAnyValue:ArnNotEquals': { + 'backup:CopyTargets': [self.cross_account_backup_vault.backup_vault_arn] + } + }, + ) + ] + ) + }, + ) + + def _create_ssn_backup_service_role(self) -> None: + """Create a specialized backup service role for SSN data with enhanced security controls.""" + self.ssn_backup_service_role = Role( + self, + 'SSNBackupServiceRole', + role_name=f'CompactConnect-{self.environment_name}-SSNBackupRole', + assumed_by=ServicePrincipal('backup.amazonaws.com'), + managed_policies=[ + ManagedPolicy.from_aws_managed_policy_name('service-role/AWSBackupServiceRolePolicyForBackup'), + ManagedPolicy.from_aws_managed_policy_name('service-role/AWSBackupServiceRolePolicyForRestores'), + ], + # Create a policy that restricts cross-account copy operations to only our approved backup vault + # This provides security controls while allowing necessary backup and restore operations + inline_policies={ + 'SSNBackupSecurityPolicy': PolicyDocument( + statements=[ + PolicyStatement( + sid='RestrictCrossAccountOperations', + effect=Effect.DENY, + actions=['backup:CopyIntoBackupVault', 'backup:StartCopyJob'], + resources=['*'], + conditions={ + 'ForAnyValue:ArnNotEquals': { + 'backup:CopyTargets': [self.cross_account_ssn_backup_vault.backup_vault_arn] + } + }, + ) + ] + ) + }, + ) + + def _create_local_backup_vault(self) -> None: + """Create the local backup vault for general backup operations.""" + self.local_backup_vault = BackupVault( + self, + 'LocalBackupVault', + backup_vault_name=f'CompactConnect-{self.environment_name}-BackupVault', + encryption_key=self.local_backup_key, + removal_policy=self.removal_policy, + # note the changeable_for field is not set, so this lock is set under governance mode + lock_configuration=LockConfiguration(min_retention=Duration.days(90)), + access_policy=PolicyDocument( + statements=[ + PolicyStatement( + sid='EnableBackupVaultAccess', + effect=Effect.ALLOW, + actions=['backup:CopyIntoBackupVault'], + resources=['*'], + principals=[AccountPrincipal(self.account)], + ), + # We only allow copies from this vault, to our approved backup account vault + PolicyStatement( + sid='OnlyCopyIntoApprovedVault', + effect=Effect.DENY, + actions=['backup:CopyIntoBackupVault', 'backup:StartCopyJob'], + resources=['*'], + principals=[StarPrincipal()], + conditions={ + 'ForAnyValue:ArnNotEquals': { + 'backup:CopyTargets': [self.cross_account_backup_vault.backup_vault_arn] + } + }, + ), + ] + ), + ) + + def _create_local_ssn_backup_vault(self) -> None: + """Create the dedicated local backup vault for SSN data.""" + self.local_ssn_backup_vault = BackupVault( + self, + 'LocalSSNBackupVault', + backup_vault_name=f'CompactConnect-{self.environment_name}-SSNBackupVault', + encryption_key=self.local_ssn_backup_key, + removal_policy=self.removal_policy, + # note the changeable_for field is not set, so this lock is set under governance mode + lock_configuration=LockConfiguration(min_retention=Duration.days(90)), + access_policy=PolicyDocument( + statements=[ + PolicyStatement( + sid='EnableBackupVaultAccess', + effect=Effect.ALLOW, + actions=['backup:CopyIntoBackupVault'], + resources=['*'], + principals=[AccountPrincipal(self.account)], + ), + # We only allow copies from this vault, to our approved backup account vault + PolicyStatement( + sid='OnlyCopyIntoApprovedVault', + effect=Effect.DENY, + actions=['backup:CopyIntoBackupVault', 'backup:StartCopyJob'], + resources=['*'], + principals=[StarPrincipal()], + conditions={ + 'ForAnyValue:ArnNotEquals': { + 'backup:CopyTargets': [self.cross_account_ssn_backup_vault.backup_vault_arn] + } + }, + ), + ] + ), + ) + + def _create_cross_account_vault_references(self) -> None: + """Create references to cross-account backup vaults.""" + # Create reference to general cross-account backup vault + general_vault_arn = self.format_arn( + account=self.backup_config['backup_account_id'], + region=self.backup_config['backup_region'], + service='backup', + resource='backup-vault', + resource_name=self.backup_config['general_vault_name'], + arn_format=ArnFormat.COLON_RESOURCE_NAME, + ) + self.cross_account_backup_vault = BackupVault.from_backup_vault_arn( + self, 'CrossAccountBackupVault', general_vault_arn + ) + + # Create reference to SSN cross-account backup vault + ssn_vault_arn = self.format_arn( + account=self.backup_config['backup_account_id'], + region=self.backup_config['backup_region'], + service='backup', + resource='backup-vault', + resource_name=self.backup_config['ssn_vault_name'], + arn_format=ArnFormat.COLON_RESOURCE_NAME, + ) + self.cross_account_ssn_backup_vault = BackupVault.from_backup_vault_arn( + self, 'CrossAccountSSNBackupVault', ssn_vault_arn + ) + + def _create_backup_monitoring(self) -> None: + """Create comprehensive backup monitoring using CloudWatch alarms and EventBridge rules.""" + + # CloudWatch Metric-based Alarms + self._create_backup_job_failure_alarms() + self._create_copy_job_failure_alarms() + self._create_recovery_point_alarms() + + # EventBridge Rules for real-time monitoring + self._create_backup_event_rules() + self._create_operational_security_rules() + + def _create_backup_job_failure_alarms(self) -> None: + """Create alarms for backup job failures across all backup vaults.""" + + # General backup job failures + general_backup_failures = Alarm( + self, + 'GeneralBackupJobFailures', + metric=Metric( + namespace='AWS/Backup', + metric_name='NumberOfBackupJobsFailed', + dimensions_map={'BackupVaultName': self.local_backup_vault.backup_vault_name}, + statistic='Sum', + period=Duration.minutes(5), + ), + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description='One or more backup jobs have failed in the general backup vault. ' + 'Investigation required to ensure data protection is maintained.', + ) + general_backup_failures.add_alarm_action(SnsAction(self.alarm_topic)) + + # SSN backup job failures (critical) + ssn_backup_failures = Alarm( + self, + 'SSNBackupJobFailures', + metric=Metric( + namespace='AWS/Backup', + metric_name='NumberOfBackupJobsFailed', + dimensions_map={'BackupVaultName': self.local_ssn_backup_vault.backup_vault_name}, + statistic='Sum', + period=Duration.minutes(5), + ), + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description='CRITICAL: SSN backup job has failed. Immediate investigation required ' + 'as this affects sensitive data protection compliance.', + ) + ssn_backup_failures.add_alarm_action(SnsAction(self.alarm_topic)) + + # Backup job expiration monitoring + backup_job_expired = Alarm( + self, + 'BackupJobsExpired', + metric=Metric( + namespace='AWS/Backup', + metric_name='NumberOfBackupJobsExpired', + statistic='Sum', + period=Duration.hours(1), + ), + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description='Backup jobs have expired without starting. This may indicate ' + 'resource conflicts or scheduling issues.', + ) + backup_job_expired.add_alarm_action(SnsAction(self.alarm_topic)) + + def _create_copy_job_failure_alarms(self) -> None: + """Create alarms for cross-account copy job failures.""" + + copy_job_failures = Alarm( + self, + 'CrossAccountCopyJobFailures', + metric=Metric( + namespace='AWS/Backup', + metric_name='NumberOfCopyJobsFailed', + statistic='Sum', + period=Duration.minutes(15), + ), + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description='Cross-account copy jobs have failed. This affects disaster recovery ' + 'capability and requires immediate investigation.', + ) + copy_job_failures.add_alarm_action(SnsAction(self.alarm_topic)) + + def _create_recovery_point_alarms(self) -> None: + """Create alarms for recovery point issues.""" + + # Partial recovery points + partial_recovery_points = Alarm( + self, + 'PartialRecoveryPoints', + metric=Metric( + namespace='AWS/Backup', + metric_name='NumberOfRecoveryPointsPartial', + statistic='Sum', + period=Duration.hours(1), + ), + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description='Partial recovery points detected. These backups may be incomplete ' + 'and could affect restore capabilities.', + ) + partial_recovery_points.add_alarm_action(SnsAction(self.alarm_topic)) + + # Expired recovery points that couldn't be deleted + expired_recovery_points = Alarm( + self, + 'ExpiredRecoveryPoints', + metric=Metric( + namespace='AWS/Backup', + metric_name='NumberOfRecoveryPointsExpired', + statistic='Sum', + period=Duration.hours(24), + ), + threshold=5, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description='Multiple recovery points have expired but could not be deleted. ' + 'This may cause unexpected storage costs.', + ) + expired_recovery_points.add_alarm_action(SnsAction(self.alarm_topic)) + + def _create_backup_event_rules(self) -> None: + """Create EventBridge rules for real-time backup failure events.""" + + # Backup job failure events + Rule( + self, + 'BackupJobFailureRule', + event_pattern=EventPattern( + source=['aws.backup'], + detail_type=['Backup Job State Change'], + detail={ + 'state': ['FAILED', 'ABORTED'], + }, + ), + targets=[SnsTopic(self.alarm_topic)], + ) + + # Copy job failure events + Rule( + self, + 'CopyJobFailureRule', + event_pattern=EventPattern( + source=['aws.backup'], + detail_type=['Copy Job State Change'], + detail={ + 'state': ['FAILED'], + }, + ), + targets=[SnsTopic(self.alarm_topic)], + ) + + # Recovery point partial/failed events + Rule( + self, + 'RecoveryPointIssuesRule', + event_pattern=EventPattern( + source=['aws.backup'], + detail_type=['Recovery Point State Change'], + detail={ + 'status': ['PARTIAL', 'EXPIRED'], + }, + ), + targets=[SnsTopic(self.alarm_topic)], + ) + + def _create_operational_security_rules(self) -> None: + """Create EventBridge rules for operational security monitoring.""" + + # Manual backup deletion monitoring + Rule( + self, + 'ManualBackupDeletionRule', + event_pattern=EventPattern( + source=['aws.backup'], + detail_type=['Recovery Point State Change'], + detail={ + 'status': ['DELETED'], + # Monitor for manual deletions which may indicate security issues + }, + ), + targets=[SnsTopic(self.alarm_topic)], + ) + + # Backup vault modifications + Rule( + self, + 'BackupVaultModificationRule', + event_pattern=EventPattern( + source=['aws.backup'], + detail_type=['Backup Vault State Change'], + detail={ + 'state': ['MODIFIED', 'DELETED'], + }, + ), + targets=[SnsTopic(self.alarm_topic)], + ) + + # Backup plan modifications/deletions + Rule( + self, + 'BackupPlanChangesRule', + event_pattern=EventPattern( + source=['aws.backup'], + detail_type=['Backup Plan State Change'], + detail={ + 'state': ['MODIFIED', 'DELETED'], + }, + ), + targets=[SnsTopic(self.alarm_topic)], + ) + + def _add_cdk_nag_suppressions(self) -> None: + """Add CDK NAG suppressions for expected patterns in backup infrastructure.""" + + # Add stack-level suppression for inline policies (same reasoning as main Stack class) + NagSuppressions.add_stack_suppressions( + self, + [ + { + 'id': 'HIPAA.Security-IAMNoInlinePolicy', + 'reason': ( + 'CDK allows for granular permissions crafting that is attached to policies ' + 'directly to each resource, by virtue of its Resource.grant_* methods. ' + 'This approach results in an improvement in the principle of least privilege, ' + 'because each resource has permissions specifically crafted for that resource ' + 'and only allows exactly what it needs to do, rather than sharing more coarse managed policies.' + ), + }, + ], + ) + + # Suppress AWS managed policy warnings for backup service roles + # These are the standard AWS managed policies required for AWS Backup service functionality + NagSuppressions.add_resource_suppressions( + self.backup_service_role, + [ + { + 'id': 'AwsSolutions-IAM4', + 'reason': ( + 'AWS Backup service requires these standard AWS managed policies for backup ' + 'and restore operations. These are the minimal required permissions for ' + 'backup service functionality including S3-specific operations.' + ), + }, + ], + ) + + NagSuppressions.add_resource_suppressions( + self.ssn_backup_service_role, + [ + { + 'id': 'AwsSolutions-IAM4', + 'reason': ( + 'AWS Backup service requires these standard AWS managed policies for backup ' + 'and restore operations. SSN backup role uses the same base policies with ' + 'additional customer-managed security restrictions.' + ), + }, + ], + ) diff --git a/backend/common-cdk/tests/__init__.py b/backend/common-cdk/tests/__init__.py index e69de29bb2..50f923b27e 100644 --- a/backend/common-cdk/tests/__init__.py +++ b/backend/common-cdk/tests/__init__.py @@ -0,0 +1,5 @@ +import os +import sys + +# common-cdk is the package root when running pytest from backend/common-cdk +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) diff --git a/backend/common-cdk/tests/fixtures/branding/background.png b/backend/common-cdk/tests/fixtures/branding/background.png new file mode 100644 index 0000000000..f37764b1f7 Binary files /dev/null and b/backend/common-cdk/tests/fixtures/branding/background.png differ diff --git a/backend/common-cdk/tests/fixtures/branding/favicon.ico b/backend/common-cdk/tests/fixtures/branding/favicon.ico new file mode 100644 index 0000000000..f37764b1f7 Binary files /dev/null and b/backend/common-cdk/tests/fixtures/branding/favicon.ico differ diff --git a/backend/common-cdk/tests/fixtures/branding/logo.png b/backend/common-cdk/tests/fixtures/branding/logo.png new file mode 100644 index 0000000000..f37764b1f7 Binary files /dev/null and b/backend/common-cdk/tests/fixtures/branding/logo.png differ diff --git a/backend/common-cdk/tests/fixtures/lambdas/nodejs/dummy/handler.ts b/backend/common-cdk/tests/fixtures/lambdas/nodejs/dummy/handler.ts new file mode 100644 index 0000000000..40edaf6366 --- /dev/null +++ b/backend/common-cdk/tests/fixtures/lambdas/nodejs/dummy/handler.ts @@ -0,0 +1,3 @@ +export const handler = async (_event: unknown): Promise => { + // stub handler for testing +}; diff --git a/backend/common-cdk/tests/fixtures/lambdas/nodejs/yarn.lock b/backend/common-cdk/tests/fixtures/lambdas/nodejs/yarn.lock new file mode 100644 index 0000000000..4a5801883d --- /dev/null +++ b/backend/common-cdk/tests/fixtures/lambdas/nodejs/yarn.lock @@ -0,0 +1,2 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 diff --git a/backend/common-cdk/tests/fixtures/lambdas/python/common/requirements.txt b/backend/common-cdk/tests/fixtures/lambdas/python/common/requirements.txt new file mode 100644 index 0000000000..45ab242915 --- /dev/null +++ b/backend/common-cdk/tests/fixtures/lambdas/python/common/requirements.txt @@ -0,0 +1 @@ +# stub requirements file for CDK synthesis tests diff --git a/backend/common-cdk/tests/fixtures/lambdas/python/provider-data-v1/handlers/ingest.py b/backend/common-cdk/tests/fixtures/lambdas/python/provider-data-v1/handlers/ingest.py new file mode 100644 index 0000000000..7cbcd3fe11 --- /dev/null +++ b/backend/common-cdk/tests/fixtures/lambdas/python/provider-data-v1/handlers/ingest.py @@ -0,0 +1,5 @@ +"""Stub handler for CDK synthesis tests.""" + + +def preprocess_license_ingest(event, context): # noqa: ANN001,ANN201 + pass diff --git a/backend/common-cdk/tests/fixtures/resources/cognito-blocked-notification.txt b/backend/common-cdk/tests/fixtures/resources/cognito-blocked-notification.txt new file mode 100644 index 0000000000..2ec43d0ff8 --- /dev/null +++ b/backend/common-cdk/tests/fixtures/resources/cognito-blocked-notification.txt @@ -0,0 +1 @@ +Your account has been blocked due to suspicious activity. Please contact support if you believe this is an error. diff --git a/backend/common-cdk/tests/fixtures/resources/cognito-no-action-notification.txt b/backend/common-cdk/tests/fixtures/resources/cognito-no-action-notification.txt new file mode 100644 index 0000000000..8d2d7f3781 --- /dev/null +++ b/backend/common-cdk/tests/fixtures/resources/cognito-no-action-notification.txt @@ -0,0 +1 @@ +Suspicious activity was detected on your account. No action was taken at this time. diff --git a/backend/common-cdk/tests/test_access_logs_bucket.py b/backend/common-cdk/tests/test_access_logs_bucket.py new file mode 100644 index 0000000000..c32d24a5ef --- /dev/null +++ b/backend/common-cdk/tests/test_access_logs_bucket.py @@ -0,0 +1,141 @@ +from unittest import TestCase + +from aws_cdk import App, RemovalPolicy, Stack +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_s3 import CfnBucket + +from common_constructs.access_logs_bucket import AccessLogsBucket + + +class TestAccessLogsBucket(TestCase): + def setUp(self): + self.app = App() + self.stack = Stack(self.app, 'TestStack', env={'account': '111122223333', 'region': 'us-east-1'}) + + def test_blocks_all_public_access(self): + AccessLogsBucket(self.stack, 'Bucket') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + { + 'PublicAccessBlockConfiguration': { + 'BlockPublicAcls': True, + 'BlockPublicPolicy': True, + 'IgnorePublicAcls': True, + 'RestrictPublicBuckets': True, + } + }, + ) + + def test_uses_s3_managed_encryption(self): + AccessLogsBucket(self.stack, 'Bucket') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + { + 'BucketEncryption': { + 'ServerSideEncryptionConfiguration': [{'ServerSideEncryptionByDefault': {'SSEAlgorithm': 'AES256'}}] + } + }, + ) + + def test_versioning_enabled(self): + AccessLogsBucket(self.stack, 'Bucket') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + {'VersioningConfiguration': {'Status': 'Enabled'}}, + ) + + def test_intelligent_tiering_lifecycle_transitions_at_day_zero(self): + AccessLogsBucket(self.stack, 'Bucket') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + { + 'LifecycleConfiguration': { + 'Rules': Match.array_with( + [ + Match.object_like( + { + 'Transitions': [ + { + 'StorageClass': 'INTELLIGENT_TIERING', + 'TransitionInDays': 0, + } + ] + } + ) + ] + ) + } + }, + ) + + def test_intelligent_tiering_archival_after_180_days(self): + AccessLogsBucket(self.stack, 'Bucket') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + { + 'IntelligentTieringConfigurations': Match.array_with( + [ + Match.object_like( + { + 'Id': 'ArchiveAfter6Mo', + 'Tierings': Match.array_with( + [Match.object_like({'AccessTier': 'ARCHIVE_ACCESS', 'Days': 180})] + ), + } + ) + ] + ) + }, + ) + + def test_object_lock_enabled_with_90_day_compliance_when_retain(self): + AccessLogsBucket(self.stack, 'Bucket', removal_policy=RemovalPolicy.RETAIN) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + { + 'ObjectLockEnabled': True, + 'ObjectLockConfiguration': { + 'ObjectLockEnabled': 'Enabled', + 'Rule': { + 'DefaultRetention': { + 'Mode': 'COMPLIANCE', + 'Days': 90, + } + }, + }, + }, + ) + + def test_no_object_lock_when_not_retain(self): + AccessLogsBucket(self.stack, 'Bucket', removal_policy=RemovalPolicy.DESTROY) + + template = Template.from_stack(self.stack) + buckets = template.find_resources( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'ObjectLockEnabled': True}}, + ) + self.assertEqual({}, buckets) + + def test_deny_delete_object_resource_policy(self): + AccessLogsBucket(self.stack, 'Bucket') + + template = Template.from_stack(self.stack) + policies = template.find_resources('AWS::S3::BucketPolicy') + deny_delete_found = any( + stmt.get('Effect') == 'Deny' and 's3:DeleteObject' in stmt.get('Action', []) + for policy in policies.values() + for stmt in policy['Properties']['PolicyDocument'].get('Statement', []) + ) + self.assertTrue(deny_delete_found, 'No Deny s3:DeleteObject policy statement found') diff --git a/backend/common-cdk/tests/test_backup_plan.py b/backend/common-cdk/tests/test_backup_plan.py new file mode 100644 index 0000000000..f3f64ff882 --- /dev/null +++ b/backend/common-cdk/tests/test_backup_plan.py @@ -0,0 +1,129 @@ +from unittest import TestCase + +from aws_cdk import App, Stack +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_backup import BackupResource, BackupVault, CfnBackupPlan, CfnBackupSelection +from aws_cdk.aws_iam import Role, ServicePrincipal +from aws_cdk.aws_kms import Key + +from common_constructs.backup_plan import CCBackupPlan + + +class TestCCBackupPlan(TestCase): + def setUp(self): + self.app = App() + self.stack = Stack(self.app, 'TestStack') + self.key = Key(self.stack, 'Key') + self.local_vault = BackupVault(self.stack, 'LocalVault', encryption_key=self.key) + self.cross_account_vault = BackupVault.from_backup_vault_arn( + self.stack, + 'CrossAccountVault', + backup_vault_arn='arn:aws:backup:us-east-1:999999999999:backup-vault:remote-vault', + ) + self.backup_role = Role( + self.stack, + 'BackupRole', + assumed_by=ServicePrincipal('backup.amazonaws.com'), + ) + self.backup_policy = { + 'schedule': {'hour': '5', 'minute': '0'}, + 'delete_after_days': 180, + 'cold_storage_after_days': 30, + } + self.resource = ( + BackupResource.from_dynamo_db_table( + Stack.of(self.stack).node.try_find_child('Dummy') # placeholder; works as construct reference + ) + if False + else None + ) # Use arn-based resource instead + + def _make_plan(self, **kwargs): + from aws_cdk.aws_dynamodb import Attribute, AttributeType, Table + + table = Table( + self.stack, + 'Table', + partition_key=Attribute(name='pk', type=AttributeType.STRING), + ) + return CCBackupPlan( + self.stack, + 'Plan', + backup_plan_name_prefix='test-resource', + backup_resources=[BackupResource.from_dynamo_db_table(table)], + backup_vault=self.local_vault, + backup_service_role=self.backup_role, + cross_account_backup_vault=self.cross_account_vault, + backup_policy=self.backup_policy, + **kwargs, + ) + + def test_backup_plan_name_uses_prefix(self): + self._make_plan() + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBackupPlan.CFN_RESOURCE_TYPE_NAME, + {'BackupPlan': Match.object_like({'BackupPlanName': 'test-resource-BackupPlan'})}, + ) + + def test_rule_uses_delete_after_from_policy(self): + self._make_plan() + + template = Template.from_stack(self.stack) + (plan,) = template.find_resources(CfnBackupPlan.CFN_RESOURCE_TYPE_NAME).values() + rule = plan['Properties']['BackupPlan']['BackupPlanRule'][0] + self.assertEqual(180, rule['Lifecycle']['DeleteAfterDays']) + + def test_rule_uses_cold_storage_after_from_policy(self): + self._make_plan() + + template = Template.from_stack(self.stack) + (plan,) = template.find_resources(CfnBackupPlan.CFN_RESOURCE_TYPE_NAME).values() + rule = plan['Properties']['BackupPlan']['BackupPlanRule'][0] + self.assertEqual(30, rule['Lifecycle']['MoveToColdStorageAfterDays']) + + def test_cross_account_copy_action_is_present(self): + self._make_plan() + + template = Template.from_stack(self.stack) + (plan,) = template.find_resources(CfnBackupPlan.CFN_RESOURCE_TYPE_NAME).values() + rule = plan['Properties']['BackupPlan']['BackupPlanRule'][0] + copy_actions = rule.get('CopyActions', []) + self.assertEqual(1, len(copy_actions)) + self.assertIn('arn:aws:backup:us-east-1:999999999999:backup-vault:remote-vault', str(copy_actions[0])) + + def test_copy_action_lifecycle_matches_primary_rule(self): + self._make_plan() + + template = Template.from_stack(self.stack) + (plan,) = template.find_resources(CfnBackupPlan.CFN_RESOURCE_TYPE_NAME).values() + rule = plan['Properties']['BackupPlan']['BackupPlanRule'][0] + copy_action = rule['CopyActions'][0] + self.assertEqual(180, copy_action['Lifecycle']['DeleteAfterDays']) + self.assertEqual(30, copy_action['Lifecycle']['MoveToColdStorageAfterDays']) + + def test_backup_selection_uses_provided_role(self): + self._make_plan() + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnBackupSelection.CFN_RESOURCE_TYPE_NAME, + { + 'BackupSelection': Match.object_like( + { + 'SelectionName': 'test-resource-Selection', + 'IamRoleArn': { + 'Fn::GetAtt': [self.stack.get_logical_id(self.backup_role.node.default_child), 'Arn'] + }, + } + ) + }, + ) + + def test_backup_selection_includes_provided_resources(self): + self._make_plan() + + template = Template.from_stack(self.stack) + selections = template.find_resources(CfnBackupSelection.CFN_RESOURCE_TYPE_NAME) + self.assertEqual(1, len(selections)) diff --git a/backend/common-cdk/tests/test_bucket.py b/backend/common-cdk/tests/test_bucket.py new file mode 100644 index 0000000000..81200d7b2f --- /dev/null +++ b/backend/common-cdk/tests/test_bucket.py @@ -0,0 +1,138 @@ +from unittest import TestCase + +from aws_cdk import App, Stack +from aws_cdk.assertions import Template +from aws_cdk.aws_s3 import BucketEncryption, CfnBucket + +from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.bucket import Bucket + +TEST_BUCKET_LOGICAL_ID = 'Bucket83908E77' + + +class TestBucket(TestCase): + def setUp(self): + self.app = App() + self.stack = Stack(self.app, 'TestStack', env={'account': '111122223333', 'region': 'us-east-1'}) + self.access_logs_bucket = AccessLogsBucket(self.stack, 'AccessLogs') + + def test_blocks_all_public_access(self): + Bucket(self.stack, 'Bucket', server_access_logs_bucket=self.access_logs_bucket) + + template = Template.from_stack(self.stack) + buckets = template.find_resources( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'PublicAccessBlockConfiguration': { + 'BlockPublicAcls': True, + 'BlockPublicPolicy': True, + 'IgnorePublicAcls': True, + 'RestrictPublicBuckets': True, + } + } + }, + ) + # Both the bucket and the access logs bucket must block all public access + self.assertEqual(len(buckets), 2) + + def test_enforces_ssl(self): + Bucket(self.stack, 'Bucket', server_access_logs_bucket=self.access_logs_bucket) + + template = Template.from_stack(self.stack) + # SSL is enforced via a bucket policy requiring aws:SecureTransport + policies = template.find_resources('AWS::S3::BucketPolicy') + self.assertEqual( + { + 'Properties': { + 'Bucket': {'Ref': TEST_BUCKET_LOGICAL_ID}, + 'PolicyDocument': { + 'Statement': [ + { + 'Action': 's3:*', + 'Condition': {'Bool': {'aws:SecureTransport': 'false'}}, + 'Effect': 'Deny', + 'Principal': {'AWS': '*'}, + 'Resource': [ + {'Fn::GetAtt': ['Bucket83908E77', 'Arn']}, + {'Fn::Join': ['', [{'Fn::GetAtt': ['Bucket83908E77', 'Arn']}, '/*']]}, + ], + } + ], + 'Version': '2012-10-17', + }, + }, + 'Type': 'AWS::S3::BucketPolicy', + }, + policies['BucketPolicyE9A3008A'], + ) + + def test_bucket_owner_enforced_object_ownership(self): + Bucket(self.stack, 'Bucket', server_access_logs_bucket=self.access_logs_bucket) + + template = Template.from_stack(self.stack) + buckets = template.find_resources( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'OwnershipControls': {'Rules': [{'ObjectOwnership': 'BucketOwnerEnforced'}]}}}, + ) + self.assertTrue(TEST_BUCKET_LOGICAL_ID in buckets) + + def test_default_encryption_is_s3_managed(self): + Bucket(self.stack, 'Bucket', server_access_logs_bucket=self.access_logs_bucket) + + template = Template.from_stack(self.stack) + buckets = template.find_resources( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'BucketEncryption': { + 'ServerSideEncryptionConfiguration': [ + {'ServerSideEncryptionByDefault': {'SSEAlgorithm': 'AES256'}} + ] + } + } + }, + ) + self.assertTrue(TEST_BUCKET_LOGICAL_ID in buckets) + + def test_encryption_kwarg_overrides_default(self): + from aws_cdk.aws_kms import Key + + key = Key(self.stack, 'Key') + Bucket( + self.stack, + 'Bucket', + server_access_logs_bucket=self.access_logs_bucket, + encryption=BucketEncryption.KMS, + encryption_key=key, + ) + + template = Template.from_stack(self.stack) + buckets = template.find_resources( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'BucketEncryption': { + 'ServerSideEncryptionConfiguration': [ + {'ServerSideEncryptionByDefault': {'SSEAlgorithm': 'aws:kms'}} + ] + } + } + }, + ) + self.assertTrue(TEST_BUCKET_LOGICAL_ID in buckets) + + def test_server_access_logs_prefix_includes_scope_path_and_construct_id(self): + Bucket(self.stack, 'Bucket', server_access_logs_bucket=self.access_logs_bucket) + + template = Template.from_stack(self.stack) + # The prefix is _logs//// + buckets = template.find_resources( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'LoggingConfiguration': {'LogFilePrefix': '_logs/111122223333/us-east-1/TestStack/Bucket'} + } + }, + ) + self.assertTrue(TEST_BUCKET_LOGICAL_ID in buckets) diff --git a/backend/common-cdk/tests/test_compact_connect_api.py b/backend/common-cdk/tests/test_compact_connect_api.py new file mode 100644 index 0000000000..e639047751 --- /dev/null +++ b/backend/common-cdk/tests/test_compact_connect_api.py @@ -0,0 +1,296 @@ +"""Tests for the CompactConnectApi common construct.""" + +from unittest import TestCase + +from aws_cdk import App, Environment +from aws_cdk.assertions import Template +from aws_cdk.aws_apigateway import CfnRestApi, CfnStage +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_cognito import UserPool as CdkUserPool +from aws_cdk.aws_kms import Key +from aws_cdk.aws_sns import Topic +from aws_cdk.aws_wafv2 import CfnWebACLAssociation + +from common_constructs.compact_connect_api import CompactConnectApi +from common_constructs.stack import AppStack, StandardTags + +_CDK_CONTEXT = { + 'compacts': ['aslp', 'octp', 'coun'], + 'jurisdictions': ['al', 'ak', 'az'], + 'license_types': { + 'aslp': [{'name': 'audiologist', 'abbreviation': 'aud'}], + 'octp': [{'name': 'occupational therapist', 'abbreviation': 'ot'}], + 'coun': [{'name': 'licensed professional counselor', 'abbreviation': 'lpc'}], + }, +} + +_STANDARD_TAGS = StandardTags(project='test', service='test', environment='test') + +_TEST_ENV = Environment(account='111122223333', region='us-east-1') + +_HOSTED_ZONE_CONTEXT = { + 'hosted-zone:account=111122223333:domainName=example.com:region=us-east-1': { + 'Id': 'Z1234567890', + 'Name': 'example.com.', + }, +} + + +def _make_app(extra_context: dict | None = None) -> App: + ctx = dict(_CDK_CONTEXT) + if extra_context: + ctx.update(extra_context) + return App(context=ctx) + + +def _make_stack(app: App, environment_name: str = 'sandbox', *, env=None, **env_ctx_kwargs) -> AppStack: + env_context = {'allow_local_ui': True, 'local_ui_port': '3018'} + env_context.update(env_ctx_kwargs) + kwargs = {} + if env is not None: + kwargs['env'] = env + return AppStack( + app, + 'TestStack', + environment_context=env_context, + environment_name=environment_name, + standard_tags=_STANDARD_TAGS, + **kwargs, + ) + + +def _make_api( + stack: AppStack, + environment_name: str = 'sandbox', + *, + domain_name: str | None = None, + construct_id: str = 'Api', + stage_name_suffix: str | None = None, +) -> CompactConnectApi: + key = Key(stack, f'{construct_id}Key') + topic = Topic(stack, f'{construct_id}AlarmTopic', master_key=key) + user_pool = CdkUserPool(stack, f'{construct_id}StaffUserPool') + kwargs = {} + if stage_name_suffix is not None: + kwargs['stage_name_suffix'] = stage_name_suffix + api = CompactConnectApi( + stack, + construct_id, + environment_name=environment_name, + alarm_topic=topic, + staff_users_user_pool=user_pool, + domain_name=domain_name, + **kwargs, + ) + api.root.add_method('GET') + return api + + +class TestCompactConnectApi(TestCase): + def setUp(self): + self.app = _make_app() + self.stack = _make_stack(self.app) + + # --- stage name --------------------------------------------------------- + + def test_stage_name_defaults_to_environment_with_blue_suffix(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnStage.CFN_RESOURCE_TYPE_NAME, + {'StageName': 'sandbox-blue'}, + ) + + def test_stage_name_suffix_override_is_applied(self): + _make_api(self.stack, stage_name_suffix='green') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnStage.CFN_RESOURCE_TYPE_NAME, + {'StageName': 'sandbox-green'}, + ) + + def test_empty_stage_name_suffix_raises(self): + with self.assertRaises(ValueError): + _make_api(self.stack, stage_name_suffix='') + + def test_whitespace_only_stage_name_suffix_raises(self): + with self.assertRaises(ValueError): + _make_api(self.stack, stage_name_suffix=' ') + + def test_tracing_enabled_on_deployment_stage(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnStage.CFN_RESOURCE_TYPE_NAME, + {'TracingEnabled': True}, + ) + + # --- access log format -------------------------------------------------- + + def test_access_log_format_includes_xray_trace_id(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + stages = template.find_resources(CfnStage.CFN_RESOURCE_TYPE_NAME) + stage_props = list(stages.values())[0]['Properties'] + log_format = stage_props['AccessLogSetting']['Format'] + self.assertIn('xrayTraceId', log_format) + + def test_access_log_format_includes_waf_evaluation(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + stages = template.find_resources(CfnStage.CFN_RESOURCE_TYPE_NAME) + stage_props = list(stages.values())[0]['Properties'] + log_format = stage_props['AccessLogSetting']['Format'] + self.assertIn('wafResponseCode', log_format) + + # --- execute-api endpoint ----------------------------------------------- + + def test_execute_api_endpoint_disabled_for_test_environment(self): + test_app = _make_app(_HOSTED_ZONE_CONTEXT) + test_stack = _make_stack( + test_app, + environment_name='test', + domain_name='example.com', + env=_TEST_ENV, + ) + _make_api( + test_stack, + environment_name='test', + domain_name='api.example.com', + construct_id='TestEnvApi', + ) + + template = Template.from_stack(test_stack) + template.has_resource_properties( + CfnRestApi.CFN_RESOURCE_TYPE_NAME, + {'DisableExecuteApiEndpoint': True}, + ) + template.has_resource_properties( + CfnStage.CFN_RESOURCE_TYPE_NAME, + {'StageName': 'test-blue'}, + ) + template.resource_count_is('AWS::ApiGateway::DomainName', 1) + + def test_execute_api_endpoint_not_disabled_for_sandbox_environment(self): + _make_api(self.stack, environment_name='sandbox') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnRestApi.CFN_RESOURCE_TYPE_NAME, + {'DisableExecuteApiEndpoint': False}, + ) + + # --- WAF association ---------------------------------------------------- + + def test_webacl_associated_with_deployment_stage(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + template.resource_count_is(CfnWebACLAssociation.CFN_RESOURCE_TYPE_NAME, 1) + + def test_custom_domain_configured_when_hosted_zone_present(self): + app = _make_app(_HOSTED_ZONE_CONTEXT) + stack = _make_stack( + app, + domain_name='example.com', + allow_local_ui=True, + local_ui_port='3018', + env=_TEST_ENV, + ) + _make_api(stack, domain_name='api.example.com', construct_id='DomainApi') + + template = Template.from_stack(stack) + template.resource_count_is('AWS::ApiGateway::DomainName', 1) + + # --- alarms ------------------------------------------------------------- + + def test_server_error_alarm_threshold_is_one(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'MetricName': '5XXError', + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + }, + ) + + def test_client_error_alarm_threshold_is_half(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'MetricName': '4XXError', + 'Threshold': 0.5, + 'ComparisonOperator': 'GreaterThanThreshold', + }, + ) + + def test_latency_alarm_threshold_is_25_seconds(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'MetricName': 'Latency', + 'Threshold': 25_000, + 'ComparisonOperator': 'GreaterThanThreshold', + }, + ) + + # --- gateway responses with CORS headers -------------------------------- + + def test_bad_request_body_gateway_response_has_cors_header(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + responses = template.find_resources( + 'AWS::ApiGateway::GatewayResponse', + props={'Properties': {'ResponseType': 'BAD_REQUEST_BODY'}}, + ) + self.assertEqual(1, len(responses)) + (resp,) = responses.values() + self.assertIn( + 'gatewayresponse.header.Access-Control-Allow-Origin', + resp['Properties']['ResponseParameters'], + ) + + def test_unauthorized_gateway_response_has_cors_header(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + responses = template.find_resources( + 'AWS::ApiGateway::GatewayResponse', + props={'Properties': {'ResponseType': 'UNAUTHORIZED'}}, + ) + self.assertEqual(1, len(responses)) + (resp,) = responses.values() + self.assertIn( + 'gatewayresponse.header.Access-Control-Allow-Origin', + resp['Properties']['ResponseParameters'], + ) + + def test_access_denied_gateway_response_has_cors_header(self): + _make_api(self.stack) + + template = Template.from_stack(self.stack) + responses = template.find_resources( + 'AWS::ApiGateway::GatewayResponse', + props={'Properties': {'ResponseType': 'ACCESS_DENIED'}}, + ) + self.assertEqual(1, len(responses)) + (resp,) = responses.values() + self.assertIn( + 'gatewayresponse.header.Access-Control-Allow-Origin', + resp['Properties']['ResponseParameters'], + ) diff --git a/backend/common-cdk/tests/test_constants.py b/backend/common-cdk/tests/test_constants.py new file mode 100644 index 0000000000..d48c24d24e --- /dev/null +++ b/backend/common-cdk/tests/test_constants.py @@ -0,0 +1,11 @@ +from unittest import TestCase + +from common_constructs.constants import BETA_ENV_NAME, PROD_ENV_NAME + + +class TestConstants(TestCase): + def test_prod_env_name(self): + self.assertEqual('prod', PROD_ENV_NAME) + + def test_beta_env_name(self): + self.assertEqual('beta', BETA_ENV_NAME) diff --git a/backend/common-cdk/tests/test_nodejs_function.py b/backend/common-cdk/tests/test_nodejs_function.py new file mode 100644 index 0000000000..1715e480c2 --- /dev/null +++ b/backend/common-cdk/tests/test_nodejs_function.py @@ -0,0 +1,110 @@ +import os +from unittest import TestCase +from unittest.mock import patch + +from aws_cdk import App, Duration, Stack +from aws_cdk.assertions import Template +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_lambda import CfnFunction, Runtime +from aws_cdk.aws_logs import CfnLogGroup, RetentionDays +from aws_cdk.aws_sns import Topic + +from common_constructs.nodejs_function import NodejsFunction + +_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures') +_os_path_join = os.path.join + + +def _join_with_nodejs_fixtures(*parts: str) -> str: + """Redirect lambdas/nodejs paths to tests/fixtures so the Node.js JSII kernel can locate them.""" + if len(parts) >= 2 and parts[0] == 'lambdas' and parts[1] == 'nodejs': + return _os_path_join(_FIXTURES_DIR, *parts) + return _os_path_join(*parts) + + +def _make_fn(stack: Stack, construct_id: str = 'Fn', **kwargs) -> NodejsFunction: + with patch('common_constructs.nodejs_function.os.path.join', side_effect=_join_with_nodejs_fixtures): + return NodejsFunction(stack, construct_id, lambda_dir='dummy', **kwargs) + + +class TestNodejsFunction(TestCase): + def setUp(self): + self.app = App(context={'aws:cdk:bundling-stacks': []}) + self.stack = Stack(self.app, 'TestStack') + + def test_runtime_is_nodejs_24_x(self): + _make_fn(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnFunction.CFN_RESOURCE_TYPE_NAME, + {'Runtime': Runtime.NODEJS_24_X.to_string()}, + ) + + def test_default_timeout_is_28_seconds(self): + _make_fn(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnFunction.CFN_RESOURCE_TYPE_NAME, + {'Timeout': 28}, + ) + + def test_timeout_kwarg_overrides_default(self): + _make_fn(self.stack, timeout=Duration.seconds(10)) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnFunction.CFN_RESOURCE_TYPE_NAME, + {'Timeout': 10}, + ) + + def test_default_log_retention_is_one_month(self): + _make_fn(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnLogGroup.CFN_RESOURCE_TYPE_NAME, + {'RetentionInDays': 30}, + ) + + def test_custom_log_retention_is_respected(self): + _make_fn(self.stack, log_retention=RetentionDays.ONE_YEAR) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnLogGroup.CFN_RESOURCE_TYPE_NAME, + {'RetentionInDays': 365}, + ) + + def test_throttle_alarm_wired_to_topic_when_alarm_topic_provided(self): + topic = Topic(self.stack, 'AlarmTopic') + _make_fn(self.stack, alarm_topic=topic) + + template = Template.from_stack(self.stack) + alarms = template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) + self.assertEqual(1, len(alarms)) + (alarm,) = alarms.values() + self.assertEqual([{'Ref': 'AlarmTopicD01E77F9'}], alarm['Properties']['AlarmActions']) + + def test_no_alarm_when_alarm_topic_omitted(self): + _make_fn(self.stack) + + template = Template.from_stack(self.stack) + self.assertEqual({}, template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME)) + + def test_throttle_alarm_triggers_on_one_throttle(self): + topic = Topic(self.stack, 'AlarmTopic') + _make_fn(self.stack, alarm_topic=topic) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + 'EvaluationPeriods': 1, + 'MetricName': 'Throttles', + 'Namespace': 'AWS/Lambda', + }, + ) diff --git a/backend/compact-connect/tests/common_constructs/test_queue_event_listener.py b/backend/common-cdk/tests/test_queue_event_listener.py similarity index 100% rename from backend/compact-connect/tests/common_constructs/test_queue_event_listener.py rename to backend/common-cdk/tests/test_queue_event_listener.py diff --git a/backend/compact-connect/tests/common_constructs/test_queued_lambda_processor.py b/backend/common-cdk/tests/test_queued_lambda_processor.py similarity index 100% rename from backend/compact-connect/tests/common_constructs/test_queued_lambda_processor.py rename to backend/common-cdk/tests/test_queued_lambda_processor.py diff --git a/backend/common-cdk/tests/test_security_profile.py b/backend/common-cdk/tests/test_security_profile.py new file mode 100644 index 0000000000..6317a090f3 --- /dev/null +++ b/backend/common-cdk/tests/test_security_profile.py @@ -0,0 +1,14 @@ +from unittest import TestCase + +from common_constructs.security_profile import SecurityProfile + + +class TestSecurityProfile(TestCase): + def test_recommended_value_is_one(self): + self.assertEqual(1, SecurityProfile.RECOMMENDED.value) + + def test_vulnerable_value_is_two(self): + self.assertEqual(2, SecurityProfile.VULNERABLE.value) + + def test_members_are_stable(self): + self.assertEqual({'RECOMMENDED', 'VULNERABLE'}, {m.name for m in SecurityProfile}) diff --git a/backend/common-cdk/tests/test_service_principal_name.py b/backend/common-cdk/tests/test_service_principal_name.py new file mode 100644 index 0000000000..4f933668d8 --- /dev/null +++ b/backend/common-cdk/tests/test_service_principal_name.py @@ -0,0 +1,24 @@ +from unittest import TestCase + +from common_constructs.service_principal_name import ServicePrincipalName + + +class TestServicePrincipalName(TestCase): + def test_lambda_principal_value(self): + self.assertEqual('lambda.amazonaws.com', ServicePrincipalName.LAMBDA.value) + + def test_dynamodb_principal_value(self): + self.assertEqual('dynamodb.amazonaws.com', ServicePrincipalName.DYNAMODB.value) + + def test_logs_delivery_principal_value(self): + self.assertEqual('delivery.logs.amazonaws.com', ServicePrincipalName.LOGS_DELIVERY.value) + + def test_s3_principal_value(self): + self.assertEqual('s3.amazonaws.com', ServicePrincipalName.S3.value) + + def test_all_members_have_expected_service_arn_suffix(self): + for member in ServicePrincipalName: + self.assertTrue( + member.value.endswith('.amazonaws.com'), + f'{member.name} value should be an AWS service principal: {member.value}', + ) diff --git a/backend/common-cdk/tests/test_ssm_parameter_utility.py b/backend/common-cdk/tests/test_ssm_parameter_utility.py new file mode 100644 index 0000000000..26caaaaf59 --- /dev/null +++ b/backend/common-cdk/tests/test_ssm_parameter_utility.py @@ -0,0 +1,74 @@ +from unittest import TestCase + +from aws_cdk import App, Stack +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_events import EventBus +from aws_cdk.aws_ssm import CfnParameter + +from common_constructs.ssm_parameter_utility import ( + DATA_EVENT_BUS_ARN_SSM_PARAMETER_NAME, + SSMParameterUtility, +) + + +class TestSSMParameterUtility(TestCase): + def setUp(self): + self.app = App() + self.stack = Stack(self.app, 'TestStack') + self.event_bus = EventBus(self.stack, 'DataEventBus') + + def test_parameter_name_constant_value(self): + self.assertEqual( + '/deployment/event-bridge/event-bus/data-event-bus-arn', + DATA_EVENT_BUS_ARN_SSM_PARAMETER_NAME, + ) + + def test_set_data_event_bus_arn_ssm_parameter_writes_correct_name(self): + SSMParameterUtility.set_data_event_bus_arn_ssm_parameter(self.stack, self.event_bus) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnParameter.CFN_RESOURCE_TYPE_NAME, + {'Name': DATA_EVENT_BUS_ARN_SSM_PARAMETER_NAME}, + ) + + def test_set_data_event_bus_arn_ssm_parameter_stores_event_bus_arn(self): + SSMParameterUtility.set_data_event_bus_arn_ssm_parameter(self.stack, self.event_bus) + + template = Template.from_stack(self.stack) + # The value must reference the EventBusArn attribute of the event bus. + template.has_resource_properties( + CfnParameter.CFN_RESOURCE_TYPE_NAME, + { + 'Value': {'Fn::GetAtt': [Match.string_like_regexp('DataEventBus'), 'Arn']}, + }, + ) + + def test_load_data_event_bus_from_ssm_parameter_does_not_create_direct_ref(self): + """load_* reads from SSM and returns an EventBus without creating a CloudFormation Output.""" + consumer_stack = Stack(self.app, 'ConsumerStack') + bus = SSMParameterUtility.load_data_event_bus_from_ssm_parameter(consumer_stack) + + # The bus ARN must be resolved through SSM (a dynamic reference), not a direct Fn::ImportValue. + self.assertIsNotNone(bus) + template = Template.from_stack(consumer_stack) + rendered = template.to_json() + self.assertNotIn('Fn::ImportValue', str(rendered)) + + def test_load_and_set_parameter_names_match(self): + """The parameter name used to write must be the same one used to read.""" + SSMParameterUtility.set_data_event_bus_arn_ssm_parameter(self.stack, self.event_bus) + + consumer_stack = Stack(self.app, 'ConsumerStack') + SSMParameterUtility.load_data_event_bus_from_ssm_parameter(consumer_stack) + + # Writer stack creates the parameter with the well-known name + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnParameter.CFN_RESOURCE_TYPE_NAME, + {'Name': DATA_EVENT_BUS_ARN_SSM_PARAMETER_NAME}, + ) + # Consumer stack uses SSM dynamic reference – no direct CloudFormation cross-stack dependency + consumer_template = Template.from_stack(consumer_stack) + consumer_rendered = consumer_template.to_json() + self.assertNotIn('Fn::ImportValue', str(consumer_rendered)) diff --git a/backend/common-cdk/tests/test_ssn_table.py b/backend/common-cdk/tests/test_ssn_table.py new file mode 100644 index 0000000000..9d37caf275 --- /dev/null +++ b/backend/common-cdk/tests/test_ssn_table.py @@ -0,0 +1,640 @@ +"""Tests for the SSNTable common construct.""" + +import os +from unittest import TestCase +from unittest.mock import patch + +from aws_cdk import App, RemovalPolicy +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_backup import CfnBackupPlan +from aws_cdk.aws_dynamodb import CfnTable +from aws_cdk.aws_events import EventBus +from aws_cdk.aws_iam import CfnPolicy, CfnRole +from aws_cdk.aws_kms import CfnKey +from aws_cdk.aws_lambda import Runtime +from aws_cdk.aws_sns import Topic + +from common_constructs.ssn_table import ( + SSNTable, +) +from common_constructs.stack import AppStack, StandardTags +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack + +_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures') +_os_path_join = os.path.join + +_CDK_CONTEXT = { + 'compacts': ['aslp', 'octp', 'coun'], + 'jurisdictions': ['al', 'ak', 'az'], + 'license_types': { + 'aslp': [{'name': 'audiologist', 'abbreviation': 'aud'}], + 'octp': [{'name': 'occupational therapist', 'abbreviation': 'ot'}], + 'coun': [{'name': 'licensed professional counselor', 'abbreviation': 'lpc'}], + }, + 'aws:cdk:bundling-stacks': [], +} + +_APP_ENV_CONTEXT = {'allow_local_ui': True, 'local_ui_port': '3018'} + +_BACKUP_CONFIG = { + 'backup_account_id': '123456789012', + 'backup_region': 'us-east-1', + 'general_vault_name': 'test-general-vault', + 'ssn_vault_name': 'test-ssn-vault', +} + +_BACKUP_POLICY = { + 'schedule': {'hour': '5', 'minute': '0'}, + 'delete_after_days': 180, + 'cold_storage_after_days': 30, +} + +_ENVIRONMENT_CONTEXT_NO_BACKUP = { + 'backup_enabled': False, + 'backup_policies': {'general_data': _BACKUP_POLICY}, +} + +_ENVIRONMENT_CONTEXT_WITH_BACKUP = { + 'backup_enabled': True, + 'backup_policies': {'general_data': _BACKUP_POLICY}, +} + +_STANDARD_TAGS = StandardTags(project='test', service='test', environment='test') + +_DR_LAMBDA_ROLE_DESCRIPTION = 'Dedicated role for SSN table disaster recovery Lambda operations' +_DR_STEP_FUNCTION_ROLE_DESCRIPTION = 'Dedicated role for SSN table disaster recovery Step Function operations' + + +def _get_ssn_table_logical_id(template: Template) -> str: + tables = template.find_resources(CfnTable.CFN_RESOURCE_TYPE_NAME) + if len(tables) != 1: + raise AssertionError(f'Expected exactly one DynamoDB table, found {len(tables)}') + return next(iter(tables.keys())) + + +def _get_ssn_key_logical_id(template: Template) -> str: + keys = template.find_resources(CfnKey.CFN_RESOURCE_TYPE_NAME) + if len(keys) != 1: + raise AssertionError(f'Expected exactly one KMS key, found {len(keys)}') + return next(iter(keys.keys())) + + +def _get_ssn_key_policy_document(template: Template) -> dict: + keys = template.find_resources(CfnKey.CFN_RESOURCE_TYPE_NAME) + if len(keys) != 1: + raise AssertionError(f'Expected exactly one KMS key, found {len(keys)}') + (key,) = keys.values() + return key['Properties']['KeyPolicy'] + + +def _get_role_logical_id_by_description(template: Template, description: str) -> str: + roles = template.find_resources(CfnRole.CFN_RESOURCE_TYPE_NAME) + matches = [lid for lid, role in roles.items() if role['Properties'].get('Description') == description] + if len(matches) != 1: + raise AssertionError(f'Expected exactly one role with description {description!r}, found {len(matches)}') + return matches[0] + + +def _policy_attached_to_role(role_logical_id: str, roles_property: list) -> bool: + for role in roles_property: + if role == role_logical_id: + return True + if isinstance(role, dict) and role.get('Ref') == role_logical_id: + return True + return False + + +def _get_role_policy_document_by_description(template: Template, description: str) -> dict: + role_logical_id = _get_role_logical_id_by_description(template, description) + policies = template.find_resources(CfnPolicy.CFN_RESOURCE_TYPE_NAME) + matches = [ + policy['Properties']['PolicyDocument'] + for policy in policies.values() + if _policy_attached_to_role(role_logical_id, policy['Properties'].get('Roles', [])) + ] + if len(matches) != 1: + raise AssertionError(f'Expected exactly one inline policy for role {description!r}, found {len(matches)}') + return matches[0] + + +def _get_ssn_table_resource_policy_document(template: Template) -> dict: + tables = template.find_resources(CfnTable.CFN_RESOURCE_TYPE_NAME) + if len(tables) != 1: + raise AssertionError(f'Expected exactly one DynamoDB table, found {len(tables)}') + (table,) = tables.values() + return table['Properties']['ResourcePolicy']['PolicyDocument'] + + +def _dynamodb_ssn_index_not_resource_arn_join() -> dict: + return { + 'Fn::Join': [ + '', + [ + 'arn:', + {'Ref': 'AWS::Partition'}, + ':dynamodb:', + {'Ref': 'AWS::Region'}, + ':', + {'Ref': 'AWS::AccountId'}, + ':table/ssn-table-DataEventsLog/index/ssnIndex', + ], + ], + } + + +def _join_with_python_fixtures(*parts: str) -> str: + """Redirect lambdas/python paths to tests/fixtures so CDK asset bundling resolves correctly.""" + if len(parts) >= 2 and parts[0] == 'lambdas' and parts[1] == 'python': + return _os_path_join(_FIXTURES_DIR, *parts) + return _os_path_join(*parts) + + +def _make_ssn_table( + stack, + removal_policy: RemovalPolicy = RemovalPolicy.DESTROY, + backup_infrastructure_stack=None, + environment_context: dict = None, +) -> SSNTable: + from common_constructs.python_common_layer_versions import PythonCommonLayerVersions + from common_constructs.python_function import PythonFunction + + if environment_context is None: + environment_context = _ENVIRONMENT_CONTEXT_NO_BACKUP + + alarm_topic = Topic(stack, 'AlarmTopic') + data_event_bus = EventBus(stack, 'DataEventBus') + + with ( + patch('common_constructs.python_function.os.path.join', side_effect=_join_with_python_fixtures), + patch('common_constructs.python_common_layer_versions.os.path.join', side_effect=_join_with_python_fixtures), + ): + # Reset per-test so layers are always created in the current stack (avoids cross-App references). + PythonFunction._common_layer_versions = None # noqa: SLF001 + PythonCommonLayerVersions(stack, 'CommonLayers', compatible_runtimes=[Runtime.PYTHON_3_14]) + + return SSNTable( + stack, + 'SSNTable', + removal_policy=removal_policy, + data_event_bus=data_event_bus, + alarm_topic=alarm_topic, + backup_infrastructure_stack=backup_infrastructure_stack, + environment_context=environment_context, + ) + + +class TestSSNTableConfig(TestCase): + @classmethod + def setUpClass(cls): + app = App(context=_CDK_CONTEXT) + cls.stack = AppStack( + app, + 'TestStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context=_APP_ENV_CONTEXT, + ) + _make_ssn_table(cls.stack) + cls.template = Template.from_stack(cls.stack) + + def test_table_name_is_ssn_table_data_events_log(self): + self.template.has_resource_properties( + CfnTable.CFN_RESOURCE_TYPE_NAME, + {'TableName': 'ssn-table-DataEventsLog'}, + ) + + def test_billing_mode_is_pay_per_request(self): + self.template.has_resource_properties( + CfnTable.CFN_RESOURCE_TYPE_NAME, + {'BillingMode': 'PAY_PER_REQUEST'}, + ) + + def test_customer_managed_kms_encryption(self): + self.template.has_resource_properties( + CfnTable.CFN_RESOURCE_TYPE_NAME, + { + 'SSESpecification': { + 'SSEEnabled': True, + 'SSEType': 'KMS', + } + }, + ) + + def test_pitr_enabled(self): + self.template.has_resource_properties( + CfnTable.CFN_RESOURCE_TYPE_NAME, + {'PointInTimeRecoverySpecification': {'PointInTimeRecoveryEnabled': True}}, + ) + + def test_deletion_protection_enabled_when_retain(self): + app = App(context=_CDK_CONTEXT) + stack = AppStack( + app, + 'RetainStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context=_APP_ENV_CONTEXT, + ) + _make_ssn_table(stack, removal_policy=RemovalPolicy.RETAIN) + + template = Template.from_stack(stack) + template.has_resource_properties( + CfnTable.CFN_RESOURCE_TYPE_NAME, + {'DeletionProtectionEnabled': True}, + ) + + def test_ssn_index_gsi_exists(self): + self.template.has_resource_properties( + CfnTable.CFN_RESOURCE_TYPE_NAME, + { + 'GlobalSecondaryIndexes': Match.array_with( + [ + Match.object_like( + { + 'IndexName': 'ssnIndex', + 'KeySchema': Match.array_with( + [Match.object_like({'AttributeName': 'providerIdGSIpk'})] + ), + } + ) + ] + ) + }, + ) + + +class TestSSNTableResourcePolicy(TestCase): + @classmethod + def setUpClass(cls): + app = App(context=_CDK_CONTEXT) + cls.stack = AppStack( + app, + 'TestStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context=_APP_ENV_CONTEXT, + ) + _make_ssn_table(cls.stack) + cls.template = Template.from_stack(cls.stack) + + def test_table_resource_policy_document(self): + """Snapshot of the full DynamoDB resource policy; any intentional change should update this test.""" + dr_lambda_role_id = _get_role_logical_id_by_description(self.template, _DR_LAMBDA_ROLE_DESCRIPTION) + + expected = { + 'Version': '2012-10-17', + 'Statement': [ + { + 'Action': 'dynamodb:CreateBackup', + 'Condition': { + 'StringNotEquals': { + 'aws:PrincipalServiceName': 'dynamodb.amazonaws.com', + } + }, + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + }, + { + 'Action': [ + 'dynamodb:BatchGetItem', + 'dynamodb:BatchWriteItem', + 'dynamodb:PartiQL*', + 'dynamodb:Scan', + ], + 'Condition': { + 'StringNotEquals': { + 'aws:PrincipalArn': [{'Fn::GetAtt': [dr_lambda_role_id, 'Arn']}], + 'aws:PrincipalServiceName': 'dynamodb.amazonaws.com', + } + }, + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + }, + { + 'Action': [ + 'dynamodb:GetItem', + 'dynamodb:Query', + 'dynamodb:ConditionCheckItem', + ], + 'Effect': 'Deny', + 'NotResource': _dynamodb_ssn_index_not_resource_arn_join(), + 'Principal': '*', + }, + ], + } + + self.assertEqual(expected, _get_ssn_table_resource_policy_document(self.template)) + + +class TestSSNTableKMSKey(TestCase): + @classmethod + def setUpClass(cls): + app = App(context=_CDK_CONTEXT) + cls.stack = AppStack( + app, + 'TestStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context=_APP_ENV_CONTEXT, + ) + _make_ssn_table(cls.stack) + cls.template = Template.from_stack(cls.stack) + + def test_kms_key_rotation_enabled(self): + self.template.has_resource_properties( + CfnKey.CFN_RESOURCE_TYPE_NAME, + {'EnableKeyRotation': True}, + ) + + def test_kms_key_alias_is_ssn_key(self): + self.template.has_resource_properties( + 'AWS::KMS::Alias', + {'AliasName': 'alias/ssn-key'}, + ) + + def test_ssn_key_policy_document(self): + """Snapshot of the full SSN KMS key policy; any intentional change should update this test.""" + expected = { + 'Statement': [ + { + 'Action': 'kms:*', + 'Effect': 'Allow', + 'Principal': { + 'AWS': { + 'Fn::Join': [ + '', + ['arn:', {'Ref': 'AWS::Partition'}, ':iam::', {'Ref': 'AWS::AccountId'}, ':root'], + ] + } + }, + 'Resource': '*', + }, + { + 'Action': ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + 'Condition': { + 'StringNotEquals': { + 'aws:PrincipalArn': [ + {'Fn::GetAtt': ['SSNTableLicenseIngestRoleC883020F', 'Arn']}, + {'Fn::GetAtt': ['SSNTableLicenseUploadRole46F85F47', 'Arn']}, + {'Fn::GetAtt': ['DisasterRecoveryLambdaRole4BDEAE6F', 'Arn']}, + {'Fn::GetAtt': ['SSNTableDisasterRecoveryStepFunctionRoleCE265991', 'Arn']}, + ], + 'aws:PrincipalServiceName': ['dynamodb.amazonaws.com', 'events.amazonaws.com'], + } + }, + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + }, + ], + 'Version': '2012-10-17', + } + self.assertEqual(expected, _get_ssn_key_policy_document(self.template)) + + +class TestSSNTableRoles(TestCase): + @classmethod + def setUpClass(cls): + app = App(context=_CDK_CONTEXT) + cls.stack = AppStack( + app, + 'TestStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context=_APP_ENV_CONTEXT, + ) + _make_ssn_table(cls.stack) + cls.template = Template.from_stack(cls.stack) + + def _get_role_names(self) -> set[str]: + roles = self.template.find_resources(CfnRole.CFN_RESOURCE_TYPE_NAME) + return {r['Properties'].get('Description', '') for r in roles.values()} + + def test_license_ingest_role_exists(self): + template_json = self.template.to_json() + self.assertIn('Dedicated role for license ingest', str(template_json)) + + def test_license_upload_role_exists(self): + template_json = self.template.to_json() + self.assertIn('Dedicated role for lambdas that upload license records', str(template_json)) + + def test_disaster_recovery_lambda_role_assumes_lambda(self): + self.template.has_resource_properties( + CfnRole.CFN_RESOURCE_TYPE_NAME, + { + 'AssumeRolePolicyDocument': Match.object_like( + { + 'Statement': Match.array_with( + [Match.object_like({'Principal': {'Service': 'lambda.amazonaws.com'}})] + ) + } + ), + 'Description': 'Dedicated role for SSN table disaster recovery Lambda operations', + }, + ) + + def test_disaster_recovery_step_function_role_assumes_states(self): + self.template.has_resource_properties( + CfnRole.CFN_RESOURCE_TYPE_NAME, + { + 'AssumeRolePolicyDocument': Match.object_like( + { + 'Statement': Match.array_with( + [Match.object_like({'Principal': {'Service': 'states.amazonaws.com'}})] + ) + } + ) + }, + ) + + def test_disaster_recovery_step_function_role_policy_document(self): + """Snapshot of the full inline policy for the DR Step Function role.""" + table_id = _get_ssn_table_logical_id(self.template) + key_id = _get_ssn_key_logical_id(self.template) + + expected = { + 'Version': '2012-10-17', + 'Statement': [ + { + 'Action': [ + 'dynamodb:RestoreTableToPointInTime', + 'dynamodb:DescribeTable', + 'dynamodb:BatchWriteItem', + 'dynamodb:DeleteItem', + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:Query', + 'dynamodb:Scan', + 'dynamodb:UpdateItem', + ], + 'Effect': 'Allow', + 'Resource': [ + {'Fn::GetAtt': [table_id, 'Arn']}, + { + 'Fn::Join': [ + '', + [{'Fn::GetAtt': [table_id, 'Arn']}, '/backup/*'], + ] + }, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:dynamodb:', + {'Ref': 'AWS::Region'}, + ':', + {'Ref': 'AWS::AccountId'}, + ':table/DR-TEMP-SSN-*', + ], + ] + }, + { + 'Fn::Join': [ + '', + [ + 'arn:aws:dynamodb:', + {'Ref': 'AWS::Region'}, + ':', + {'Ref': 'AWS::AccountId'}, + ':table/DR-TEMP-SSN-*/index/*', + ], + ] + }, + ], + }, + { + 'Action': 'states:StartExecution', + 'Effect': 'Allow', + 'Resource': { + 'Fn::Join': [ + '', + [ + 'arn:', + {'Ref': 'AWS::Partition'}, + ':states:', + {'Ref': 'AWS::Region'}, + ':', + {'Ref': 'AWS::AccountId'}, + ':stateMachine:SSNTable-SSNSyncTableData', + ], + ], + }, + }, + { + 'Action': [ + 'events:PutTargets', + 'events:PutRule', + 'events:DescribeRule', + ], + 'Effect': 'Allow', + 'Resource': { + 'Fn::Join': [ + '', + [ + 'arn:aws:events:', + {'Ref': 'AWS::Region'}, + ':', + {'Ref': 'AWS::AccountId'}, + ':rule/StepFunctionsGetEventsForStepFunctionsExecutionRule', + ], + ], + }, + }, + { + 'Action': [ + 'kms:DescribeKey', + 'kms:CreateGrant', + 'kms:Decrypt', + 'kms:Encrypt', + 'kms:GenerateDataKey*', + 'kms:ReEncrypt*', + ], + 'Effect': 'Allow', + 'Resource': {'Fn::GetAtt': [key_id, 'Arn']}, + }, + ], + } + + actual = _get_role_policy_document_by_description(self.template, _DR_STEP_FUNCTION_ROLE_DESCRIPTION) + self.assertEqual(expected, actual) + + +class TestSSNTableBackupEnabled(TestCase): + @classmethod + def setUpClass(cls): + app = App(context=_CDK_CONTEXT) + cls.main_stack = AppStack( + app, + 'MainStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context=_APP_ENV_CONTEXT, + ) + backup_alarm_topic = Topic(cls.main_stack, 'BackupAlarmTopic') + + cls.backup_infrastructure_stack = BackupInfrastructureStack( + cls.main_stack, + 'BackupInfrastructure', + environment_name='sandbox', + backup_config=_BACKUP_CONFIG, + alarm_topic=backup_alarm_topic, + removal_policy=RemovalPolicy.DESTROY, + ) + + _make_ssn_table( + cls.main_stack, + backup_infrastructure_stack=cls.backup_infrastructure_stack, + environment_context=_ENVIRONMENT_CONTEXT_WITH_BACKUP, + ) + cls.template = Template.from_stack(cls.main_stack) + + def test_backup_plan_created_when_backup_enabled(self): + backup_plans = self.template.find_resources(CfnBackupPlan.CFN_RESOURCE_TYPE_NAME) + self.assertGreaterEqual(len(backup_plans), 1) + + def test_ssn_key_policy_document_when_backup_enabled(self): + """Snapshot of the SSN KMS key policy with backup enabled (backup role on DENY allowlist).""" + expected = { + 'Statement': [ + { + 'Action': 'kms:*', + 'Effect': 'Allow', + 'Principal': { + 'AWS': { + 'Fn::Join': [ + '', + ['arn:', {'Ref': 'AWS::Partition'}, ':iam::', {'Ref': 'AWS::AccountId'}, ':root'], + ] + } + }, + 'Resource': '*', + }, + { + 'Action': ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + 'Condition': { + 'StringNotEquals': { + 'aws:PrincipalArn': [ + {'Fn::GetAtt': ['SSNTableLicenseIngestRoleC883020F', 'Arn']}, + {'Fn::GetAtt': ['SSNTableLicenseUploadRole46F85F47', 'Arn']}, + {'Fn::GetAtt': ['DisasterRecoveryLambdaRole4BDEAE6F', 'Arn']}, + {'Fn::GetAtt': ['SSNTableDisasterRecoveryStepFunctionRoleCE265991', 'Arn']}, + { + 'Fn::GetAtt': [ + 'BackupInfrastructureNestedStackBackupInfrastructureNestedStackResource71C96FBD', + 'Outputs.MainStackBackupInfrastructureSSNBackupServiceRoleEB4E841DArn', + ] + }, + ], + 'aws:PrincipalServiceName': ['dynamodb.amazonaws.com', 'events.amazonaws.com'], + } + }, + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + }, + ], + 'Version': '2012-10-17', + } + self.assertEqual(expected, _get_ssn_key_policy_document(self.template)) diff --git a/backend/common-cdk/tests/test_stack.py b/backend/common-cdk/tests/test_stack.py new file mode 100644 index 0000000000..3027384128 --- /dev/null +++ b/backend/common-cdk/tests/test_stack.py @@ -0,0 +1,113 @@ +from unittest import TestCase + +from aws_cdk import App, Environment + +from common_constructs.stack import AppStack, Stack, StandardTags + +_TEST_ENV = Environment(account='111122223333', region='us-east-1') + +_CDK_CONTEXT = { + 'compacts': ['aslp', 'octp', 'coun'], + 'jurisdictions': ['al', 'ak', 'az'], + 'license_types': { + 'aslp': [{'name': 'audiologist', 'abbreviation': 'aud'}], + 'octp': [{'name': 'occupational therapist', 'abbreviation': 'ot'}], + 'coun': [{'name': 'licensed professional counselor', 'abbreviation': 'lpc'}], + }, + 'hosted-zone:account=111122223333:domainName=example.com:region=us-east-1': { + 'Id': 'Z1234567890', + 'Name': 'example.com.', + }, +} + +_STANDARD_TAGS = StandardTags(project='test', service='test', environment='test') + + +class TestStackLicenseContext(TestCase): + def setUp(self): + self.app = App(context=_CDK_CONTEXT) + self.stack = Stack(self.app, 'BaseStack', standard_tags=_STANDARD_TAGS, environment_name='sandbox') + + def test_license_type_names_flattens_all_compacts(self): + self.assertEqual( + ['audiologist', 'occupational therapist', 'licensed professional counselor'], self.stack.license_type_names + ) + + def test_license_type_abbreviations_flattens_all_compacts(self): + self.assertEqual(['aud', 'ot', 'lpc'], self.stack.license_type_abbreviations) + + def test_license_types_returns_context_dict(self): + self.assertEqual(_CDK_CONTEXT['license_types'], self.stack.license_types) + + +class TestAppStack(TestCase): + def setUp(self): + self.app = App(context=_CDK_CONTEXT) + + def test_prod_environment_requires_domain_name(self): + with self.assertRaises(ValueError): + AppStack( + self.app, + 'ProdStack', + standard_tags=_STANDARD_TAGS, + environment_name='prod', + environment_context={}, + env=_TEST_ENV, + ) + + def test_domain_properties_derived_from_hosted_zone(self): + stack = AppStack( + self.app, + 'DomainStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context={'domain_name': 'example.com'}, + env=_TEST_ENV, + ) + self.assertEqual('api.example.com', stack.api_domain_name) + self.assertEqual('state-api.example.com', stack.state_api_domain_name) + self.assertEqual('search.example.com', stack.search_api_domain_name) + self.assertEqual('app.example.com', stack.ui_domain_name) + + def test_ui_domain_name_override_takes_precedence(self): + stack = AppStack( + self.app, + 'OverrideStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context={ + 'domain_name': 'example.com', + 'ui_domain_name_override': 'custom.example.com', + }, + env=_TEST_ENV, + ) + self.assertEqual('custom.example.com', stack.ui_domain_name) + + def test_allowed_origins_includes_ui_and_local_when_configured(self): + stack = AppStack( + self.app, + 'CorsStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context={ + 'domain_name': 'example.com', + 'allow_local_ui': True, + 'local_ui_port': '3018', + }, + env=_TEST_ENV, + ) + self.assertEqual( + ['https://app.example.com', 'http://localhost:3018'], + stack.allowed_origins, + ) + + def test_common_env_vars_includes_api_base_url_when_domain_configured(self): + stack = AppStack( + self.app, + 'EnvStack', + standard_tags=_STANDARD_TAGS, + environment_name='sandbox', + environment_context={'domain_name': 'example.com'}, + env=_TEST_ENV, + ) + self.assertEqual('https://api.example.com', stack.common_env_vars['API_BASE_URL']) diff --git a/backend/common-cdk/tests/test_user_pool.py b/backend/common-cdk/tests/test_user_pool.py new file mode 100644 index 0000000000..09b53efc8e --- /dev/null +++ b/backend/common-cdk/tests/test_user_pool.py @@ -0,0 +1,357 @@ +import base64 +import os +from unittest import TestCase + +from aws_cdk import App, RemovalPolicy, Stack +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_cognito import ( + CfnUserPool, + CfnUserPoolClient, + CfnUserPoolRiskConfigurationAttachment, + PasswordPolicy, + SignInAliases, + StandardAttributes, + UserPoolEmail, +) +from aws_cdk.aws_kms import Key + +from common_constructs.security_profile import SecurityProfile +from common_constructs.user_pool import UserPool + +_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), 'fixtures') +_BRANDING_DIR = os.path.join(_FIXTURES_DIR, 'branding') +_FAVICON_ICO = os.path.join(_BRANDING_DIR, 'favicon.ico') +_FORM_LOGO_PNG = os.path.join(_BRANDING_DIR, 'logo.png') +_PAGE_BACKGROUND_PNG = os.path.join(_BRANDING_DIR, 'background.png') + + +def _make_pool(stack: Stack, construct_id: str = 'Pool', **kwargs) -> UserPool: + defaults = { + 'environment_name': 'sandbox', + 'sign_in_aliases': SignInAliases(email=True), + 'standard_attributes': StandardAttributes(), + 'email': UserPoolEmail.with_cognito('noreply@example.com'), + 'notification_from_email': None, + 'ses_identity_arn': None, + 'removal_policy': RemovalPolicy.DESTROY, + 'encryption_key': Key(stack, f'{construct_id}Key'), + } + defaults.update(kwargs) + return UserPool(stack, construct_id, **defaults) + + +class TestUserPool(TestCase): + def setUp(self): + self._original_dir = os.getcwd() + os.chdir(_FIXTURES_DIR) + self.addCleanup(os.chdir, self._original_dir) + + self.app = App() + self.stack = Stack(self.app, 'TestStack') + + # --- MFA settings ------------------------------------------------------- + + def test_recommended_profile_requires_mfa(self): + _make_pool(self.stack, security_profile=SecurityProfile.RECOMMENDED) + + template = Template.from_stack(self.stack) + template.has_resource_properties(CfnUserPool.CFN_RESOURCE_TYPE_NAME, {'MfaConfiguration': 'ON'}) + + def test_vulnerable_profile_makes_mfa_optional(self): + _make_pool(self.stack, security_profile=SecurityProfile.VULNERABLE) + + template = Template.from_stack(self.stack) + template.has_resource_properties(CfnUserPool.CFN_RESOURCE_TYPE_NAME, {'MfaConfiguration': 'OPTIONAL'}) + + def test_mfa_uses_totp_only(self): + _make_pool(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + { + 'EnabledMfas': ['SOFTWARE_TOKEN_MFA'], + }, + ) + + # --- password policy ---------------------------------------------------- + + def test_password_policy_min_length_12(self): + _make_pool(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + {'Policies': {'PasswordPolicy': Match.object_like({'MinimumLength': 12})}}, + ) + + def test_password_history_size_4(self): + _make_pool(self.stack) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + {'Policies': {'PasswordPolicy': Match.object_like({'PasswordHistorySize': 4})}}, + ) + + def test_custom_password_policy_kwarg_overrides_default(self): + _make_pool(self.stack, password_policy=PasswordPolicy(min_length=16)) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + {'Policies': {'PasswordPolicy': Match.object_like({'MinimumLength': 16})}}, + ) + + # --- threat protection --------------------------------------------------- + + def test_recommended_profile_full_function_threat_protection(self): + _make_pool(self.stack, security_profile=SecurityProfile.RECOMMENDED) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + {'UserPoolTier': 'PLUS'}, + ) + + def test_recommended_profile_standard_threat_protection_full_function(self): + _make_pool(self.stack, security_profile=SecurityProfile.RECOMMENDED) + + template = Template.from_stack(self.stack) + # FULL_FUNCTION standard threat protection + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + { + 'UserPoolAddOns': {'AdvancedSecurityMode': 'ENFORCED'}, + }, + ) + + def test_vulnerable_profile_audit_only_threat_protection(self): + _make_pool(self.stack, security_profile=SecurityProfile.VULNERABLE) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + { + 'UserPoolAddOns': {'AdvancedSecurityMode': 'AUDIT'}, + }, + ) + + # --- risk configuration ------------------------------------------------- + + def test_recommended_profile_risk_config_high_event_action_mfa_required(self): + _make_pool(self.stack, security_profile=SecurityProfile.RECOMMENDED) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolRiskConfigurationAttachment.CFN_RESOURCE_TYPE_NAME, + { + 'AccountTakeoverRiskConfiguration': { + 'Actions': Match.object_like({'HighAction': Match.object_like({'EventAction': 'MFA_REQUIRED'})}) + } + }, + ) + + def test_vulnerable_profile_risk_config_high_event_action_no_action(self): + _make_pool(self.stack, security_profile=SecurityProfile.VULNERABLE) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolRiskConfigurationAttachment.CFN_RESOURCE_TYPE_NAME, + { + 'AccountTakeoverRiskConfiguration': { + 'Actions': Match.object_like({'HighAction': Match.object_like({'EventAction': 'NO_ACTION'})}) + } + }, + ) + + def test_recommended_profile_compromised_credentials_block(self): + _make_pool(self.stack, security_profile=SecurityProfile.RECOMMENDED) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolRiskConfigurationAttachment.CFN_RESOURCE_TYPE_NAME, + {'CompromisedCredentialsRiskConfiguration': {'Actions': {'EventAction': 'BLOCK'}}}, + ) + + def test_vulnerable_profile_compromised_credentials_no_action(self): + _make_pool(self.stack, security_profile=SecurityProfile.VULNERABLE) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolRiskConfigurationAttachment.CFN_RESOURCE_TYPE_NAME, + {'CompromisedCredentialsRiskConfiguration': {'Actions': {'EventAction': 'NO_ACTION'}}}, + ) + + # --- deletion protection ------------------------------------------------ + + def test_deletion_protection_enabled_when_retain(self): + _make_pool(self.stack, removal_policy=RemovalPolicy.RETAIN) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + {'DeletionProtection': 'ACTIVE'}, + ) + + def test_no_deletion_protection_when_destroy(self): + _make_pool(self.stack, removal_policy=RemovalPolicy.DESTROY) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + {'DeletionProtection': 'INACTIVE'}, + ) + + # --- prod profile guard ------------------------------------------------- + + def test_prod_environment_raises_if_not_recommended(self): + with self.assertRaises(ValueError): + _make_pool( + self.stack, + environment_name='prod', + security_profile=SecurityProfile.VULNERABLE, + ) + + # --- UI client settings ------------------------------------------------- + + def test_ui_client_oauth_uses_authorization_code_grant_not_implicit(self): + pool = _make_pool(self.stack) + pool.add_ui_client( + ui_domain_name=None, + environment_context={'allow_local_ui': True, 'local_ui_port': '3000'}, + read_attributes=None, + write_attributes=None, + ) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + { + 'AllowedOAuthFlows': Match.array_with(['code']), + }, + ) + clients = template.find_resources( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'AllowedOAuthFlows': ['implicit']}}, + ) + self.assertEqual({}, clients) + + def test_ui_client_sets_short_token_validities(self): + pool = _make_pool(self.stack) + pool.add_ui_client( + ui_domain_name=None, + environment_context={'allow_local_ui': True, 'local_ui_port': '3000'}, + read_attributes=None, + write_attributes=None, + ) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + { + 'AccessTokenValidity': 5, + 'IdTokenValidity': 5, + 'TokenValidityUnits': Match.object_like({'AccessToken': 'minutes', 'IdToken': 'minutes'}), + }, + ) + + def test_ui_client_prevent_user_existence_errors(self): + pool = _make_pool(self.stack) + pool.add_ui_client( + ui_domain_name=None, + environment_context={'allow_local_ui': True, 'local_ui_port': '3000'}, + read_attributes=None, + write_attributes=None, + ) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + {'PreventUserExistenceErrors': 'ENABLED'}, + ) + + def test_ui_client_does_not_generate_secret(self): + pool = _make_pool(self.stack) + pool.add_ui_client( + ui_domain_name=None, + environment_context={'allow_local_ui': True, 'local_ui_port': '3000'}, + read_attributes=None, + write_attributes=None, + ) + + template = Template.from_stack(self.stack) + clients = template.find_resources( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'GenerateSecret': True}}, + ) + self.assertEqual({}, clients) + + def test_ui_client_requires_callback_url(self): + pool = _make_pool(self.stack) + with self.assertRaises(ValueError): + pool.add_ui_client( + ui_domain_name=None, + environment_context={}, + read_attributes=None, + write_attributes=None, + ) + + def test_add_default_app_client_domain_creates_cognito_domain(self): + pool = _make_pool(self.stack) + pool.add_default_app_client_domain('testprefix') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + 'AWS::Cognito::UserPoolDomain', + {'Domain': Match.string_like_regexp('testprefix')}, + ) + + +class TestUserPoolManagedLoginBranding(TestCase): + """Tests for prepare_assets_for_managed_login_ui and convert_img_to_base_64.""" + + def setUp(self): + self._original_dir = os.getcwd() + os.chdir(_FIXTURES_DIR) + self.addCleanup(os.chdir, self._original_dir) + + self.app = App() + self.stack = Stack(self.app, 'TestStack') + self.pool = _make_pool(self.stack, construct_id='BrandingPool') + + def test_convert_img_to_base_64_round_trips_file_bytes(self): + expected = open(_FORM_LOGO_PNG, 'rb').read() + + encoded = self.pool.convert_img_to_base_64(_FORM_LOGO_PNG) + + self.assertIsInstance(encoded, str) + self.assertEqual(expected, base64.b64decode(encoded)) + + def test_prepare_assets_without_background_returns_favicon_and_logo(self): + assets = self.pool.prepare_assets_for_managed_login_ui(_FAVICON_ICO, _FORM_LOGO_PNG) + + self.assertEqual(2, len(assets)) + self.assertEqual( + [ + {'category': 'FAVICON_ICO', 'color_mode': 'LIGHT', 'extension': 'ICO'}, + {'category': 'FORM_LOGO', 'color_mode': 'LIGHT', 'extension': 'PNG'}, + ], + [{'category': a.category, 'color_mode': a.color_mode, 'extension': a.extension} for a in assets], + ) + for asset in assets: + self.assertGreater(len(asset.bytes), 0) + + def test_prepare_assets_with_background_includes_page_background_asset(self): + assets = self.pool.prepare_assets_for_managed_login_ui( + _FAVICON_ICO, + _FORM_LOGO_PNG, + background_file_path=_PAGE_BACKGROUND_PNG, + ) + + self.assertEqual(3, len(assets)) + background = assets[2] + self.assertEqual('PAGE_BACKGROUND', background.category) + self.assertEqual('LIGHT', background.color_mode) + self.assertEqual('PNG', background.extension) + self.assertGreater(len(background.bytes), 0) diff --git a/backend/common-cdk/tests/test_webacl.py b/backend/common-cdk/tests/test_webacl.py new file mode 100644 index 0000000000..0f36166a59 --- /dev/null +++ b/backend/common-cdk/tests/test_webacl.py @@ -0,0 +1,150 @@ +from unittest import TestCase + +from aws_cdk import App, Stack +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_apigateway import RestApi +from aws_cdk.aws_logs import CfnLogGroup +from aws_cdk.aws_wafv2 import CfnLoggingConfiguration, CfnWebACL, CfnWebACLAssociation + +from common_constructs.security_profile import SecurityProfile +from common_constructs.webacl import WebACL, WebACLRules, WebACLScope + + +class TestWebACL(TestCase): + def setUp(self): + self.app = App() + # Pin account/region so log_group_name comparisons are stable. + self.stack = Stack(self.app, 'TestStack', env={'account': '111122223333', 'region': 'us-east-1'}) + + # --- security defaults -------------------------------------------------- + + def test_recommended_profile_includes_rate_limit_and_crs_rules(self): + WebACL(self.stack, 'ACL', security_profile=SecurityProfile.RECOMMENDED) + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnWebACL.CFN_RESOURCE_TYPE_NAME, + { + 'DefaultAction': {'Allow': {}}, + 'Scope': 'REGIONAL', + 'Rules': Match.array_with( + [ + Match.object_like({'Name': 'RateLimit', 'Action': {'Block': {}}}), + Match.object_like( + { + 'Name': 'CRSRule', + 'Statement': { + 'ManagedRuleGroupStatement': { + 'Name': 'AWSManagedRulesCommonRuleSet', + 'VendorName': 'AWS', + } + }, + } + ), + ] + ), + }, + ) + + def test_vulnerable_profile_omits_rate_limit_rule(self): + WebACL(self.stack, 'ACL', security_profile=SecurityProfile.VULNERABLE) + + template = Template.from_stack(self.stack) + acls = template.find_resources(CfnWebACL.CFN_RESOURCE_TYPE_NAME) + self.assertEqual(1, len(acls)) + (acl,) = acls.values() + rule_names = {rule['Name'] for rule in acl['Properties']['Rules']} + self.assertEqual({'CRSRule'}, rule_names) + + def test_crs_rule_overrides_body_size_restriction_to_count(self): + """SizeRestrictions_BODY is overridden to Count so large license uploads are not blocked.""" + WebACL(self.stack, 'ACL') + + template = Template.from_stack(self.stack) + (acl,) = template.find_resources(CfnWebACL.CFN_RESOURCE_TYPE_NAME).values() + crs_rule = next(r for r in acl['Properties']['Rules'] if r['Name'] == 'CRSRule') + overrides = crs_rule['Statement']['ManagedRuleGroupStatement']['RuleActionOverrides'] + size_override = next(o for o in overrides if o['Name'] == 'SizeRestrictions_BODY') + self.assertIn('Count', size_override['ActionToUse']) + + def test_default_scope_is_regional(self): + WebACL(self.stack, 'ACL') + + template = Template.from_stack(self.stack) + (acl,) = template.find_resources(CfnWebACL.CFN_RESOURCE_TYPE_NAME).values() + self.assertEqual('REGIONAL', acl['Properties']['Scope']) + + # --- logging guarantees ------------------------------------------------- + + def test_logging_redacts_authorization_header(self): + WebACL(self.stack, 'ACL') + + template = Template.from_stack(self.stack) + template.has_resource_properties( + CfnLoggingConfiguration.CFN_RESOURCE_TYPE_NAME, + {'RedactedFields': [{'SingleHeader': {'Name': 'Authorization'}}]}, + ) + + def test_log_group_uses_one_month_retention_and_waf_prefix(self): + WebACL(self.stack, 'ACL') + + template = Template.from_stack(self.stack) + groups = template.find_resources( + CfnLogGroup.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'RetentionInDays': 30}}, + ) + self.assertEqual(1, len(groups)) + (group,) = groups.values() + self.assertTrue(group['Properties']['LogGroupName'].startswith('aws-waf-logs-')) + + def test_logging_disabled_creates_no_log_group_or_logging_config(self): + WebACL(self.stack, 'ACL', enable_acl_logging=False) + + template = Template.from_stack(self.stack) + self.assertEqual({}, template.find_resources(CfnLoggingConfiguration.CFN_RESOURCE_TYPE_NAME)) + self.assertEqual({}, template.find_resources(CfnLogGroup.CFN_RESOURCE_TYPE_NAME)) + + # --- scope guards ------------------------------------------------------- + + def test_cloudfront_scope_outside_us_east_1_raises(self): + bad_stack = Stack(self.app, 'BadRegion', env={'account': '111122223333', 'region': 'us-west-2'}) + with self.assertRaises(RuntimeError): + WebACL(bad_stack, 'ACL', acl_scope=WebACLScope.CLOUDFRONT) + + # --- public API --------------------------------------------------------- + + def test_associate_stage_creates_webacl_association(self): + acl = WebACL(self.stack, 'ACL') + api = RestApi(self.stack, 'Api') + api.root.add_method('GET') + acl.associate_stage(api.deployment_stage) + + template = Template.from_stack(self.stack) + template.resource_count_is(CfnWebACLAssociation.CFN_RESOURCE_TYPE_NAME, 1) + + def test_custom_rules_override_security_profile_defaults(self): + only_crs = [WebACLRules.common_rule()] + WebACL(self.stack, 'ACL', security_profile=SecurityProfile.RECOMMENDED, rules=only_crs) + + template = Template.from_stack(self.stack) + (acl,) = template.find_resources(CfnWebACL.CFN_RESOURCE_TYPE_NAME).values() + self.assertEqual(['CRSRule'], [rule['Name'] for rule in acl['Properties']['Rules']]) + + def test_cloudwatch_metrics_enabled_on_acl_and_rules(self): + WebACL(self.stack, 'ACL') + + template = Template.from_stack(self.stack) + (acl,) = template.find_resources(CfnWebACL.CFN_RESOURCE_TYPE_NAME).values() + self.assertTrue(acl['Properties']['VisibilityConfig']['CloudWatchMetricsEnabled']) + for rule in acl['Properties']['Rules']: + self.assertTrue(rule['VisibilityConfig']['CloudWatchMetricsEnabled']) + + def test_add_rule_appends_to_rules_list(self): + acl = WebACL(self.stack, 'ACL', security_profile=SecurityProfile.VULNERABLE) + initial_count = len(acl.rules) + new_rule = WebACLRules.rate_limit_rule() + acl.add_rule(new_rule) + # Check that the rule is actually in the rules list after adding + self.assertIn(new_rule, acl.rules) + # Optionally still check the count as a secondary check + self.assertEqual(initial_count + 1, len(acl.rules)) diff --git a/backend/compact-connect/common_constructs/cognito_user_backup.py b/backend/compact-connect/common_constructs/cognito_user_backup.py index 44a8b22e05..373b85ef40 100644 --- a/backend/compact-connect/common_constructs/cognito_user_backup.py +++ b/backend/compact-connect/common_constructs/cognito_user_backup.py @@ -22,12 +22,12 @@ from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.backup_plan import CCBackupPlan from common_constructs.bucket import Bucket +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan -from common_constructs.python_function import PythonFunction from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/compact-connect/common_constructs/data_migration.py b/backend/compact-connect/common_constructs/data_migration.py deleted file mode 100644 index 1f70df6223..0000000000 --- a/backend/compact-connect/common_constructs/data_migration.py +++ /dev/null @@ -1,133 +0,0 @@ -import os - -import jsii -from aws_cdk import CustomResource, Duration, Stack -from aws_cdk.aws_iam import IGrantable, IRole -from aws_cdk.aws_logs import LogGroup, RetentionDays -from aws_cdk.custom_resources import Provider -from cdk_nag import NagSuppressions -from constructs import Construct - -from common_constructs.python_function import PythonFunction - - -@jsii.implements(IGrantable) -class DataMigration(Construct): - def __init__( - self, - scope: Construct, - construct_id: str, - *, - migration_dir: str, - lambda_environment: dict, - role: IRole = None, - custom_resource_properties: dict = None, - ): - """ - This construct is used to run a data migration. - It will create a lambda function and a provider that will run the migration. - - :param migration_dir: The directory containing the migration code. Name the directory after the associated - GitHub issue that requires the migration. - :param lambda_environment: The environment variables for the lambda function. - :param role: The IAM role to use for the lambda function, with the necessary permissions. - :param custom_resource_properties: The properties for the custom resource. - """ - super().__init__(scope, construct_id) - self.migration_function = PythonFunction( - self, - 'MigrationFunction', - index=os.path.join(migration_dir, 'main.py'), - lambda_dir='migration', - handler='on_event', - log_retention=RetentionDays.ONE_MONTH, - role=role, - environment=lambda_environment, - timeout=Duration.minutes(15), - # These are one-time migration scripts, so it is cost-effective to increase their memory size - # so they complete their process sooner - memory_size=3008, - ) - provider_log_group = LogGroup( - self, - 'ProviderLogGroup', - retention=RetentionDays.ONE_DAY, - ) - NagSuppressions.add_resource_suppressions( - provider_log_group, - suppressions=[ - { - 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', - 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' - ' logs to operators with credentials for the AWS account is desired. Encryption is not appropriate' - ' here.', - }, - ], - ) - self.provider = Provider( - self, - 'Provider', - on_event_handler=self.migration_function, - log_group=provider_log_group, - ) - NagSuppressions.add_resource_suppressions_by_path( - Stack.of(self), - f'{self.provider.node.path}/framework-onEvent/Resource', - [ - { - 'id': 'AwsSolutions-L1', - 'reason': 'We do not control this runtime', - }, - { - 'id': 'HIPAA.Security-LambdaConcurrency', - 'reason': 'This function is only run at deploy time, by CloudFormation and has no need for ' - 'concurrency limits.', - }, - { - 'id': 'HIPAA.Security-LambdaDLQ', - 'reason': 'This is a synchronous function run at deploy time. It does not need a DLQ', - }, - { - 'id': 'HIPAA.Security-LambdaInsideVPC', - 'reason': 'We may choose to move our lambdas into private VPC subnets in a future enhancement', - }, - ], - ) - - NagSuppressions.add_resource_suppressions_by_path( - Stack.of(self), - path=f'{self.provider.node.path}/framework-onEvent/ServiceRole/DefaultPolicy/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM5', - 'reason': 'The actions in this policy are specifically what this lambda needs to read ' - 'and is scoped to one table and encryption key.', - }, - ], - ) - - NagSuppressions.add_resource_suppressions_by_path( - Stack.of(self), - path=f'{self.provider.node.path}/framework-onEvent/ServiceRole/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM4', - 'appliesTo': [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' - ], # noqa: E501 line-too-long - 'reason': 'This policy is appropriate for the log retention lambda', - }, - ], - ) - - self.custom_resource = CustomResource( - self, - 'CustomResource', - resource_type='Custom::DataMigration', - service_token=self.provider.service_token, - properties=custom_resource_properties, - ) - - @property - def grant_principal(self): - return self.migration_function.grant_principal diff --git a/backend/compact-connect/common_constructs/user_pool.py b/backend/compact-connect/common_constructs/user_pool.py deleted file mode 100644 index b766143a40..0000000000 --- a/backend/compact-connect/common_constructs/user_pool.py +++ /dev/null @@ -1,441 +0,0 @@ -import base64 -import os -from collections.abc import Mapping - -from aws_cdk import CfnOutput, Duration, RemovalPolicy -from aws_cdk.aws_certificatemanager import Certificate, CertificateValidation -from aws_cdk.aws_cognito import ( - AccountRecovery, - AuthFlow, - AutoVerifiedAttrs, - CfnManagedLoginBranding, - CfnUserPoolRiskConfigurationAttachment, - ClientAttributes, - CognitoDomainOptions, - CustomDomainOptions, - CustomThreatProtectionMode, - DeviceTracking, - FeaturePlan, - ICustomAttribute, - ManagedLoginVersion, - Mfa, - MfaSecondFactor, - OAuthFlows, - OAuthScope, - OAuthSettings, - PasswordPolicy, - SignInAliases, - StandardAttributes, - StandardThreatProtectionMode, - UserPoolClient, - UserPoolEmail, -) -from aws_cdk.aws_cognito import UserPool as CdkUserPool -from aws_cdk.aws_kms import IKey -from aws_cdk.aws_route53 import ARecord, IHostedZone, RecordTarget -from aws_cdk.aws_route53_targets import UserPoolDomainTarget -from cdk_nag import NagSuppressions -from common_constructs.security_profile import SecurityProfile -from common_constructs.stack import Stack -from constructs import Construct - - -class UserPool(CdkUserPool): - # A lot of arguments legitimately need to be passed into the constructor - def __init__( # pylint: disable=too-many-arguments - self, - scope: Construct, - construct_id: str, - *, - environment_name: str, - encryption_key: IKey, - sign_in_aliases: SignInAliases | None, - standard_attributes: StandardAttributes, - custom_attributes: Mapping[str, ICustomAttribute] | None = None, - email: UserPoolEmail, - notification_from_email: str | None, - ses_identity_arn: str | None, - removal_policy, - security_profile: SecurityProfile = SecurityProfile.RECOMMENDED, - password_policy: PasswordPolicy = None, - **kwargs, - ): - if environment_name == 'prod' and security_profile != SecurityProfile.RECOMMENDED: - raise ValueError('Security profile must be RECOMMENDED in production environments') - - super().__init__( - scope, - construct_id, - removal_policy=removal_policy, - deletion_protection=removal_policy != RemovalPolicy.DESTROY, - email=email, - account_recovery=AccountRecovery.EMAIL_ONLY, - auto_verify=AutoVerifiedAttrs(email=True), - standard_threat_protection_mode=StandardThreatProtectionMode.FULL_FUNCTION - if security_profile == SecurityProfile.RECOMMENDED - else StandardThreatProtectionMode.AUDIT_ONLY, - # Custom threat protection mode is only for custom authentication flows - # which we don't currently have in this project. We'll keep this in place - # anyway, for future-proofing, since it is harmless to leave it in place. - custom_threat_protection_mode=CustomThreatProtectionMode.FULL_FUNCTION - if security_profile == SecurityProfile.RECOMMENDED - else CustomThreatProtectionMode.AUDIT_ONLY, - # required for threat protection modes - feature_plan=FeaturePlan.PLUS, - custom_sender_kms_key=encryption_key, - device_tracking=DeviceTracking( - challenge_required_on_new_device=True, device_only_remembered_on_user_prompt=True - ), - mfa=Mfa.REQUIRED if security_profile == SecurityProfile.RECOMMENDED else Mfa.OPTIONAL, - mfa_second_factor=MfaSecondFactor(otp=True, sms=False, email=False), - password_policy=PasswordPolicy( - min_length=12, - require_digits=True, - require_lowercase=True, - require_uppercase=False, - require_symbols=False, - password_history_size=4, - ) - if not password_policy - else password_policy, - self_sign_up_enabled=False, - sign_in_aliases=sign_in_aliases, - sign_in_case_sensitive=False, - standard_attributes=standard_attributes, - custom_attributes=custom_attributes, - **kwargs, - ) - - self.security_profile = security_profile - - # Configure notification emails if provided - self.notification_from_email = notification_from_email - self.ses_identity_arn = ses_identity_arn - - CfnOutput(self, f'{construct_id}UserPoolId', value=self.user_pool_id) - - self._add_risk_configuration(security_profile) - - if security_profile == SecurityProfile.VULNERABLE: - NagSuppressions.add_resource_suppressions( - self, - suppressions=[ - { - 'id': 'AwsSolutions-COG2', - 'reason': 'MFA is disabled to facilitate automated security testing in some pre-production' - ' environments.', - }, - { - 'id': 'AwsSolutions-COG3', - 'reason': 'Threat protection mode is not enforced in some pre-production environments to' - ' facilitate automated security testing.', - }, - ], - ) - NagSuppressions.add_resource_suppressions( - self, - suppressions=[ - { - 'id': 'AwsSolutions-COG1', - 'reason': 'OWASP ASVS v4.0.3-2.1.9 specifically prohibits requirements on upper or lower case or' - ' numbers or special characters.', - } - ], - ) - - def add_custom_app_client_domain( - self, - hosted_zone: IHostedZone, - scope: Construct, - app_client_domain_prefix: str, - ): - """ - Creates a custom subdomain for the cognito app client in the form of: - {app_client_domain_prefix}-auth.{base_domain_name} - :param hosted_zone: The hosted zone the domain will use - :param scope: The CDK construct scope - """ - domain_prefix = f'{app_client_domain_prefix.lower()}-auth' - domain_name = f'{domain_prefix}.{hosted_zone.zone_name}' - cert_id = f'{app_client_domain_prefix}AuthCert' - cert = Certificate( - scope, cert_id, domain_name=domain_name, validation=CertificateValidation.from_dns(hosted_zone=hosted_zone) - ) - domain = self.add_domain( - f'{app_client_domain_prefix}UserPoolDomain', - custom_domain=CustomDomainOptions(certificate=cert, domain_name=domain_name), - managed_login_version=ManagedLoginVersion.NEWER_MANAGED_LOGIN, - ) - - self.record = ARecord( - self, - f'{app_client_domain_prefix}AuthDomainARecord', - zone=hosted_zone, - record_name=domain_name, - target=RecordTarget.from_alias(UserPoolDomainTarget(domain)), - ) - - CfnOutput(self, f'{app_client_domain_prefix}UserPoolDomainName', value=domain.domain_name) - - # Add NAG suppressions for the auto-generated custom resource Lambda - stack = Stack.of(self) - - # Suppress for the CustomResourcePolicy - NagSuppressions.add_resource_suppressions_by_path( - stack, - f'{domain.node.path}/CloudFrontDomainName/CustomResourcePolicy/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM5', - 'appliesTo': ['Resource::*'], - 'reason': 'This is an AWS-managed custom resource Lambda that requires wildcard permissions' - 'to describe CloudFront distributions.', - } - ], - ) - - # Suppress for the auto-generated Lambda function - # The construct ID is auto-generated by CDK, so we need to suppress at the stack level - NagSuppressions.add_resource_suppressions_by_path( - stack, - f'{stack.node.path}/AWS679f53fac002430cb0da5b7982bd2287/ServiceRole/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM4', - 'appliesTo': [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' - ], - 'reason': 'This is an AWS-managed custom resource Lambda that uses the standard execution role.', - } - ], - ) - - NagSuppressions.add_resource_suppressions_by_path( - stack, - f'{stack.node.path}/AWS679f53fac002430cb0da5b7982bd2287/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-L1', - 'reason': 'We do not maintain this lambda runtime. It will be updated with future CDK versions', - }, - { - 'id': 'HIPAA.Security-LambdaDLQ', - 'reason': 'This is an AWS-managed custom resource Lambda used only during deployment.' - 'A DLQ is not necessary.', - }, - { - 'id': 'HIPAA.Security-LambdaInsideVPC', - 'reason': 'This is an AWS-managed custom resource Lambda that needs internet access to' - 'describe CloudFront distributions.', - }, - ], - ) - - self.app_client_custom_domain = domain - - def add_default_app_client_domain( - self, - non_custom_domain_prefix: str, - ): - """ - Creates a cognito based sub domain in the form of: - {non_custom_domain_prefix}.auth.us-east-1.amazoncognito.com - :param non_custom_domain_prefix: The prefix to put on the cognito based app client domain - """ - self.default_user_pool_domain = self.add_domain( - f'{non_custom_domain_prefix}UserPoolDomain', - cognito_domain=CognitoDomainOptions(domain_prefix=non_custom_domain_prefix), - managed_login_version=ManagedLoginVersion.NEWER_MANAGED_LOGIN, - ) - - def add_ui_client( - self, - ui_domain_name: str, - environment_context: dict, - read_attributes: ClientAttributes, - write_attributes: ClientAttributes, - ui_scopes: list[OAuthScope] = None, - ): - """ - Creates an app client for the UI to authenticate with the user pool. - - :param ui_domain_name: The ui domain name used to determine acceptable redirects. - :param environment_context: The environment context used to determine acceptable redirects. - :param read_attributes: The attributes that the UI can read. - :param write_attributes: The attributes that the UI can write. - :param ui_scopes: OAuth scopes that are allowed with this client - """ - callback_urls = [] - if ui_domain_name is not None: - callback_urls.append(f'https://{ui_domain_name}/auth/callback') - # This toggle will allow front-end devs to point their local UI at this environment's user pool to support - # authenticated actions. - if environment_context.get('allow_local_ui', False): - local_ui_port = environment_context.get('local_ui_port', '3018') - callback_urls.append(f'http://localhost:{local_ui_port}/auth/callback') - if not callback_urls: - raise ValueError( - "This app requires a callback url for its authentication path. Either provide 'domain_name' or set " - "'allow_local_ui' to true in this environment's context." - ) - - logout_urls = [] - if ui_domain_name is not None: - logout_urls.append(f'https://{ui_domain_name}/Login') - logout_urls.append(f'https://{ui_domain_name}/Dashboard') - logout_urls.append(f'https://{ui_domain_name}/Logout') - # This toggle will allow front-end devs to point their local UI at this environment's user pool to support - # authenticated actions. - if environment_context.get('allow_local_ui', False): - local_ui_port = environment_context.get('local_ui_port', '3018') - logout_urls.append(f'http://localhost:{local_ui_port}/Login') - logout_urls.append(f'http://localhost:{local_ui_port}/Dashboard') - logout_urls.append(f'http://localhost:{local_ui_port}/Logout') - if not logout_urls: - raise ValueError( - "This app requires a logout url for its logout function. Either provide 'domain_name' or set " - "'allow_local_ui' to true in this environment's context." - ) - - return self.add_client( - 'UIClient', - auth_flows=AuthFlow( - # Admin User Password is required for AdminInitiateAuth, which we use for account recovery - # (and automated testing in test environments) - admin_user_password=True, - custom=False, - user_srp=False if self.security_profile == SecurityProfile.RECOMMENDED else True, - user_password=False, - ), - o_auth=OAuthSettings( - callback_urls=callback_urls, - logout_urls=logout_urls, - flows=OAuthFlows(authorization_code_grant=True, implicit_code_grant=False), - scopes=ui_scopes, - ), - access_token_validity=Duration.minutes(5), - id_token_validity=Duration.minutes(5), - auth_session_validity=Duration.minutes(15), - enable_token_revocation=True, - generate_secret=False, - refresh_token_validity=Duration.days(30), - write_attributes=write_attributes, - read_attributes=read_attributes, - prevent_user_existence_errors=True, - ) - - def _add_risk_configuration(self, security_profile: SecurityProfile): - with open(os.path.join('resources', 'cognito-blocked-notification.txt')) as f: - blocked_notify_text = f.read() - with open(os.path.join('resources', 'cognito-no-action-notification.txt')) as f: - no_action_notify_text = f.read() - CfnUserPoolRiskConfigurationAttachment( - self, - 'UserPoolRiskConfiguration', - # Applies to all clients - client_id='ALL', - user_pool_id=self.user_pool_id, - # If Cognito suspects an account take-over event, notify the user - account_takeover_risk_configuration=CfnUserPoolRiskConfigurationAttachment.AccountTakeoverRiskConfigurationTypeProperty( - actions=CfnUserPoolRiskConfigurationAttachment.AccountTakeoverActionsTypeProperty( - high_action=CfnUserPoolRiskConfigurationAttachment.AccountTakeoverActionTypeProperty( - event_action='MFA_REQUIRED' if security_profile == SecurityProfile.RECOMMENDED else 'NO_ACTION', - notify=True, - ), - medium_action=CfnUserPoolRiskConfigurationAttachment.AccountTakeoverActionTypeProperty( - event_action='MFA_REQUIRED' if security_profile == SecurityProfile.RECOMMENDED else 'NO_ACTION', - notify=True, - ), - low_action=CfnUserPoolRiskConfigurationAttachment.AccountTakeoverActionTypeProperty( - event_action='MFA_REQUIRED' if security_profile == SecurityProfile.RECOMMENDED else 'NO_ACTION', - notify=True, - ), - ), - **( - { - 'notify_configuration': CfnUserPoolRiskConfigurationAttachment.NotifyConfigurationTypeProperty( - source_arn=self.ses_identity_arn, - block_email=CfnUserPoolRiskConfigurationAttachment.NotifyEmailTypeProperty( - subject='CompactConnect: Account Security Alert', - text_body=blocked_notify_text, - html_body=f'

{blocked_notify_text}

', - ), - mfa_email=CfnUserPoolRiskConfigurationAttachment.NotifyEmailTypeProperty( - subject='CompactConnect: Account Security Alert', - text_body=no_action_notify_text, - html_body=f'

{no_action_notify_text}

', - ), - no_action_email=CfnUserPoolRiskConfigurationAttachment.NotifyEmailTypeProperty( - subject='CompactConnect: Account Security Alert', - text_body=no_action_notify_text, - html_body=f'

{no_action_notify_text}

', - ), - from_=self.notification_from_email, - ) - } - if self.notification_from_email is not None - else {} - ), - ), - # If Cognito detects the user trying to register compromised credentials, block the activity - compromised_credentials_risk_configuration=CfnUserPoolRiskConfigurationAttachment.CompromisedCredentialsRiskConfigurationTypeProperty( - actions=CfnUserPoolRiskConfigurationAttachment.CompromisedCredentialsActionsTypeProperty( - event_action='BLOCK' if security_profile == SecurityProfile.RECOMMENDED else 'NO_ACTION' - ) - ), - ) - - def add_managed_login_styles( - self, - user_pool_client: UserPoolClient, - branding_assets: list[any] = None, - branding_settings: dict = None, - ): - # Handle custom styles - login_branding = CfnManagedLoginBranding( - self, - 'MyCfnManagedLoginBranding', - user_pool_id=self.user_pool_id, - assets=branding_assets, - client_id=user_pool_client.user_pool_client_id, - return_merged_resources=False, - settings=branding_settings, - use_cognito_provided_values=False, - ) - - login_branding.add_dependency(user_pool_client.node.default_child) - - def prepare_assets_for_managed_login_ui( - self, ico_filepath: str, logo_filepath: str, background_file_path: str | None = None - ): - # options found: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cognito-managedloginbranding-assettype.html#cfn-cognito-managedloginbranding-assettype-category - assets = [] - base_64_favicon = self.convert_img_to_base_64(ico_filepath) - assets.append( - CfnManagedLoginBranding.AssetTypeProperty( - category='FAVICON_ICO', color_mode='LIGHT', extension='ICO', bytes=base_64_favicon - ) - ) - - base_64_logo = self.convert_img_to_base_64(logo_filepath) - assets.append( - CfnManagedLoginBranding.AssetTypeProperty( - category='FORM_LOGO', color_mode='LIGHT', extension='PNG', bytes=base_64_logo - ) - ) - - if background_file_path: - base_64_background = self.convert_img_to_base_64(background_file_path) - assets.append( - CfnManagedLoginBranding.AssetTypeProperty( - category='PAGE_BACKGROUND', color_mode='LIGHT', extension='PNG', bytes=base_64_background - ) - ) - - return assets - - def convert_img_to_base_64(self, file_path: str): - with open(file_path, 'rb') as binary_file: - binary_file_data = binary_file.read() - base64_encoded_data = base64.b64encode(binary_file_data) - return base64_encoded_data.decode('utf-8') diff --git a/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt b/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt index 5f7003c6d4..21c6c2f687 100644 --- a/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt @@ -4,48 +4,44 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/cognito-backup/requirements-dev.in # -aws-lambda-powertools==3.28.0 - # via -r cognito-backup/requirements-dev.in -boto3==1.42.89 +aws-lambda-powertools==3.29.0 + # via -r lambdas/python/cognito-backup/requirements-dev.in +boto3==1.43.12 # via - # -r cognito-backup/requirements-dev.in + # -r lambdas/python/cognito-backup/requirements-dev.in # moto -botocore==1.42.91 +botocore==1.43.12 # via - # -r cognito-backup/requirements-dev.in + # -r lambdas/python/cognito-backup/requirements-dev.in # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via # joserfc # moto -idna==3.11 +idna==3.15 # via requests iniconfig==2.3.0 # via pytest -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # aws-lambda-powertools # boto3 # botocore -joserfc==1.6.4 +joserfc==1.6.5 # via moto markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[cognitoidp,s3]==5.1.22 - # via -r cognito-backup/requirements-dev.in -packaging==26.1 + # via werkzeug +moto[cognitoidp,s3]==5.2.1 + # via -r lambdas/python/cognito-backup/requirements-dev.in +packaging==26.2 # via pytest pluggy==1.6.0 # via pytest @@ -56,22 +52,20 @@ pycparser==3.0 pygments==2.20.0 # via pytest pytest==9.0.3 - # via -r cognito-backup/requirements-dev.in + # via -r lambdas/python/cognito-backup/requirements-dev.in python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/common/requirements-dev.in b/backend/compact-connect/lambdas/python/common/requirements-dev.in index 22f9a75159..21396bb345 100644 --- a/backend/compact-connect/lambdas/python/common/requirements-dev.in +++ b/backend/compact-connect/lambdas/python/common/requirements-dev.in @@ -2,5 +2,5 @@ moto[all]>=5.0.12, <6 boto3-stubs[full] Faker>=40, <41 -cryptography>=46, <47 +cryptography>=48, <49 attrs>=25, <26 diff --git a/backend/compact-connect/lambdas/python/common/requirements-dev.txt b/backend/compact-connect/lambdas/python/common/requirements-dev.txt index dfca9adb2e..205a40f2d6 100644 --- a/backend/compact-connect/lambdas/python/common/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/common/requirements-dev.txt @@ -10,24 +10,23 @@ antlr4-python3-runtime==4.13.2 # via moto attrs==25.4.0 # via - # -r common/requirements-dev.in + # -r lambdas/python/common/requirements-dev.in # jsonschema + # jsonschema-path # referencing -aws-sam-translator==1.103.0 - # via - # cfn-lint - # moto +aws-sam-translator==1.110.0 + # via cfn-lint aws-xray-sdk==2.15.0 # via moto -boto3==1.42.89 +boto3==1.43.12 # via # aws-sam-translator # moto -boto3-stubs[full]==1.42.91 - # via -r common/requirements-dev.in -boto3-stubs-full==1.42.91 +boto3-stubs[full]==1.43.12 + # via -r lambdas/python/common/requirements-dev.in +boto3-stubs-full==1.43.12 # via boto3-stubs -botocore==1.42.91 +botocore==1.43.12 # via # aws-xray-sdk # boto3 @@ -35,34 +34,32 @@ botocore==1.42.91 # s3transfer botocore-stubs==1.42.41 # via boto3-stubs -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography -cfn-lint==1.41.0 +cfn-lint==1.51.0 # via moto charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via - # -r common/requirements-dev.in + # -r lambdas/python/common/requirements-dev.in # joserfc # moto docker==7.1.0 # via moto -faker==40.15.0 - # via -r common/requirements-dev.in +faker==40.18.0 + # via -r lambdas/python/common/requirements-dev.in graphql-core==3.2.8 # via moto -idna==3.11 +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore -joserfc==1.6.4 +joserfc==1.6.5 # via moto jsonpatch==1.33 # via cfn-lint @@ -70,13 +67,13 @@ jsonpath-ng==1.8.0 # via moto jsonpointer==3.1.1 # via jsonpatch -jsonschema==4.24.1 +jsonschema==4.26.0 # via # aws-sam-translator # moto # openapi-schema-validator # openapi-spec-validator -jsonschema-path==0.4.5 +jsonschema-path==0.5.0 # via openapi-spec-validator jsonschema-specifications==2025.9.1 # via @@ -85,46 +82,41 @@ jsonschema-specifications==2025.9.1 lazy-object-proxy==1.12.0 # via openapi-spec-validator markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[all]==5.1.22 - # via -r common/requirements-dev.in + # via werkzeug +moto[all]==5.2.1 + # via -r lambdas/python/common/requirements-dev.in mpmath==1.3.0 # via sympy multipart==1.3.1 # via moto networkx==3.6.1 # via cfn-lint -openapi-schema-validator==0.8.1 +openapi-schema-validator==0.9.0 # via openapi-spec-validator -openapi-spec-validator==0.8.4 +openapi-spec-validator==0.9.0 # via moto -pathable==0.5.0 +pathable==0.6.0 # via jsonschema-path py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi -pydantic==2.12.4 +pydantic==2.13.4 # via # aws-sam-translator - # moto # openapi-schema-validator # openapi-spec-validator # pydantic-settings -pydantic-core==2.41.5 +pydantic-core==2.46.4 # via pydantic -pydantic-settings==2.14.0 +pydantic-settings==2.14.1 # via # openapi-schema-validator # openapi-spec-validator pyparsing==3.3.2 # via moto python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore python-dotenv==1.2.2 # via pydantic-settings pyyaml==6.0.3 @@ -139,9 +131,9 @@ referencing==0.37.0 # jsonschema-path # jsonschema-specifications # openapi-schema-validator -regex==2026.4.4 +regex==2026.5.9 # via cfn-lint -requests==2.33.1 +requests==2.34.2 # via # docker # moto @@ -154,7 +146,7 @@ rpds-py==0.30.0 # via # jsonschema # referencing -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via @@ -185,7 +177,7 @@ urllib3==2.7.0 # responses werkzeug==3.1.8 # via moto -wrapt==2.1.2 +wrapt==2.2.0 # via aws-xray-sdk xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/common/requirements.in b/backend/compact-connect/lambdas/python/common/requirements.in index bb9c2f281d..dc017c3abc 100644 --- a/backend/compact-connect/lambdas/python/common/requirements.in +++ b/backend/compact-connect/lambdas/python/common/requirements.in @@ -1,6 +1,6 @@ argon2-cffi>=25.1.0, <26.0.0 aws-lambda-powertools>=3.5.0, <4 boto3>=1.34.33, <2 -cryptography>=46, <47 +cryptography>=48, <49 marshmallow>=4.3.0, <5.0.0 requests>=2.31.0, <3.0.0 diff --git a/backend/compact-connect/lambdas/python/common/requirements.txt b/backend/compact-connect/lambdas/python/common/requirements.txt index d73dc3d5ab..c74c914592 100644 --- a/backend/compact-connect/lambdas/python/common/requirements.txt +++ b/backend/compact-connect/lambdas/python/common/requirements.txt @@ -5,18 +5,18 @@ # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/common/requirements.in # argon2-cffi==25.1.0 - # via -r common/requirements.in + # via -r lambdas/python/common/requirements.in argon2-cffi-bindings==25.1.0 # via argon2-cffi -aws-lambda-powertools==3.28.0 - # via -r common/requirements.in -boto3==1.42.89 - # via -r common/requirements.in -botocore==1.42.91 +aws-lambda-powertools==3.29.0 + # via -r lambdas/python/common/requirements.in +boto3==1.43.12 + # via -r lambdas/python/common/requirements.in +botocore==1.43.12 # via # boto3 # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via @@ -24,9 +24,9 @@ cffi==2.0.0 # cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 - # via -r common/requirements.in -idna==3.11 +cryptography==48.0.0 + # via -r lambdas/python/common/requirements.in +idna==3.15 # via requests jmespath==1.1.0 # via @@ -34,14 +34,14 @@ jmespath==1.1.0 # boto3 # botocore marshmallow==4.3.0 - # via -r common/requirements.in + # via -r lambdas/python/common/requirements.in pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 # via botocore -requests==2.33.1 - # via -r common/requirements.in -s3transfer==0.16.0 +requests==2.34.2 + # via -r lambdas/python/common/requirements.in +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt b/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt index fd6c74d7d7..64b7f93c91 100644 --- a/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt @@ -4,57 +4,51 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/compact-configuration/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto docker==7.1.0 # via moto -idna==3.11 +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[dynamodb,s3]==5.1.22 - # via -r compact-configuration/requirements-dev.in + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/compact-configuration/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt index 1931581007..47721f87fa 100644 --- a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt @@ -4,57 +4,51 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/custom-resources/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto docker==7.1.0 # via moto -idna==3.11 +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[dynamodb,s3]==5.1.22 - # via -r custom-resources/requirements-dev.in + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/custom-resources/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt index 29aee1caf7..ff4ce6bd3a 100644 --- a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt @@ -4,57 +4,51 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/data-events/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto docker==7.1.0 # via moto -idna==3.11 +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[dynamodb,s3]==5.1.22 - # via -r data-events/requirements-dev.in + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/data-events/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt b/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt index 1f5dc76ebf..5cd736ae0e 100644 --- a/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt @@ -4,57 +4,51 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/disaster-recovery/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto docker==7.1.0 # via moto -idna==3.11 +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[dynamodb,s3]==5.1.22 - # via -r disaster-recovery/requirements-dev.in + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/disaster-recovery/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt index ffb24944ec..4a6baa8bbd 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt @@ -4,59 +4,53 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/provider-data-v1/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto docker==7.1.0 # via moto -faker==40.15.0 - # via -r provider-data-v1/requirements-dev.in -idna==3.11 +faker==40.18.0 + # via -r lambdas/python/provider-data-v1/requirements-dev.in +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[dynamodb,s3]==5.1.22 - # via -r provider-data-v1/requirements-dev.in + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/provider-data-v1/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/purchases/requirements-dev.in b/backend/compact-connect/lambdas/python/purchases/requirements-dev.in index b921f83209..d59e726e52 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements-dev.in +++ b/backend/compact-connect/lambdas/python/purchases/requirements-dev.in @@ -11,7 +11,7 @@ Faker>=40, <41 argon2-cffi>=25.1.0, <26.0.0 aws-lambda-powertools>=3.5.0, <4 boto3>=1.34.33, <2 -cryptography>=46, <47 +cryptography>=48, <49 marshmallow>=4.3.0, <5.0.0 requests>=2.31.0, <3.0.0 urllib3>=2.7.0, <3 diff --git a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt index 658108d4bc..17a8fa62d2 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt @@ -5,29 +5,29 @@ # pip-compile --no-emit-index-url --no-strip-extras requirements-dev.in # argon2-cffi==25.1.0 - # via -r purchases/requirements-dev.in + # via -r requirements-dev.in argon2-cffi-bindings==25.1.0 # via argon2-cffi -aws-lambda-powertools==3.28.0 - # via -r purchases/requirements-dev.in +aws-lambda-powertools==3.29.0 + # via -r requirements-dev.in boolean-py==5.0 # via license-expression -boto3==1.42.89 +boto3==1.43.12 # via - # -r purchases/requirements-dev.in + # -r requirements-dev.in # moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -build==1.4.3 +build==1.5.0 # via pip-tools cachecontrol[filecache]==0.14.4 # via # cachecontrol # pip-audit -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via @@ -35,15 +35,15 @@ cffi==2.0.0 # cryptography charset-normalizer==3.4.7 # via requests -click==8.3.2 +click==8.4.0 # via pip-tools -coverage[toml]==7.13.5 +coverage[toml]==7.14.0 # via - # -r purchases/requirements-dev.in + # -r requirements-dev.in # pytest-cov -cryptography==46.0.7 +cryptography==48.0.0 # via - # -r purchases/requirements-dev.in + # -r requirements-dev.in # moto cyclonedx-python-lib==11.7.0 # via pip-audit @@ -51,16 +51,14 @@ defusedxml==0.7.1 # via py-serializable docker==7.1.0 # via moto -faker==40.14.1 - # via -r purchases/requirements-dev.in -filelock==3.28.0 +faker==40.18.0 + # via -r requirements-dev.in +filelock==3.29.0 # via cachecontrol -idna==3.11 +idna==3.15 # via requests iniconfig==2.3.0 # via pytest -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # aws-lambda-powertools @@ -68,23 +66,21 @@ jmespath==1.1.0 # botocore license-expression==30.4.4 # via cyclonedx-python-lib -markdown-it-py==4.0.0 +markdown-it-py==4.2.0 # via rich markupsafe==3.0.3 - # via - # jinja2 - # werkzeug + # via werkzeug marshmallow==4.3.0 - # via -r purchases/requirements-dev.in + # via -r requirements-dev.in mdurl==0.1.2 # via markdown-it-py -moto[dynamodb,s3]==5.1.22 - # via -r purchases/requirements-dev.in +moto[dynamodb,s3]==5.2.1 + # via -r requirements-dev.in msgpack==1.1.2 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib -packaging==26.1 +packaging==26.2 # via # build # pip-audit @@ -94,11 +90,11 @@ packaging==26.1 pip-api==0.0.34 # via pip-audit pip-audit==2.10.0 - # via -r purchases/requirements-dev.in + # via -r requirements-dev.in pip-requirements-parser==32.0.1 # via pip-audit pip-tools==7.5.3 - # via -r purchases/requirements-dev.in + # via -r requirements-dev.in platformdirs==4.9.6 # via pip-audit pluggy==1.6.0 @@ -123,21 +119,19 @@ pyproject-hooks==1.2.0 # pip-tools pytest==9.0.3 # via - # -r purchases/requirements-dev.in + # -r requirements-dev.in # pytest-cov pytest-cov==7.1.0 - # via -r purchases/requirements-dev.in + # via -r requirements-dev.in python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via - # -r purchases/requirements-dev.in + # -r requirements-dev.in # cachecontrol # docker # moto @@ -147,9 +141,9 @@ responses==0.26.0 # via moto rich==15.0.0 # via pip-audit -ruff==0.15.11 - # via -r purchases/requirements-dev.in -s3transfer==0.16.0 +ruff==0.15.14 + # via -r requirements-dev.in +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil @@ -160,17 +154,19 @@ tomli==2.4.1 tomli-w==1.2.0 # via pip-audit typing-extensions==4.15.0 - # via aws-lambda-powertools + # via + # aws-lambda-powertools + # cyclonedx-python-lib urllib3==2.7.0 # via - # -r purchases/requirements-dev.in + # -r requirements-dev.in # botocore # docker # requests # responses werkzeug==3.1.8 # via moto -wheel==0.46.3 +wheel==0.47.0 # via pip-tools xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/purchases/requirements.txt b/backend/compact-connect/lambdas/python/purchases/requirements.txt index 3f9e735363..fa1cfa48fa 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements.txt @@ -6,17 +6,17 @@ # authorizenet==1.1.6 # via -r requirements.in -certifi==2026.2.25 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -idna==3.11 +idna==3.15 # via requests lxml==4.9.4 # via authorizenet pyxb-x==1.2.6.3 # via authorizenet -requests==2.33.1 +requests==2.34.2 # via authorizenet urllib3==2.7.0 # via diff --git a/backend/compact-connect/lambdas/python/search/requirements-dev.txt b/backend/compact-connect/lambdas/python/search/requirements-dev.txt index c8e6e7a752..bdeb9d80d4 100644 --- a/backend/compact-connect/lambdas/python/search/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/search/requirements-dev.txt @@ -4,55 +4,49 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto docker==7.1.0 # via moto -idna==3.11 +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[dynamodb]==5.1.22 - # via -r search/requirements-dev.in + # via werkzeug +moto[dynamodb]==5.2.1 + # via -r lambdas/python/search/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/search/requirements.txt b/backend/compact-connect/lambdas/python/search/requirements.txt index 7c40b5b0fa..c3c15ef45b 100644 --- a/backend/compact-connect/lambdas/python/search/requirements.txt +++ b/backend/compact-connect/lambdas/python/search/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements.in # -certifi==2026.2.25 +certifi==2026.5.20 # via # opensearch-py # requests @@ -14,17 +14,17 @@ events==0.5 # via opensearch-py grpcio==1.80.0 # via opensearch-protobufs -idna==3.11 +idna==3.15 # via requests -opensearch-protobufs==0.19.0 +opensearch-protobufs==1.2.0 # via opensearch-py -opensearch-py==3.1.0 +opensearch-py==3.2.0 # via -r lambdas/python/search/requirements.in -protobuf==7.34.1 +protobuf==7.35.0 # via opensearch-protobufs python-dateutil==2.9.0.post0 # via opensearch-py -requests==2.33.1 +requests==2.34.2 # via opensearch-py six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt index cf042a6233..65ec1147e2 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt @@ -4,57 +4,51 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-user-pre-token/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto docker==7.1.0 # via moto -idna==3.11 +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[dynamodb,s3]==5.1.22 - # via -r staff-user-pre-token/requirements-dev.in + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/staff-user-pre-token/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt index 0a8e043bfb..b649b72d81 100644 --- a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt @@ -4,63 +4,57 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-users/requirements-dev.in # -boto3==1.42.89 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via # joserfc # moto docker==7.1.0 # via moto -faker==40.15.0 - # via -r staff-users/requirements-dev.in -idna==3.11 +faker==40.18.0 + # via -r lambdas/python/staff-users/requirements-dev.in +idna==3.15 # via requests -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore -joserfc==1.6.4 +joserfc==1.6.5 # via moto markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto[cognitoidp,dynamodb,s3]==5.1.22 - # via -r staff-users/requirements-dev.in + # via werkzeug +moto[cognitoidp,dynamodb,s3]==5.2.1 + # via -r lambdas/python/staff-users/requirements-dev.in py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via # moto # responses -requests==2.33.1 +requests==2.34.2 # via # docker # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/requirements-dev.txt b/backend/compact-connect/requirements-dev.txt index 68670cae35..354a42a797 100644 --- a/backend/compact-connect/requirements-dev.txt +++ b/backend/compact-connect/requirements-dev.txt @@ -6,19 +6,19 @@ # boolean-py==5.0 # via license-expression -build==1.4.3 +build==1.5.0 # via pip-tools cachecontrol[filecache]==0.14.4 # via # cachecontrol # pip-audit -certifi==2026.2.25 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -click==8.3.2 +click==8.4.0 # via pip-tools -coverage[toml]==7.13.5 +coverage[toml]==7.14.0 # via # -r requirements-dev.in # pytest-cov @@ -26,17 +26,17 @@ cyclonedx-python-lib==11.7.0 # via pip-audit defusedxml==0.7.1 # via py-serializable -faker==40.15.0 +faker==40.18.0 # via -r requirements-dev.in filelock==3.29.0 # via cachecontrol -idna==3.11 +idna==3.15 # via requests iniconfig==2.3.0 # via pytest license-expression==30.4.4 # via cyclonedx-python-lib -markdown-it-py==4.0.0 +markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py @@ -44,7 +44,7 @@ msgpack==1.1.2 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib -packaging==26.1 +packaging==26.2 # via # build # pip-audit @@ -83,13 +83,13 @@ pytest==9.0.3 # pytest-cov pytest-cov==7.1.0 # via -r requirements-dev.in -requests==2.33.1 +requests==2.34.2 # via # cachecontrol # pip-audit rich==15.0.0 # via pip-audit -ruff==0.15.11 +ruff==0.15.14 # via -r requirements-dev.in sortedcontainers==2.4.0 # via cyclonedx-python-lib @@ -99,7 +99,7 @@ tomli-w==1.2.0 # via pip-audit urllib3==2.7.0 # via requests -wheel==0.46.3 +wheel==0.47.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/backend/compact-connect/requirements.txt b/backend/compact-connect/requirements.txt index bde6e7be9b..cc60e5679c 100644 --- a/backend/compact-connect/requirements.txt +++ b/backend/compact-connect/requirements.txt @@ -10,20 +10,20 @@ attrs==25.4.0 # jsii aws-cdk-asset-awscli-v1==2.2.273 # via aws-cdk-lib -aws-cdk-asset-node-proxy-agent-v6==2.1.1 +aws-cdk-asset-node-proxy-agent-v6==2.1.2 # via aws-cdk-lib -aws-cdk-aws-lambda-python-alpha==2.250.0a0 +aws-cdk-aws-lambda-python-alpha==2.256.1a0 # via -r requirements.in -aws-cdk-cloud-assembly-schema==53.17.0 +aws-cdk-cloud-assembly-schema==53.27.0 # via aws-cdk-lib -aws-cdk-lib==2.250.0 +aws-cdk-lib==2.256.1 # via # -r requirements.in # aws-cdk-aws-lambda-python-alpha # cdk-nag cattrs==25.3.0 # via jsii -cdk-nag==2.37.55 +cdk-nag==2.38.2 # via -r requirements.in constructs==10.6.0 # via @@ -33,7 +33,7 @@ constructs==10.6.0 # cdk-nag importlib-resources==7.1.0 # via jsii -jsii==1.128.0 +jsii==1.131.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-node-proxy-agent-v6 diff --git a/backend/compact-connect/stacks/api_lambda_stack/__init__.py b/backend/compact-connect/stacks/api_lambda_stack/__init__.py index 714f0eab79..c692172bb6 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/__init__.py +++ b/backend/compact-connect/stacks/api_lambda_stack/__init__.py @@ -9,11 +9,11 @@ from aws_cdk.aws_secretsmanager import ISecret, Secret from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.ssm_parameter_utility import SSMParameterUtility from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.python_function import PythonFunction -from common_constructs.ssm_parameter_utility import SSMParameterUtility from stacks import persistent_stack as ps from stacks.provider_users import ProviderUsersStack diff --git a/backend/compact-connect/stacks/api_lambda_stack/attestations.py b/backend/compact-connect/stacks/api_lambda_stack/attestations.py index 2efce5b9a9..3b94976665 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/attestations.py +++ b/backend/compact-connect/stacks/api_lambda_stack/attestations.py @@ -6,9 +6,9 @@ from aws_cdk.aws_dynamodb import ITable from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/bulk_upload_url.py b/backend/compact-connect/stacks/api_lambda_stack/bulk_upload_url.py index 1c36db39b9..b5091bf5f2 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/bulk_upload_url.py +++ b/backend/compact-connect/stacks/api_lambda_stack/bulk_upload_url.py @@ -6,9 +6,9 @@ from aws_cdk.aws_iam import IRole from aws_cdk.aws_s3 import IBucket from aws_cdk.aws_sns import ITopic +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/compact_configuration_api.py b/backend/compact-connect/stacks/api_lambda_stack/compact_configuration_api.py index 86ec808333..a054e3df3e 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/compact_configuration_api.py +++ b/backend/compact-connect/stacks/api_lambda_stack/compact_configuration_api.py @@ -7,9 +7,9 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/credentials.py b/backend/compact-connect/stacks/api_lambda_stack/credentials.py index a2e2dc7c80..d8a406bdb1 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/credentials.py +++ b/backend/compact-connect/stacks/api_lambda_stack/credentials.py @@ -9,9 +9,9 @@ from aws_cdk.aws_secretsmanager import ISecret from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/feature_flags.py b/backend/compact-connect/stacks/api_lambda_stack/feature_flags.py index ddb1dddcbe..e7f2bfcb37 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/feature_flags.py +++ b/backend/compact-connect/stacks/api_lambda_stack/feature_flags.py @@ -4,9 +4,9 @@ from aws_cdk.aws_secretsmanager import Secret from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/post_licenses.py b/backend/compact-connect/stacks/api_lambda_stack/post_licenses.py index 452bcee687..539694a63a 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/post_licenses.py +++ b/backend/compact-connect/stacks/api_lambda_stack/post_licenses.py @@ -7,9 +7,9 @@ from aws_cdk.aws_iam import IRole from aws_cdk.aws_sns import ITopic from aws_cdk.aws_sqs import IQueue +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/provider_management.py b/backend/compact-connect/stacks/api_lambda_stack/provider_management.py index c83475f263..4da002e35d 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/provider_management.py +++ b/backend/compact-connect/stacks/api_lambda_stack/provider_management.py @@ -8,9 +8,9 @@ from aws_cdk.aws_events import EventBus from aws_cdk.aws_iam import Policy, PolicyStatement from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/provider_users.py b/backend/compact-connect/stacks/api_lambda_stack/provider_users.py index 0dce00f7a6..a5b23e7f18 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/provider_users.py +++ b/backend/compact-connect/stacks/api_lambda_stack/provider_users.py @@ -10,10 +10,10 @@ from aws_cdk.aws_logs import RetentionDays from aws_cdk.aws_secretsmanager import Secret from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps from stacks.provider_users import ProviderUsersStack diff --git a/backend/compact-connect/stacks/api_lambda_stack/public_lookup_api.py b/backend/compact-connect/stacks/api_lambda_stack/public_lookup_api.py index c0bad9ccb5..557004de63 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/public_lookup_api.py +++ b/backend/compact-connect/stacks/api_lambda_stack/public_lookup_api.py @@ -7,9 +7,9 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_lambda_stack/purchases.py b/backend/compact-connect/stacks/api_lambda_stack/purchases.py index b2e65aa131..df96b633c3 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/purchases.py +++ b/backend/compact-connect/stacks/api_lambda_stack/purchases.py @@ -8,10 +8,10 @@ from aws_cdk.aws_secretsmanager import ISecret from cdk_nag import NagSuppressions from common_constructs.alarm_topic import AlarmTopic +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks.persistent_stack import CompactConfigurationTable, PersistentStack, ProviderTable, TransactionHistoryTable diff --git a/backend/compact-connect/stacks/api_lambda_stack/staff_users.py b/backend/compact-connect/stacks/api_lambda_stack/staff_users.py index 6cc4f061fb..609390c7f4 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/staff_users.py +++ b/backend/compact-connect/stacks/api_lambda_stack/staff_users.py @@ -16,10 +16,10 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions -from constructs import Construct - from common_constructs.python_function import PythonFunction from common_constructs.user_pool import UserPool +from constructs import Construct + from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py b/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py index a0c46e7b3a..8126988036 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/compact_configuration_api.py @@ -2,9 +2,9 @@ from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks.api_lambda_stack import ApiLambdaStack from .api_model import ApiModel diff --git a/backend/compact-connect/stacks/api_stack/v1_api/provider_management.py b/backend/compact-connect/stacks/api_stack/v1_api/provider_management.py index bd01159bcc..39e180ee67 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/provider_management.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/provider_management.py @@ -9,9 +9,9 @@ TreatMissingData, ) from aws_cdk.aws_cloudwatch_actions import SnsAction +from common_constructs.python_function import PythonFunction from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks.api_lambda_stack import ApiLambdaStack from .api_model import ApiModel diff --git a/backend/compact-connect/stacks/api_stack/v1_api/provider_users.py b/backend/compact-connect/stacks/api_stack/v1_api/provider_users.py index 9735a0a931..f58dd201ae 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/provider_users.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/provider_users.py @@ -3,10 +3,10 @@ from aws_cdk import Duration from aws_cdk.aws_apigateway import LambdaIntegration, MethodResponse, Resource from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks.api_lambda_stack import ApiLambdaStack from .api_model import ApiModel diff --git a/backend/compact-connect/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py b/backend/compact-connect/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py index b8e78a96d4..2c1b6c2a84 100644 --- a/backend/compact-connect/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py +++ b/backend/compact-connect/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py @@ -19,11 +19,11 @@ ) from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.ssm_parameter_utility import SSMParameterUtility from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction -from common_constructs.ssm_parameter_utility import SSMParameterUtility from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/disaster_recovery_stack/sync_table_step_function.py b/backend/compact-connect/stacks/disaster_recovery_stack/sync_table_step_function.py index 91c6c89df9..f22301d0c5 100644 --- a/backend/compact-connect/stacks/disaster_recovery_stack/sync_table_step_function.py +++ b/backend/compact-connect/stacks/disaster_recovery_stack/sync_table_step_function.py @@ -19,10 +19,10 @@ ) from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.persistent_stack.ssn_table import SSN_SYNC_STATE_MACHINE_NAME diff --git a/backend/compact-connect/stacks/event_listener_stack/__init__.py b/backend/compact-connect/stacks/event_listener_stack/__init__.py index afae5ddcfb..1788e54752 100644 --- a/backend/compact-connect/stacks/event_listener_stack/__init__.py +++ b/backend/compact-connect/stacks/event_listener_stack/__init__.py @@ -5,12 +5,12 @@ from aws_cdk import Duration from aws_cdk.aws_events import EventBus from cdk_nag import NagSuppressions -from common_constructs.stack import AppStack -from constructs import Construct - from common_constructs.python_function import PythonFunction from common_constructs.queue_event_listener import QueueEventListener from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack +from constructs import Construct + from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/expiration_reminder_stack/__init__.py b/backend/compact-connect/stacks/expiration_reminder_stack/__init__.py index 1875ca9392..c7fc3f29c9 100644 --- a/backend/compact-connect/stacks/expiration_reminder_stack/__init__.py +++ b/backend/compact-connect/stacks/expiration_reminder_stack/__init__.py @@ -10,10 +10,10 @@ from aws_cdk.aws_events_targets import LambdaFunction from aws_cdk.aws_logs import QueryDefinition, QueryString, RetentionDays from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import event_state_stack as ess from stacks import persistent_stack as ps from stacks import search_persistent_stack as sps diff --git a/backend/compact-connect/stacks/feature_flag_stack/__init__.py b/backend/compact-connect/stacks/feature_flag_stack/__init__.py index e2f30e6b3a..1257a1f854 100644 --- a/backend/compact-connect/stacks/feature_flag_stack/__init__.py +++ b/backend/compact-connect/stacks/feature_flag_stack/__init__.py @@ -74,10 +74,10 @@ from aws_cdk.aws_secretsmanager import Secret from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.feature_flag_stack.feature_flag_resource import FeatureFlagEnvironmentName, FeatureFlagResource diff --git a/backend/compact-connect/stacks/ingest_stack.py b/backend/compact-connect/stacks/ingest_stack.py index cb8e0c4efa..5391597b19 100644 --- a/backend/compact-connect/stacks/ingest_stack.py +++ b/backend/compact-connect/stacks/ingest_stack.py @@ -8,12 +8,12 @@ from aws_cdk.aws_events import EventBus, EventPattern, Rule from aws_cdk.aws_events_targets import SqsQueue from cdk_nag import NagSuppressions -from common_constructs.stack import AppStack, Stack -from constructs import Construct - from common_constructs.python_function import PythonFunction from common_constructs.queued_lambda_processor import QueuedLambdaProcessor from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack, Stack +from constructs import Construct + from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/notification_stack.py b/backend/compact-connect/stacks/notification_stack.py index d81676d9d9..35f89cc3d5 100644 --- a/backend/compact-connect/stacks/notification_stack.py +++ b/backend/compact-connect/stacks/notification_stack.py @@ -8,13 +8,13 @@ from aws_cdk.aws_events import EventBus, EventPattern, IEventBus, Rule from aws_cdk.aws_events_targets import SqsQueue from cdk_nag import NagSuppressions -from common_constructs.stack import AppStack -from constructs import Construct - from common_constructs.python_function import PythonFunction from common_constructs.queue_event_listener import QueueEventListener from common_constructs.queued_lambda_processor import QueuedLambdaProcessor from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack +from constructs import Construct + from stacks import event_state_stack as ess from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/persistent_stack/__init__.py b/backend/compact-connect/stacks/persistent_stack/__init__.py index ffc67af3aa..5086d646c5 100644 --- a/backend/compact-connect/stacks/persistent_stack/__init__.py +++ b/backend/compact-connect/stacks/persistent_stack/__init__.py @@ -13,13 +13,13 @@ COGNITO_AUTH_DOMAIN_SUFFIX, PersistentStackFrontendAppConfigUtility, ) +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.python_common_layer_versions import PythonCommonLayerVersions from common_constructs.security_profile import SecurityProfile +from common_constructs.ssm_parameter_utility import SSMParameterUtility from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.nodejs_function import NodejsFunction -from common_constructs.python_common_layer_versions import PythonCommonLayerVersions -from common_constructs.ssm_parameter_utility import SSMParameterUtility from stacks.backup_infrastructure_stack import BackupInfrastructureStack from stacks.persistent_stack.bulk_uploads_bucket import BulkUploadsBucket from stacks.persistent_stack.compact_configuration_table import CompactConfigurationTable diff --git a/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py b/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py index 5f39f145e5..9e9ecc42ff 100644 --- a/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py +++ b/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py @@ -15,11 +15,11 @@ from cdk_nag import NagSuppressions from common_constructs.access_logs_bucket import AccessLogsBucket from common_constructs.bucket import Bucket +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct import stacks.persistent_stack as ps -from common_constructs.python_function import PythonFunction class BulkUploadsBucket(Bucket): @@ -141,21 +141,10 @@ def _add_v1_ingest_object_events( }, ], ) - NagSuppressions.add_resource_suppressions_by_path( - stack, - path=f'{stack.node.path}/BucketNotificationsHandler050a0587b7544547bf325f094a3db834/' - 'Role/DefaultPolicy/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM5', - 'appliesTo': ['Resource::*'], - 'reason': """ - The lambda policy is scoped specifically to the PutBucketNotification action, which - suits its purpose. - """, - }, - ], - ) + + # Per-bucket notification permissions are attached as inline HandlerPolicy on the bucket's + # `Notifications` construct as of CDK v2.252.0 (not Role/DefaultPolicy on the stack singleton) so we only + # need to suppress the handler role. See https://github.com/aws/aws-cdk/issues/37667. NagSuppressions.add_resource_suppressions_by_path( stack, path=f'{stack.node.path}/BucketNotificationsHandler050a0587b7544547bf325f094a3db834/Role/Resource', diff --git a/backend/compact-connect/stacks/persistent_stack/compact_configuration_table.py b/backend/compact-connect/stacks/persistent_stack/compact_configuration_table.py index e1017f276e..3a261d83b8 100644 --- a/backend/compact-connect/stacks/persistent_stack/compact_configuration_table.py +++ b/backend/compact-connect/stacks/persistent_stack/compact_configuration_table.py @@ -10,9 +10,9 @@ ) from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py b/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py index f06ed68444..2bae02e1b8 100644 --- a/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py +++ b/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py @@ -7,11 +7,10 @@ from aws_cdk.aws_logs import LogGroup, RetentionDays from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction - from .compact_configuration_table import CompactConfigurationTable diff --git a/backend/compact-connect/stacks/persistent_stack/data_event_table.py b/backend/compact-connect/stacks/persistent_stack/data_event_table.py index 4abe043781..b7dd9b4c2a 100644 --- a/backend/compact-connect/stacks/persistent_stack/data_event_table.py +++ b/backend/compact-connect/stacks/persistent_stack/data_event_table.py @@ -19,11 +19,11 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions -from constructs import Construct - from common_constructs.backup_plan import CCBackupPlan from common_constructs.python_function import PythonFunction from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from constructs import Construct + from stacks import persistent_stack as ps from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/compact-connect/stacks/persistent_stack/provider_table.py b/backend/compact-connect/stacks/persistent_stack/provider_table.py index 2b59c458df..a9764e87d5 100644 --- a/backend/compact-connect/stacks/persistent_stack/provider_table.py +++ b/backend/compact-connect/stacks/persistent_stack/provider_table.py @@ -12,9 +12,9 @@ ) from aws_cdk.aws_kms import Key from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py b/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py index d73be1aaf5..1f1f701b31 100644 --- a/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py +++ b/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py @@ -13,12 +13,12 @@ from aws_cdk.aws_s3_notifications import LambdaDestination from cdk_nag import NagSuppressions from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.backup_plan import CCBackupPlan from common_constructs.bucket import Bucket +from common_constructs.python_function import PythonFunction from constructs import Construct import stacks.persistent_stack as ps -from common_constructs.backup_plan import CCBackupPlan -from common_constructs.python_function import PythonFunction from stacks.backup_infrastructure_stack import BackupInfrastructureStack @@ -146,21 +146,6 @@ def _add_v1_object_events(self, provider_table: Table, encryption_key: IKey): }, ], ) - NagSuppressions.add_resource_suppressions_by_path( - stack, - path=f'{stack.node.path}/BucketNotificationsHandler050a0587b7544547bf325f094a3db834/' - 'Role/DefaultPolicy/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM5', - 'appliesTo': ['Resource::*'], - 'reason': """ - The lambda policy is scoped specifically to the PutBucketNotification action, which - suits its purpose. - """, - }, - ], - ) NagSuppressions.add_resource_suppressions_by_path( stack, path=f'{stack.node.path}/BucketNotificationsHandler050a0587b7544547bf325f094a3db834/Role/Resource', diff --git a/backend/compact-connect/stacks/persistent_stack/ssn_table.py b/backend/compact-connect/stacks/persistent_stack/ssn_table.py index 5fc11514eb..8e1dcd12f4 100644 --- a/backend/compact-connect/stacks/persistent_stack/ssn_table.py +++ b/backend/compact-connect/stacks/persistent_stack/ssn_table.py @@ -24,12 +24,12 @@ from aws_cdk.aws_kms import Key from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions -from common_constructs.stack import Stack -from constructs import Construct - from common_constructs.backup_plan import CCBackupPlan from common_constructs.python_function import PythonFunction from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from common_constructs.stack import Stack +from constructs import Construct + from stacks.backup_infrastructure_stack import BackupInfrastructureStack # Name for SSN disaster recovery sync table state machine for specific permissions diff --git a/backend/compact-connect/stacks/persistent_stack/staff_users.py b/backend/compact-connect/stacks/persistent_stack/staff_users.py index 440daa92b6..b35f173a46 100644 --- a/backend/compact-connect/stacks/persistent_stack/staff_users.py +++ b/backend/compact-connect/stacks/persistent_stack/staff_users.py @@ -14,13 +14,13 @@ ) from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.python_function import PythonFunction +from common_constructs.user_pool import UserPool from constructs import Construct from common_constructs.cognito_user_backup import CognitoUserBackup -from common_constructs.nodejs_function import NodejsFunction -from common_constructs.python_function import PythonFunction from common_constructs.resource_scope_mixin import ResourceScopeMixin -from common_constructs.user_pool import UserPool from stacks import persistent_stack as ps from stacks.backup_infrastructure_stack import BackupInfrastructureStack from stacks.persistent_stack.users_table import UsersTable diff --git a/backend/compact-connect/stacks/persistent_stack/transaction_history_table.py b/backend/compact-connect/stacks/persistent_stack/transaction_history_table.py index bd1f997ca7..e80f5bc6b1 100644 --- a/backend/compact-connect/stacks/persistent_stack/transaction_history_table.py +++ b/backend/compact-connect/stacks/persistent_stack/transaction_history_table.py @@ -10,9 +10,9 @@ ) from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/compact-connect/stacks/persistent_stack/user_email_notifications.py b/backend/compact-connect/stacks/persistent_stack/user_email_notifications.py index cf706f27c6..edf490cd66 100644 --- a/backend/compact-connect/stacks/persistent_stack/user_email_notifications.py +++ b/backend/compact-connect/stacks/persistent_stack/user_email_notifications.py @@ -9,11 +9,10 @@ from aws_cdk.aws_sns import Subscription, SubscriptionProtocol, Topic from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction - class UserEmailNotifications(Construct): """This Construct leverages SES to set up an email notification system to send cognito user events from our custom diff --git a/backend/compact-connect/stacks/persistent_stack/users_table.py b/backend/compact-connect/stacks/persistent_stack/users_table.py index 7629a74de8..020c6b7513 100644 --- a/backend/compact-connect/stacks/persistent_stack/users_table.py +++ b/backend/compact-connect/stacks/persistent_stack/users_table.py @@ -11,9 +11,9 @@ ) from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/compact-connect/stacks/provider_users/provider_users.py b/backend/compact-connect/stacks/provider_users/provider_users.py index 1c8fd04e15..576b73427d 100644 --- a/backend/compact-connect/stacks/provider_users/provider_users.py +++ b/backend/compact-connect/stacks/provider_users/provider_users.py @@ -12,11 +12,11 @@ UserPoolOperation, ) from aws_cdk.aws_kms import IKey +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.user_pool import UserPool from constructs import Construct from common_constructs.cognito_user_backup import CognitoUserBackup -from common_constructs.nodejs_function import NodejsFunction -from common_constructs.user_pool import UserPool from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/reporting_stack.py b/backend/compact-connect/stacks/reporting_stack.py index 19bde28ff7..4aff49e356 100644 --- a/backend/compact-connect/stacks/reporting_stack.py +++ b/backend/compact-connect/stacks/reporting_stack.py @@ -11,11 +11,11 @@ from aws_cdk.aws_lambda import Runtime from aws_cdk.aws_logs import QueryDefinition, QueryString from cdk_nag import NagSuppressions +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.python_function import PythonFunction from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.nodejs_function import NodejsFunction -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps diff --git a/backend/compact-connect/stacks/search_persistent_stack/index_manager.py b/backend/compact-connect/stacks/search_persistent_stack/index_manager.py index e5b34bd9b2..e395d36f79 100644 --- a/backend/compact-connect/stacks/search_persistent_stack/index_manager.py +++ b/backend/compact-connect/stacks/search_persistent_stack/index_manager.py @@ -7,11 +7,11 @@ from aws_cdk.aws_opensearchservice import Domain from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.constants import PROD_ENV_NAME +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.constants import PROD_ENV_NAME -from common_constructs.python_function import PythonFunction from stacks.vpc_stack import VpcStack # Index configuration constants diff --git a/backend/compact-connect/stacks/search_persistent_stack/populate_provider_documents_handler.py b/backend/compact-connect/stacks/search_persistent_stack/populate_provider_documents_handler.py index d3c013ba43..db8241bbcd 100644 --- a/backend/compact-connect/stacks/search_persistent_stack/populate_provider_documents_handler.py +++ b/backend/compact-connect/stacks/search_persistent_stack/populate_provider_documents_handler.py @@ -8,10 +8,10 @@ from aws_cdk.aws_opensearchservice import Domain from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.vpc_stack import VpcStack diff --git a/backend/compact-connect/stacks/search_persistent_stack/provider_search_domain.py b/backend/compact-connect/stacks/search_persistent_stack/provider_search_domain.py index a0ec2da241..944a97f388 100644 --- a/backend/compact-connect/stacks/search_persistent_stack/provider_search_domain.py +++ b/backend/compact-connect/stacks/search_persistent_stack/provider_search_domain.py @@ -18,10 +18,10 @@ ) from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.constants import PROD_ENV_NAME from common_constructs.stack import Stack from constructs import Construct -from common_constructs.constants import PROD_ENV_NAME from stacks.vpc_stack import VpcStack PROD_EBS_VOLUME_SIZE = 25 diff --git a/backend/compact-connect/stacks/search_persistent_stack/provider_update_ingest_handler.py b/backend/compact-connect/stacks/search_persistent_stack/provider_update_ingest_handler.py index b63a881e58..adccc8e153 100644 --- a/backend/compact-connect/stacks/search_persistent_stack/provider_update_ingest_handler.py +++ b/backend/compact-connect/stacks/search_persistent_stack/provider_update_ingest_handler.py @@ -10,11 +10,11 @@ from aws_cdk.aws_opensearchservice import Domain from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.queued_lambda_processor import QueuedLambdaProcessor from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction -from common_constructs.queued_lambda_processor import QueuedLambdaProcessor from stacks.persistent_stack import ProviderTable from stacks.vpc_stack import VpcStack diff --git a/backend/compact-connect/stacks/search_persistent_stack/search_handler.py b/backend/compact-connect/stacks/search_persistent_stack/search_handler.py index 7980836498..bd22360e72 100644 --- a/backend/compact-connect/stacks/search_persistent_stack/search_handler.py +++ b/backend/compact-connect/stacks/search_persistent_stack/search_handler.py @@ -10,10 +10,10 @@ from aws_cdk.aws_s3 import IBucket from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.vpc_stack import VpcStack diff --git a/backend/compact-connect/stacks/state_api_stack/v1_api/bulk_upload_url.py b/backend/compact-connect/stacks/state_api_stack/v1_api/bulk_upload_url.py index 8499d2c8af..953095f938 100644 --- a/backend/compact-connect/stacks/state_api_stack/v1_api/bulk_upload_url.py +++ b/backend/compact-connect/stacks/state_api_stack/v1_api/bulk_upload_url.py @@ -5,10 +5,10 @@ from aws_cdk import Duration from aws_cdk.aws_apigateway import AuthorizationType, LambdaIntegration, MethodOptions, MethodResponse, Resource from aws_cdk.aws_iam import IRole +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps from .api_model import ApiModel diff --git a/backend/compact-connect/stacks/state_api_stack/v1_api/post_licenses.py b/backend/compact-connect/stacks/state_api_stack/v1_api/post_licenses.py index 8e9bf08404..4e1005d8ca 100644 --- a/backend/compact-connect/stacks/state_api_stack/v1_api/post_licenses.py +++ b/backend/compact-connect/stacks/state_api_stack/v1_api/post_licenses.py @@ -5,10 +5,10 @@ from aws_cdk import Duration from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource from aws_cdk.aws_iam import IRole +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps from .api_model import ApiModel diff --git a/backend/compact-connect/stacks/state_api_stack/v1_api/provider_management.py b/backend/compact-connect/stacks/state_api_stack/v1_api/provider_management.py index 6472aa0713..7b4e2b8161 100644 --- a/backend/compact-connect/stacks/state_api_stack/v1_api/provider_management.py +++ b/backend/compact-connect/stacks/state_api_stack/v1_api/provider_management.py @@ -7,10 +7,10 @@ from aws_cdk.aws_dynamodb import ITable from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps from stacks.persistent_stack import ProviderTable diff --git a/backend/compact-connect/stacks/state_auth/state_auth_users.py b/backend/compact-connect/stacks/state_auth/state_auth_users.py index 95acc98c6c..116d04f25b 100644 --- a/backend/compact-connect/stacks/state_auth/state_auth_users.py +++ b/backend/compact-connect/stacks/state_auth/state_auth_users.py @@ -66,6 +66,11 @@ def __init__( { 'id': 'AwsSolutions-COG3', 'reason': 'Threat protection mode enforcement offers no benefit when there are no users.', - } + }, + { + 'id': 'AwsSolutions-COG8', + 'reason': 'This pool is for state API machine-to-machine auth only; ' + 'Cognito Plus features are not relevant for a user pool with no users.', + }, ], ) diff --git a/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py b/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py index 9ebef371b1..f1aa78eb4d 100644 --- a/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py +++ b/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py @@ -26,10 +26,10 @@ ) from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps diff --git a/backend/compact-connect/tests/app/base.py b/backend/compact-connect/tests/app/base.py index 1d83261e66..5fe1a8e81c 100644 --- a/backend/compact-connect/tests/app/base.py +++ b/backend/compact-connect/tests/app/base.py @@ -13,10 +13,10 @@ from aws_cdk.aws_kms import CfnKey from aws_cdk.aws_lambda import CfnEventSourceMapping from aws_cdk.aws_sqs import CfnQueue +from common_constructs.backup_plan import CCBackupPlan from common_constructs.stack import Stack from app import CompactConnectApp -from common_constructs.backup_plan import CCBackupPlan from pipeline import BackendStage from stacks.api_stack import ApiStack from stacks.persistent_stack import PersistentStack diff --git a/backend/compact-connect/tests/common_constructs/test_cognito_user_backup.py b/backend/compact-connect/tests/common_constructs/test_cognito_user_backup.py index 1bb4fa3a3f..02df5f7506 100644 --- a/backend/compact-connect/tests/common_constructs/test_cognito_user_backup.py +++ b/backend/compact-connect/tests/common_constructs/test_cognito_user_backup.py @@ -19,10 +19,10 @@ from aws_cdk.aws_s3 import CfnBucket from aws_cdk.aws_sns import Topic from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.python_common_layer_versions import PythonCommonLayerVersions from common_constructs.stack import AppStack, StandardTags from common_constructs.cognito_user_backup import CognitoUserBackup -from common_constructs.python_common_layer_versions import PythonCommonLayerVersions from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/compact-connect/tests/common_constructs/test_data_migration.py b/backend/compact-connect/tests/common_constructs/test_data_migration.py deleted file mode 100644 index 9b86270366..0000000000 --- a/backend/compact-connect/tests/common_constructs/test_data_migration.py +++ /dev/null @@ -1,93 +0,0 @@ -from unittest import TestCase - -from aws_cdk import App, Stack -from aws_cdk.assertions import Template -from aws_cdk.aws_iam import Role, ServicePrincipal -from aws_cdk.aws_lambda import CfnFunction, Runtime - -# Use the dummy migration directory for testing -MIGRATION_DIR = 'dummy_migration' - - -class TestDataMigration(TestCase): - def test_data_migration_synthesizes(self): - from common_constructs.stack import AppStack, StandardTags - - from common_constructs.data_migration import DataMigration - from common_constructs.python_common_layer_versions import PythonCommonLayerVersions - - app = App() - # The persistent stack and layer are required for DataMigration, as an internal lambda depends on it. - # Use a non-pipeline environment name so domain_name is not required (avoids HostedZone.from_lookup in tests). - common_stack = AppStack( - app, - 'CommonStack', - environment_context={}, - environment_name='sandbox', - standard_tags=StandardTags(project='compact-connect', service='compact-connect', environment='test'), - ) - # Create common lambda layers - PythonCommonLayerVersions( - common_stack, - 'CommonLayers', - compatible_runtimes=[Runtime.PYTHON_3_14], - ) - - stack = Stack(app, 'Stack') - - # Create a role for the migration function - role = Role(stack, 'MigrationRole', assumed_by=ServicePrincipal('lambda.amazonaws.com')) - - # Create environment variables for the lambda - lambda_environment = {'ENV_VAR1': 'value1', 'ENV_VAR2': 'value2'} - - # Create custom resource properties - custom_resource_properties = {'TestProperty': 'test-value', 'AnotherProperty': 123} - - # Create the DataMigration construct - data_migration = DataMigration( - stack, - 'TestMigration', - migration_dir=MIGRATION_DIR, - lambda_environment=lambda_environment, - role=role, - custom_resource_properties=custom_resource_properties, - ) - - # Generate the CloudFormation template - template = Template.from_stack(stack) - - # Test that the migration function is created with the correct properties - template.has_resource( - CfnFunction.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'Handler': 'dummy_migration.main.on_event', - 'Timeout': 900, # 15 minutes in seconds - 'Environment': {'Variables': lambda_environment}, - 'Role': {'Fn::GetAtt': [stack.get_logical_id(role.node.default_child), 'Arn']}, - } - }, - ) - - # Test that the custom resource is created with the correct properties - template.has_resource( - 'Custom::DataMigration', - props={ - 'Properties': { - 'ServiceToken': { - 'Fn::GetAtt': [ - stack.get_logical_id( - data_migration.provider.node.find_child('framework-onEvent').node.default_child - ), - 'Arn', - ] - }, - 'TestProperty': 'test-value', - 'AnotherProperty': 123, - } - }, - ) - - # Test that the grant_principal property returns the migration function's grant_principal - self.assertEqual(data_migration.grant_principal, data_migration.migration_function.grant_principal) diff --git a/backend/cosmetology-app/README.md b/backend/cosmetology-app/README.md index 7e2971a245..ebfeefdc0c 100644 --- a/backend/cosmetology-app/README.md +++ b/backend/cosmetology-app/README.md @@ -25,14 +25,6 @@ To deploy this app, you will need: 2) Python>=3.14 installed on your machine, preferably through a virtual environment management tool like [pyenv](https://github.com/pyenv/pyenv), for clean management of virtual environments across multiple Python versions. - > Note: The [purchases lambda](./lambdas/python/purchases) depends on the - > [Authorize.Net python sdk](https://github.com/AuthorizeNet/sdk-python/issues/164), which is barely maintained at - > present, and is not yet compatible with Python 3.13. Due to that restriction, we have to hold back the python - > version of just this lambda package, so that the entire project is not impacted. For local development, this means - > that, at least for lambdas that use this package, developers will have to have a dedicated python environment, held - > back at Python 3.12. That environment and its dependencies will have to be maintained separately from those of the - > rest of the project, which can all share a common virtual environment and common dependencies, without excessive risk of - > version conflicts. 3) Otherwise, follow the [Prerequisites section](https://cdkworkshop.com/15-prerequisites.html) of the CDK workshop to prepare your system to work with AWS-CDK, including a NodeJS install. 4) Follow the steps in the [Installing Dependencies](#installing-dependencies) section. @@ -97,15 +89,10 @@ To add additional dependencies, for example other CDK libraries, just add them t ### Convenience scripts To simplify dependency installation in this project, which includes many runtimes with similar dependencies, maintain -the dependency files with two convenience scripts, which manage the file contents for _most_ runtimes (See Note below), +the dependency files with two convenience scripts, which manage the file contents for the runtimes, [compile_requirements.sh](./bin/compile_requirements.sh), and installs the defined dependencies, [sync_deps.sh](./bin/sync_deps.sh). -> Note: Due to its dependency on the Authorize.Net python sdk, the [purchases lambda](./lambdas/python/purchases) -> dependencies have to be maintained separately from the rest of the project. You can update the requirements files for -> that lambda directly with the `pip-compile` command, and install dependencies into your python enviornment dedicated -> to that lambda with the `pip-sync` command. - ## Local Development [Back to top](#compact-connect---backend-developer-documentation) diff --git a/backend/cosmetology-app/bin/compile_requirements.sh b/backend/cosmetology-app/bin/compile_requirements.sh index 1a843d80e3..7635767061 100755 --- a/backend/cosmetology-app/bin/compile_requirements.sh +++ b/backend/cosmetology-app/bin/compile_requirements.sh @@ -16,11 +16,6 @@ pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/disas pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/disaster-recovery/requirements.in pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/provider-data-v1/requirements-dev.in pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/provider-data-v1/requirements.in -# The purchases lambda requires Python<=3.12, which is older than everything else in this project, so we have -# to install that separately, if we want to be developing with Python>=3.13 for the rest of the project, to -# avoid installation failures -# pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/purchases/requirements-dev.in -# pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/purchases/requirements.in pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/search/requirements-dev.in pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/search/requirements.in pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/staff-user-pre-token/requirements-dev.in diff --git a/backend/cosmetology-app/common_constructs/backup_plan.py b/backend/cosmetology-app/common_constructs/backup_plan.py deleted file mode 100644 index e3e088e900..0000000000 --- a/backend/cosmetology-app/common_constructs/backup_plan.py +++ /dev/null @@ -1,71 +0,0 @@ -from aws_cdk import Duration -from aws_cdk.aws_backup import ( - BackupPlan, - BackupPlanCopyActionProps, - BackupPlanRule, - BackupResource, - BackupSelection, - BackupVault, - IBackupVault, -) -from aws_cdk.aws_events import Schedule -from aws_cdk.aws_iam import IRole -from constructs import Construct - - -class CCBackupPlan(Construct): - """ - Common construct for creating backup plans for CompactConnect resources with cross-account replication. - - This consolidated backup plan construct can be used for any AWS resource type that supports - AWS Backup (DynamoDB tables, S3 buckets, etc.) by accepting a list of backup resources - and a name prefix. - """ - - def __init__( - self, - scope: Construct, - construct_id: str, - *, - backup_plan_name_prefix: str, - backup_resources: list[BackupResource], - backup_vault: BackupVault, - backup_service_role: IRole, - cross_account_backup_vault: IBackupVault, - backup_policy: dict, - **kwargs, - ): - super().__init__(scope, construct_id, **kwargs) - - # Create backup plan - self.backup_plan = BackupPlan( - self, - 'BackupPlan', - backup_plan_name=f'{backup_plan_name_prefix}-BackupPlan', - backup_plan_rules=[ - BackupPlanRule( - rule_name=f'{backup_plan_name_prefix}-Backup', - backup_vault=backup_vault, - schedule_expression=Schedule.cron(**backup_policy['schedule']), - delete_after=Duration.days(backup_policy['delete_after_days']), - move_to_cold_storage_after=Duration.days(backup_policy['cold_storage_after_days']), - copy_actions=[ - BackupPlanCopyActionProps( - destination_backup_vault=cross_account_backup_vault, - delete_after=Duration.days(backup_policy['delete_after_days']), - move_to_cold_storage_after=Duration.days(backup_policy['cold_storage_after_days']), - ) - ], - ) - ], - ) - - # Create backup selection to include the resources - self.backup_selection = BackupSelection( - self, - 'BackupSelection', - backup_plan=self.backup_plan, - resources=backup_resources, - backup_selection_name=f'{backup_plan_name_prefix}-Selection', - role=backup_service_role, - ) diff --git a/backend/cosmetology-app/common_constructs/cognito_user_backup.py b/backend/cosmetology-app/common_constructs/cognito_user_backup.py index b14c169853..ff8247aee5 100644 --- a/backend/cosmetology-app/common_constructs/cognito_user_backup.py +++ b/backend/cosmetology-app/common_constructs/cognito_user_backup.py @@ -22,12 +22,12 @@ from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.backup_plan import CCBackupPlan from common_constructs.bucket import Bucket +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan -from common_constructs.python_function import PythonFunction from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/cosmetology-app/common_constructs/constants.py b/backend/cosmetology-app/common_constructs/constants.py deleted file mode 100644 index 26201949e2..0000000000 --- a/backend/cosmetology-app/common_constructs/constants.py +++ /dev/null @@ -1,2 +0,0 @@ -PROD_ENV_NAME = 'prod' -BETA_ENV_NAME = 'beta' diff --git a/backend/cosmetology-app/common_constructs/data_migration.py b/backend/cosmetology-app/common_constructs/data_migration.py deleted file mode 100644 index 1f70df6223..0000000000 --- a/backend/cosmetology-app/common_constructs/data_migration.py +++ /dev/null @@ -1,133 +0,0 @@ -import os - -import jsii -from aws_cdk import CustomResource, Duration, Stack -from aws_cdk.aws_iam import IGrantable, IRole -from aws_cdk.aws_logs import LogGroup, RetentionDays -from aws_cdk.custom_resources import Provider -from cdk_nag import NagSuppressions -from constructs import Construct - -from common_constructs.python_function import PythonFunction - - -@jsii.implements(IGrantable) -class DataMigration(Construct): - def __init__( - self, - scope: Construct, - construct_id: str, - *, - migration_dir: str, - lambda_environment: dict, - role: IRole = None, - custom_resource_properties: dict = None, - ): - """ - This construct is used to run a data migration. - It will create a lambda function and a provider that will run the migration. - - :param migration_dir: The directory containing the migration code. Name the directory after the associated - GitHub issue that requires the migration. - :param lambda_environment: The environment variables for the lambda function. - :param role: The IAM role to use for the lambda function, with the necessary permissions. - :param custom_resource_properties: The properties for the custom resource. - """ - super().__init__(scope, construct_id) - self.migration_function = PythonFunction( - self, - 'MigrationFunction', - index=os.path.join(migration_dir, 'main.py'), - lambda_dir='migration', - handler='on_event', - log_retention=RetentionDays.ONE_MONTH, - role=role, - environment=lambda_environment, - timeout=Duration.minutes(15), - # These are one-time migration scripts, so it is cost-effective to increase their memory size - # so they complete their process sooner - memory_size=3008, - ) - provider_log_group = LogGroup( - self, - 'ProviderLogGroup', - retention=RetentionDays.ONE_DAY, - ) - NagSuppressions.add_resource_suppressions( - provider_log_group, - suppressions=[ - { - 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', - 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' - ' logs to operators with credentials for the AWS account is desired. Encryption is not appropriate' - ' here.', - }, - ], - ) - self.provider = Provider( - self, - 'Provider', - on_event_handler=self.migration_function, - log_group=provider_log_group, - ) - NagSuppressions.add_resource_suppressions_by_path( - Stack.of(self), - f'{self.provider.node.path}/framework-onEvent/Resource', - [ - { - 'id': 'AwsSolutions-L1', - 'reason': 'We do not control this runtime', - }, - { - 'id': 'HIPAA.Security-LambdaConcurrency', - 'reason': 'This function is only run at deploy time, by CloudFormation and has no need for ' - 'concurrency limits.', - }, - { - 'id': 'HIPAA.Security-LambdaDLQ', - 'reason': 'This is a synchronous function run at deploy time. It does not need a DLQ', - }, - { - 'id': 'HIPAA.Security-LambdaInsideVPC', - 'reason': 'We may choose to move our lambdas into private VPC subnets in a future enhancement', - }, - ], - ) - - NagSuppressions.add_resource_suppressions_by_path( - Stack.of(self), - path=f'{self.provider.node.path}/framework-onEvent/ServiceRole/DefaultPolicy/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM5', - 'reason': 'The actions in this policy are specifically what this lambda needs to read ' - 'and is scoped to one table and encryption key.', - }, - ], - ) - - NagSuppressions.add_resource_suppressions_by_path( - Stack.of(self), - path=f'{self.provider.node.path}/framework-onEvent/ServiceRole/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM4', - 'appliesTo': [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' - ], # noqa: E501 line-too-long - 'reason': 'This policy is appropriate for the log retention lambda', - }, - ], - ) - - self.custom_resource = CustomResource( - self, - 'CustomResource', - resource_type='Custom::DataMigration', - service_token=self.provider.service_token, - properties=custom_resource_properties, - ) - - @property - def grant_principal(self): - return self.migration_function.grant_principal diff --git a/backend/cosmetology-app/common_constructs/nodejs_function.py b/backend/cosmetology-app/common_constructs/nodejs_function.py deleted file mode 100644 index 127b16a853..0000000000 --- a/backend/cosmetology-app/common_constructs/nodejs_function.py +++ /dev/null @@ -1,120 +0,0 @@ -import os - -from aws_cdk import Duration -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData -from aws_cdk.aws_cloudwatch_actions import SnsAction -from aws_cdk.aws_iam import IRole, Role, ServicePrincipal -from aws_cdk.aws_lambda import Runtime -from aws_cdk.aws_lambda_nodejs import BundlingOptions, OutputFormat -from aws_cdk.aws_lambda_nodejs import NodejsFunction as CdkNodejsFunction -from aws_cdk.aws_logs import LogGroup, RetentionDays -from aws_cdk.aws_sns import ITopic -from cdk_nag import NagSuppressions -from constructs import Construct - - -class NodejsFunction(CdkNodejsFunction): - """Standard NodeJS lambda function""" - - def __init__( - self, - scope: Construct, - construct_id: str, - *, - lambda_dir: str, - log_retention: RetentionDays = RetentionDays.ONE_MONTH, - alarm_topic: ITopic = None, - role: IRole = None, - **kwargs, - ): - defaults = { - 'timeout': Duration.seconds(28), - } - defaults.update(kwargs) - - nodejs_dir = os.path.join('lambdas', 'nodejs') - lambda_dir = os.path.join(nodejs_dir, lambda_dir) - - log_group = LogGroup( - scope, - f'{construct_id}LogGroup', - retention=log_retention, - ) - # We can't directly grant a provided role permission to log to our log group, since that could create a - # circular dependency with the stack the role came from. The role creator will have to be responsible for - # setting its permissions. - if not role: - role = Role( - scope, - f'{construct_id}Role', - assumed_by=ServicePrincipal('lambda.amazonaws.com'), - ) - log_group.grant_write(role) - NagSuppressions.add_resource_suppressions( - log_group, - suppressions=[ - { - 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', - 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' - ' logs to operators with credentials for the AWS account is desired. Encryption is not appropriate' - ' here.', - }, - ], - ) - if log_retention == RetentionDays.INFINITE: - NagSuppressions.add_resource_suppressions( - log_group, - suppressions=[ - { - 'id': 'HIPAA.Security-CloudWatchLogGroupRetentionPeriod', - 'reason': 'We are deliberately retaining logs indefinitely here.', - }, - ], - ) - - super().__init__( - scope, - construct_id, - runtime=Runtime.NODEJS_24_X, - entry=os.path.join(lambda_dir, 'handler.ts'), - deps_lock_file_path=os.path.join(nodejs_dir, 'yarn.lock'), - bundling=BundlingOptions( - format=OutputFormat.CJS, - main_fields=['module', 'main'], - esbuild_args={'--log-limit': '0', '--tree-shaking': 'true'}, - force_docker_bundling=True, - ), - log_group=log_group, - role=role, - **defaults, - ) - if alarm_topic is not None: - self._add_alarms(alarm_topic) - - NagSuppressions.add_resource_suppressions( - self, - suppressions=[ - { - 'id': 'HIPAA.Security-LambdaDLQ', - 'reason': "These lambdas are synchronous and so don't require any DLQ configuration", - }, - { - 'id': 'HIPAA.Security-LambdaInsideVPC', - 'reason': 'We may choose to move our lambdas into private VPC subnets in a future enhancement', - }, - ], - ) - - def _add_alarms(self, alarm_topic: ITopic): - throttle_alarm = Alarm( - self, - 'ThrottleAlarm', - metric=self.metric_throttles(statistic=Stats.SUM), - evaluation_periods=1, - threshold=1, - actions_enabled=True, - alarm_description=f'{self.node.path} lambda throttles detected', - comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - ) - throttle_alarm.add_alarm_action(SnsAction(alarm_topic)) diff --git a/backend/cosmetology-app/common_constructs/python_common_layer_versions.py b/backend/cosmetology-app/common_constructs/python_common_layer_versions.py deleted file mode 100644 index 37cf1b464b..0000000000 --- a/backend/cosmetology-app/common_constructs/python_common_layer_versions.py +++ /dev/null @@ -1,121 +0,0 @@ -import os - -from aws_cdk import RemovalPolicy, Stack -from aws_cdk.aws_lambda import IFunction, ILayerVersion, Runtime -from aws_cdk.aws_lambda_python_alpha import PythonLayerVersion -from aws_cdk.aws_ssm import StringParameter -from constructs import Construct - - -class PythonCommonLayerVersions(Construct): - """ - Constructs and wraps the runtime-specific python common lambda layers to make accessing the correct layer - via the correct referencing strategy more convenient across the app. - """ - - def __init__( - self, - scope: Construct, - construct_id: str, - *, - compatible_runtimes: list[Runtime], - **kwargs, - ) -> None: - super().__init__(scope, construct_id) - - from common_constructs.python_function import PythonFunction - - PythonFunction.register_layer_versions(self) - - self._python_layers = {} - - for runtime in compatible_runtimes: - # Add the common python lambda layer for use in all python lambdas - # NOTE: this is to only be referenced directly in this stack! - # All external references should use the ssm parameter to get the value of the layer arn. - # attempting to reference this layer directly in another stack will cause this stack - # to be stuck in an UPDATE_ROLLBACK_FAILED state which will require DELETION of stacks - # that reference the layer directly. See https://github.com/aws/aws-cdk/issues/1972 - self._python_layers[runtime.name] = PythonLayerVersion( - self, - runtime.name, - entry=os.path.join('lambdas', 'python', 'common'), - # Compatible runtime(s) is a bit misleading - only the first runtime is used for bundling, so any - # other 'compatible' types listed could be broken. We'll just make one layer per runtime we need. - compatible_runtimes=[runtime], - description='A layer for common code shared between python lambdas', - # We retain the layer versions in our environments, to avoid a situation where a consuming stack is - # unable to roll back because old versions are destroyed. This means that over time, these versions - # will accumulate in prod, and given the AWS limit of 75 GB for all layer and lambda code storage - # we will likely need to add a custom resource to track these versions, and clean up versions that are - # older than a certain date. That is out of scope for our current effort, but we're leaving this comment - # here to remind us that this will need to be addressed at a later date. - removal_policy=RemovalPolicy.RETAIN, - **kwargs, - ) - - # We Store the layer ARN in SSM Parameter Store - # since lambda layers can't be shared across stacks - # directly due to the fact that you can't update a CloudFormation - # exported value that is being referenced by a resource in another stack - StringParameter( - self, - f'{runtime.name}LayerArnParameter', - # We link across stacks based on a predictable parameter name - parameter_name=self._get_parameter_name_for_runtime(runtime), - string_value=self._python_layers[runtime.name].layer_version_arn, - ) - - def get_common_layer(self, for_function: IFunction) -> ILayerVersion: - layer_stack = Stack.of(self) - function_stack = Stack.of(for_function) - runtime = for_function.runtime - - # If we're in-stack, we can just return the reference directly - if runtime.name not in self._python_layers.keys(): - raise ValueError(f'No common python layer exists for runtime {runtime.name}') - - # If we're in-stack, return a direct reference to the layer version - if function_stack is layer_stack: - return self._python_layers[runtime.name] - - # This doesn't create a cross-stack reference, but it does help CDK/CloudFormation - # to sequence the stack deploys properly. Without this, CDK may attempt to deploy - # stacks that depend on the parameters in parallel with `layer_stack`, which will fail. - for_function.node.add_dependency(layer_stack) - - return self._get_ilayer_reference(for_function) - - def _get_ilayer_reference(self, for_function: IFunction): - """ - For cross-stack, we need to build an ILayerVersion from the SSM parameter value to avoid a cross-stack - dependency that will break with every rebuild of the lambda layer version - """ - function_stack = Stack.of(for_function) - runtime = for_function.runtime - - # We only want to do this look-up once per stack, so we'll first check if it's already been done for the - # stack before creating a new one - layer_construct_id = self._get_ilayer_construct_id_for_runtime(runtime) - parameter_construct_id = f'{layer_construct_id}Parameter' - common_layer_version: ILayerVersion = function_stack.node.try_find_child(layer_construct_id) - if common_layer_version is not None: - return common_layer_version - - # Fetch the value from SSM parameter - common_python_lambda_layer_parameter = StringParameter.from_string_parameter_name( - function_stack, - parameter_construct_id, - string_parameter_name=self._get_parameter_name_for_runtime(runtime), - ) - return PythonLayerVersion.from_layer_version_arn( - function_stack, layer_construct_id, common_python_lambda_layer_parameter.string_value - ) - - @staticmethod - def _get_ilayer_construct_id_for_runtime(runtime: Runtime): - return f'{runtime.name}CommonPythonLayer' - - @staticmethod - def _get_parameter_name_for_runtime(runtime: Runtime): - return f'/deployment/lambda/layers/{runtime.name}/common-python-layer-arn' diff --git a/backend/cosmetology-app/common_constructs/python_function.py b/backend/cosmetology-app/common_constructs/python_function.py deleted file mode 100644 index 043e3da47a..0000000000 --- a/backend/cosmetology-app/common_constructs/python_function.py +++ /dev/null @@ -1,157 +0,0 @@ -from __future__ import annotations - -import os - -from aws_cdk import Duration -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData -from aws_cdk.aws_cloudwatch_actions import SnsAction -from aws_cdk.aws_iam import IRole, ManagedPolicy, Role, ServicePrincipal -from aws_cdk.aws_lambda import ILayerVersion, Runtime -from aws_cdk.aws_lambda_python_alpha import PythonFunction as CdkPythonFunction -from aws_cdk.aws_logs import ILogGroup, LogGroup, RetentionDays -from aws_cdk.aws_sns import ITopic -from cdk_nag import NagSuppressions -from constructs import Construct - -from common_constructs.python_common_layer_versions import PythonCommonLayerVersions - - -class PythonFunction(CdkPythonFunction): - """ - Standard Python lambda function. - """ - - _common_layer_versions = None - - def __init__( - self, - scope: Construct, - construct_id: str, - *, - lambda_dir: str, - runtime: Runtime = Runtime.PYTHON_3_14, - log_retention: RetentionDays = RetentionDays.INFINITE, - alarm_topic: ITopic = None, - role: IRole = None, - log_group: ILogGroup = None, - **kwargs, - ): - if self._common_layer_versions is None: - raise RuntimeError( - 'The PythonCommonLayerVersions construct must be declared before these lambdas can be built' - ) - - defaults = { - 'timeout': Duration.seconds(28), - } - defaults.update(kwargs) - - if not log_group: - log_group = LogGroup( - scope, - f'{construct_id}LogGroup', - retention=log_retention, - ) - NagSuppressions.add_resource_suppressions( - log_group, - suppressions=[ - { - 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', - 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' - ' logs to operators with credentials for the AWS account is desired. Encryption is not' - ' appropriate here.', - }, - ], - ) - if log_retention == RetentionDays.INFINITE: - NagSuppressions.add_resource_suppressions( - log_group, - suppressions=[ - { - 'id': 'HIPAA.Security-CloudWatchLogGroupRetentionPeriod', - 'reason': 'We are deliberately retaining logs indefinitely here.', - }, - ], - ) - - if not role: - role = Role( - scope, - f'{construct_id}Role', - assumed_by=ServicePrincipal('lambda.amazonaws.com'), - ) - log_group.grant_write(role) - - if 'vpc' in kwargs: - # if the function is being created in a VPC, add the AWSLambdaVPCAccessExecutionRole policy to the role - role.add_managed_policy( - ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaVPCAccessExecutionRole') - ) - NagSuppressions.add_resource_suppressions( - role, - suppressions=[ - { - 'id': 'AwsSolutions-IAM4', - 'appliesTo': [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole' - ], - 'reason': 'Lambdas deployed within a VPC require this policy to access the VPC.', - }, - ], - ) - - # We can't directly grant a provided role permission to log to our log group, since that could create a - # circular dependency with the stack the role came from. The role creator will have to be responsible for - # setting its permissions. - - super().__init__( - scope, - construct_id, - entry=os.path.join('lambdas', 'python', lambda_dir), - runtime=runtime, - log_group=log_group, - role=role, - **defaults, - ) - self.add_layers(self._get_common_layer()) - - if alarm_topic is not None: - self._add_alarms(alarm_topic) - - NagSuppressions.add_resource_suppressions( - self, - suppressions=[ - { - 'id': 'HIPAA.Security-LambdaDLQ', - 'reason': "These lambdas are synchronous and so don't require any DLQ configuration", - }, - { - 'id': 'HIPAA.Security-LambdaInsideVPC', - 'reason': 'We may choose to move our lambdas into private VPC subnets in a future enhancement', - }, - ], - ) - - def _add_alarms(self, alarm_topic: ITopic): - throttle_alarm = Alarm( - self, - 'ThrottleAlarm', - metric=self.metric_throttles(statistic=Stats.SUM), - evaluation_periods=1, - threshold=1, - actions_enabled=True, - alarm_description=f'{self.node.path} lambda throttles detected', - comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - ) - throttle_alarm.add_alarm_action(SnsAction(alarm_topic)) - - def _get_common_layer(self) -> ILayerVersion: - return self._common_layer_versions.get_common_layer(for_function=self) - - @classmethod - def register_layer_versions(cls, versions: PythonCommonLayerVersions): - """ - Register the single PythonCommonLayerVersions object to use for referencing and attaching the common layer - """ - cls._common_layer_versions = versions diff --git a/backend/cosmetology-app/common_constructs/queue_event_listener.py b/backend/cosmetology-app/common_constructs/queue_event_listener.py deleted file mode 100644 index 015f20b7be..0000000000 --- a/backend/cosmetology-app/common_constructs/queue_event_listener.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from aws_cdk import Duration -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Metric, Stats, TreatMissingData -from aws_cdk.aws_cloudwatch_actions import SnsAction -from aws_cdk.aws_events import EventBus, EventPattern, Rule -from aws_cdk.aws_events_targets import SqsQueue -from aws_cdk.aws_kms import IKey -from aws_cdk.aws_lambda import IFunction -from aws_cdk.aws_sns import ITopic -from constructs import Construct - -from common_constructs.queued_lambda_processor import QueuedLambdaProcessor - - -class QueueEventListener(Construct): - """ - This construct defines resources for an event listener that puts events on a queue to be processed by a lambda - function. - - This construct creates: - - A QueuedLambdaProcessor for reliable message processing - - An EventBridge rule to route events to the queue - - CloudWatch alarms for monitoring failures - """ - - default_visibility_timeout = Duration.minutes(5) - default_retention_period = Duration.hours(12) - default_max_batching_window = Duration.seconds(15) - - def __init__( - self, - scope: Construct, - construct_id: str, - *, - data_event_bus: EventBus, - listener_function: IFunction, - listener_detail_type: str, - encryption_key: IKey, - alarm_topic: ITopic, - visibility_timeout: Duration = default_visibility_timeout, - retention_period: Duration = default_retention_period, - max_batching_window: Duration = default_max_batching_window, - max_receive_count: int = 3, - batch_size: int = 10, - dlq_count_alarm_threshold: int = 1, - **kwargs, - ): - super().__init__(scope, construct_id, **kwargs) - - # Add specific error alarm for this handler - self.lambda_failure_alarm = Alarm( - self, - f'{construct_id}FailureAlarm', - metric=listener_function.metric_errors(statistic=Stats.SUM), - evaluation_periods=1, - threshold=1, - actions_enabled=True, - alarm_description=f'{listener_function.node.path} failed to process a message', - comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - ) - - self.lambda_failure_alarm.add_alarm_action(SnsAction(alarm_topic)) - - # Create the QueuedLambdaProcessor - self.queue_processor = QueuedLambdaProcessor( - self, - f'{construct_id}QueueProcessor', - process_function=listener_function, - visibility_timeout=visibility_timeout, - retention_period=retention_period, - max_batching_window=max_batching_window, - max_receive_count=max_receive_count, - batch_size=batch_size, - encryption_key=encryption_key, - alarm_topic=alarm_topic, - dlq_count_alarm_threshold=dlq_count_alarm_threshold, - ) - - # Create rule to route specified detail events to the SQS queue - self.event_rule = Rule( - self, - f'{construct_id}EventRule', - event_bus=data_event_bus, - event_pattern=EventPattern(detail_type=[listener_detail_type]), - targets=[SqsQueue(self.queue_processor.queue, dead_letter_queue=self.queue_processor.dlq)], - ) - - # Create an alarm for rule delivery failures - self.event_bridge_failure_alarm = Alarm( - self, - f'{construct_id}RuleFailedInvocations', - metric=Metric( - namespace='AWS/Events', - metric_name='FailedInvocations', - dimensions_map={ - 'EventBusName': data_event_bus.event_bus_name, - 'RuleName': self.event_rule.rule_name, - }, - period=Duration.minutes(5), - statistic='Sum', - ), - evaluation_periods=1, - threshold=1, - comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - ) - - self.event_bridge_failure_alarm.add_alarm_action(SnsAction(alarm_topic)) diff --git a/backend/cosmetology-app/common_constructs/queued_lambda_processor.py b/backend/cosmetology-app/common_constructs/queued_lambda_processor.py deleted file mode 100644 index 346a3c4fae..0000000000 --- a/backend/cosmetology-app/common_constructs/queued_lambda_processor.py +++ /dev/null @@ -1,152 +0,0 @@ -from aws_cdk import Duration, Stack -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, TreatMissingData -from aws_cdk.aws_cloudwatch_actions import SnsAction -from aws_cdk.aws_iam import Effect, PolicyStatement -from aws_cdk.aws_kms import IKey -from aws_cdk.aws_lambda import IFunction -from aws_cdk.aws_logs import QueryDefinition, QueryString -from aws_cdk.aws_sns import ITopic -from aws_cdk.aws_sqs import DeadLetterQueue, IQueue, Queue, QueueEncryption -from constructs import Construct - - -class QueuedLambdaProcessor(Construct): - """ - Creates a standard SQS queue and Dead Letter Queue configuration for reliable queue-based Lambda message processing - """ - - def __init__( - self, - scope: Construct, - construct_id: str, - *, - process_function: IFunction, - visibility_timeout: Duration, - retention_period: Duration, - max_batching_window: Duration, - max_receive_count: int, - batch_size: int, - encryption_key: IKey, - alarm_topic: ITopic, - dlq_count_alarm_threshold: int = 10, - dlq_retention_period: Duration | None = None, - ): - super().__init__(scope, construct_id) - - self.process_function = process_function - self.dlq = Queue( - self, - 'DLQ', - encryption=QueueEncryption.KMS, - encryption_master_key=encryption_key, - enforce_ssl=True, - retention_period=dlq_retention_period, - ) - - self.queue = Queue( - self, - 'Queue', - encryption=QueueEncryption.KMS, - encryption_master_key=encryption_key, - enforce_ssl=True, - retention_period=retention_period, - visibility_timeout=visibility_timeout, - dead_letter_queue=DeadLetterQueue(max_receive_count=max_receive_count, queue=self.dlq), - ) - - # The following section of code is equivalent to: - # process_function.add_event_source( - # SqsEventSource( - # self.queue, - # batch_size=batch_size, - # max_batching_window=max_batching_window, - # report_batch_item_failures=True, - # ), - # ) - # - # Except that we are granting the lambda permission to consume SQS messages via resource policy - # on the queue, rather than the more conventional approach of principal policy on the IAM role. - # - # We use a lower-level add_event_source_mapping method here so that we can control how those - # permissions are granted. In this case, we need to grant permissions via resource policy on - # the Queue rather than principal policy on the role to avoid creating a dependency from the - # role on the queue. In some cases, adding the dependency on the role can cause a circular - # dependency. - self.event_source_mapping = process_function.add_event_source_mapping( - f'SqsEventSource:{Stack.of(self).stack_name}:{construct_id}', - batch_size=batch_size, - max_batching_window=max_batching_window, - report_batch_item_failures=True, - event_source_arn=self.queue.queue_arn, - ) - self.queue.add_to_resource_policy( - PolicyStatement( - effect=Effect.ALLOW, - principals=[process_function.role], - actions=[ - 'sqs:ReceiveMessage', - 'sqs:ChangeMessageVisibility', - 'sqs:GetQueueUrl', - 'sqs:DeleteMessage', - 'sqs:GetQueueAttributes', - ], - resources=[self.queue.queue_arn], - ) - ) - - self._add_queue_alarms( - retention_period=retention_period, - queue=self.queue, - dlq=self.dlq, - alarm_topic=alarm_topic, - dlq_count_alarm_threshold=dlq_count_alarm_threshold, - ) - - QueryDefinition( - self, - 'RuntimeQuery', - query_definition_name=f'{self.node.id}/Lambdas', - query_string=QueryString( - fields=['@timestamp', '@log', 'level', 'status', 'message', '@message'], - filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], - sort='@timestamp desc', - ), - log_groups=[process_function.log_group], - ) - - def _add_queue_alarms( - self, - retention_period: Duration, - queue: IQueue, - dlq: IQueue, - alarm_topic: ITopic, - dlq_count_alarm_threshold: int = 10, - ): - # Alarm if messages are older than half the queue retention period - message_age_alarm = Alarm( - queue, - 'MessageAgeAlarm', - metric=queue.metric_approximate_age_of_oldest_message(), - evaluation_periods=3, - threshold=retention_period.to_seconds() // 2, - actions_enabled=True, - alarm_description=f'{queue.node.path} messages are getting old', - comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - ) - message_age_alarm.add_alarm_action(SnsAction(alarm_topic)) - - # Alarm if we see more than 10 messages in the dead letter queue - # We expect none, so this would be noteworthy - dlq_size_alarm = Alarm( - dlq, - 'DLQMessagesAlarm', - metric=dlq.metric_approximate_number_of_messages_visible(), - evaluation_periods=1, - threshold=dlq_count_alarm_threshold, - actions_enabled=True, - alarm_description=f'{dlq.node.path} high message volume', - comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, - treat_missing_data=TreatMissingData.NOT_BREACHING, - ) - dlq_size_alarm.add_alarm_action(SnsAction(alarm_topic)) diff --git a/backend/cosmetology-app/common_constructs/ssm_parameter_utility.py b/backend/cosmetology-app/common_constructs/ssm_parameter_utility.py deleted file mode 100644 index 63deb8f3a9..0000000000 --- a/backend/cosmetology-app/common_constructs/ssm_parameter_utility.py +++ /dev/null @@ -1,43 +0,0 @@ -from aws_cdk.aws_events import EventBus -from aws_cdk.aws_ssm import StringParameter -from constructs import Construct - -DATA_EVENT_BUS_ARN_SSM_PARAMETER_NAME = '/deployment/event-bridge/event-bus/data-event-bus-arn' - - -class SSMParameterUtility: - """ - Utility class for SSM parameter operations. - - This class provides static methods for common SSM parameter operations, - such as loading resources from SSM parameters to bypass cross-stack references. - """ - - @staticmethod - def set_data_event_bus_arn_ssm_parameter(scope: Construct, data_event_bus: EventBus) -> StringParameter: - return StringParameter( - scope, - 'DataEventBusArnParameter', - parameter_name=DATA_EVENT_BUS_ARN_SSM_PARAMETER_NAME, - string_value=data_event_bus.event_bus_arn, - ) - - @staticmethod - def load_data_event_bus_from_ssm_parameter(scope: Construct) -> EventBus: - """ - Load the data event bus from an SSM parameter. - - This pattern breaks cross-stack references by storing and retrieving - the event bus ARN in SSM Parameter Store rather than using a direct reference, - which helps avoid issues with CloudFormation stack updates. - - :param scope: The CDK construct scope - :return: The EventBus construct - """ - data_event_bus_arn = StringParameter.from_string_parameter_name( - scope, - 'DataEventBusArnParameter', - string_parameter_name=DATA_EVENT_BUS_ARN_SSM_PARAMETER_NAME, - ) - - return EventBus.from_event_bus_arn(scope, 'DataEventBus', event_bus_arn=data_event_bus_arn.string_value) diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/__init__.py b/backend/cosmetology-app/stacks/api_lambda_stack/__init__.py index 2fb52af30e..25ae492eea 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/__init__.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/__init__.py @@ -1,10 +1,10 @@ from __future__ import annotations from aws_cdk.aws_logs import QueryDefinition, QueryString +from common_constructs.ssm_parameter_utility import SSMParameterUtility from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.ssm_parameter_utility import SSMParameterUtility from stacks import persistent_stack as ps from .bulk_upload_url import BulkUploadUrlLambdas diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/bulk_upload_url.py b/backend/cosmetology-app/stacks/api_lambda_stack/bulk_upload_url.py index 1c36db39b9..b5091bf5f2 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/bulk_upload_url.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/bulk_upload_url.py @@ -6,9 +6,9 @@ from aws_cdk.aws_iam import IRole from aws_cdk.aws_s3 import IBucket from aws_cdk.aws_sns import ITopic +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/compact_configuration_api.py b/backend/cosmetology-app/stacks/api_lambda_stack/compact_configuration_api.py index 86ec808333..a054e3df3e 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/compact_configuration_api.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/compact_configuration_api.py @@ -7,9 +7,9 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/feature_flags.py b/backend/cosmetology-app/stacks/api_lambda_stack/feature_flags.py index ddb1dddcbe..e7f2bfcb37 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/feature_flags.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/feature_flags.py @@ -4,9 +4,9 @@ from aws_cdk.aws_secretsmanager import Secret from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/post_licenses.py b/backend/cosmetology-app/stacks/api_lambda_stack/post_licenses.py index 452bcee687..539694a63a 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/post_licenses.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/post_licenses.py @@ -7,9 +7,9 @@ from aws_cdk.aws_iam import IRole from aws_cdk.aws_sns import ITopic from aws_cdk.aws_sqs import IQueue +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py b/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py index d2e0fad740..a6a101db03 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/provider_management.py @@ -6,10 +6,10 @@ from aws_cdk.aws_lambda import Code, Function, Runtime from aws_cdk.aws_logs import RetentionDays from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/public_lookup_api.py b/backend/cosmetology-app/stacks/api_lambda_stack/public_lookup_api.py index 8aba981601..4dd0923389 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/public_lookup_api.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/public_lookup_api.py @@ -7,9 +7,9 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/api_lambda_stack/staff_users.py b/backend/cosmetology-app/stacks/api_lambda_stack/staff_users.py index 6cc4f061fb..609390c7f4 100644 --- a/backend/cosmetology-app/stacks/api_lambda_stack/staff_users.py +++ b/backend/cosmetology-app/stacks/api_lambda_stack/staff_users.py @@ -16,10 +16,10 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions -from constructs import Construct - from common_constructs.python_function import PythonFunction from common_constructs.user_pool import UserPool +from constructs import Construct + from stacks import api_lambda_stack as als from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/api_stack/v1_api/compact_configuration_api.py b/backend/cosmetology-app/stacks/api_stack/v1_api/compact_configuration_api.py index a0c46e7b3a..8126988036 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/compact_configuration_api.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/compact_configuration_api.py @@ -2,9 +2,9 @@ from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks.api_lambda_stack import ApiLambdaStack from .api_model import ApiModel diff --git a/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py b/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py index 073d37b85a..776b5fc32f 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/provider_management.py @@ -2,9 +2,9 @@ from aws_cdk import Duration from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource +from common_constructs.python_function import PythonFunction from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks.api_lambda_stack import ApiLambdaStack from .api_model import ApiModel diff --git a/backend/cosmetology-app/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py b/backend/cosmetology-app/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py index b8e78a96d4..2c1b6c2a84 100644 --- a/backend/cosmetology-app/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py +++ b/backend/cosmetology-app/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py @@ -19,11 +19,11 @@ ) from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.ssm_parameter_utility import SSMParameterUtility from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction -from common_constructs.ssm_parameter_utility import SSMParameterUtility from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/disaster_recovery_stack/sync_table_step_function.py b/backend/cosmetology-app/stacks/disaster_recovery_stack/sync_table_step_function.py index 91c6c89df9..f22301d0c5 100644 --- a/backend/cosmetology-app/stacks/disaster_recovery_stack/sync_table_step_function.py +++ b/backend/cosmetology-app/stacks/disaster_recovery_stack/sync_table_step_function.py @@ -19,10 +19,10 @@ ) from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.persistent_stack.ssn_table import SSN_SYNC_STATE_MACHINE_NAME diff --git a/backend/cosmetology-app/stacks/feature_flag_stack/__init__.py b/backend/cosmetology-app/stacks/feature_flag_stack/__init__.py index 0a13f2b680..dc5fb6ba1f 100644 --- a/backend/cosmetology-app/stacks/feature_flag_stack/__init__.py +++ b/backend/cosmetology-app/stacks/feature_flag_stack/__init__.py @@ -74,10 +74,10 @@ from aws_cdk.aws_secretsmanager import Secret from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.feature_flag_stack.feature_flag_resource import FeatureFlagEnvironmentName, FeatureFlagResource diff --git a/backend/cosmetology-app/stacks/ingest_stack.py b/backend/cosmetology-app/stacks/ingest_stack.py index cb8e0c4efa..5391597b19 100644 --- a/backend/cosmetology-app/stacks/ingest_stack.py +++ b/backend/cosmetology-app/stacks/ingest_stack.py @@ -8,12 +8,12 @@ from aws_cdk.aws_events import EventBus, EventPattern, Rule from aws_cdk.aws_events_targets import SqsQueue from cdk_nag import NagSuppressions -from common_constructs.stack import AppStack, Stack -from constructs import Construct - from common_constructs.python_function import PythonFunction from common_constructs.queued_lambda_processor import QueuedLambdaProcessor from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack, Stack +from constructs import Construct + from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/notification_stack.py b/backend/cosmetology-app/stacks/notification_stack.py index 7a8b7fdab1..bcebca0888 100644 --- a/backend/cosmetology-app/stacks/notification_stack.py +++ b/backend/cosmetology-app/stacks/notification_stack.py @@ -5,12 +5,12 @@ from aws_cdk import Duration from aws_cdk.aws_events import EventBus from cdk_nag import NagSuppressions -from common_constructs.stack import AppStack -from constructs import Construct - from common_constructs.python_function import PythonFunction from common_constructs.queue_event_listener import QueueEventListener from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack +from constructs import Construct + from stacks import event_state_stack as ess from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/persistent_stack/__init__.py b/backend/cosmetology-app/stacks/persistent_stack/__init__.py index f1bba166c9..27b5f85b7c 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/__init__.py +++ b/backend/cosmetology-app/stacks/persistent_stack/__init__.py @@ -14,13 +14,13 @@ AppId, PersistentStackFrontendAppConfigUtility, ) +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.python_common_layer_versions import PythonCommonLayerVersions from common_constructs.security_profile import SecurityProfile +from common_constructs.ssm_parameter_utility import SSMParameterUtility from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.nodejs_function import NodejsFunction -from common_constructs.python_common_layer_versions import PythonCommonLayerVersions -from common_constructs.ssm_parameter_utility import SSMParameterUtility from stacks.backup_infrastructure_stack import BackupInfrastructureStack from stacks.persistent_stack.bulk_uploads_bucket import BulkUploadsBucket from stacks.persistent_stack.compact_configuration_table import CompactConfigurationTable diff --git a/backend/cosmetology-app/stacks/persistent_stack/bulk_uploads_bucket.py b/backend/cosmetology-app/stacks/persistent_stack/bulk_uploads_bucket.py index eb5e3ba12d..76f8155e41 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/bulk_uploads_bucket.py +++ b/backend/cosmetology-app/stacks/persistent_stack/bulk_uploads_bucket.py @@ -15,11 +15,11 @@ from cdk_nag import NagSuppressions from common_constructs.access_logs_bucket import AccessLogsBucket from common_constructs.bucket import Bucket +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct import stacks.persistent_stack as ps -from common_constructs.python_function import PythonFunction class BulkUploadsBucket(Bucket): diff --git a/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_table.py b/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_table.py index e1017f276e..3a261d83b8 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_table.py +++ b/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_table.py @@ -10,9 +10,9 @@ ) from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_upload.py b/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_upload.py index 1de1e02edf..57e29792ed 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_upload.py +++ b/backend/cosmetology-app/stacks/persistent_stack/compact_configuration_upload.py @@ -6,11 +6,10 @@ from aws_cdk.aws_logs import LogGroup, RetentionDays from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction - from .compact_configuration_table import CompactConfigurationTable diff --git a/backend/cosmetology-app/stacks/persistent_stack/data_event_table.py b/backend/cosmetology-app/stacks/persistent_stack/data_event_table.py index ee4876481f..2aa07f659d 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/data_event_table.py +++ b/backend/cosmetology-app/stacks/persistent_stack/data_event_table.py @@ -19,11 +19,11 @@ from aws_cdk.aws_kms import IKey from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions -from constructs import Construct - from common_constructs.backup_plan import CCBackupPlan from common_constructs.python_function import PythonFunction from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from constructs import Construct + from stacks import persistent_stack as ps from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/cosmetology-app/stacks/persistent_stack/provider_table.py b/backend/cosmetology-app/stacks/persistent_stack/provider_table.py index 0112c88fbb..a2f94d1e85 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/provider_table.py +++ b/backend/cosmetology-app/stacks/persistent_stack/provider_table.py @@ -12,9 +12,9 @@ ) from aws_cdk.aws_kms import Key from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py b/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py index 19beaff301..ee8742022a 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py +++ b/backend/cosmetology-app/stacks/persistent_stack/ssn_table.py @@ -24,12 +24,12 @@ from aws_cdk.aws_kms import Key from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions -from common_constructs.stack import Stack -from constructs import Construct - from common_constructs.backup_plan import CCBackupPlan from common_constructs.python_function import PythonFunction from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from common_constructs.stack import Stack +from constructs import Construct + from stacks.backup_infrastructure_stack import BackupInfrastructureStack # Name for SSN disaster recovery sync table state machine for specific permissions diff --git a/backend/cosmetology-app/stacks/persistent_stack/staff_users.py b/backend/cosmetology-app/stacks/persistent_stack/staff_users.py index 4f42df80e2..5cc75508ac 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/staff_users.py +++ b/backend/cosmetology-app/stacks/persistent_stack/staff_users.py @@ -14,13 +14,13 @@ ) from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.python_function import PythonFunction +from common_constructs.user_pool import UserPool from constructs import Construct from common_constructs.cognito_user_backup import CognitoUserBackup -from common_constructs.nodejs_function import NodejsFunction -from common_constructs.python_function import PythonFunction from common_constructs.resource_scope_mixin import ResourceScopeMixin -from common_constructs.user_pool import UserPool from stacks import persistent_stack as ps from stacks.backup_infrastructure_stack import BackupInfrastructureStack from stacks.persistent_stack.users_table import UsersTable diff --git a/backend/cosmetology-app/stacks/persistent_stack/user_email_notifications.py b/backend/cosmetology-app/stacks/persistent_stack/user_email_notifications.py index cf706f27c6..edf490cd66 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/user_email_notifications.py +++ b/backend/cosmetology-app/stacks/persistent_stack/user_email_notifications.py @@ -9,11 +9,10 @@ from aws_cdk.aws_sns import Subscription, SubscriptionProtocol, Topic from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction - class UserEmailNotifications(Construct): """This Construct leverages SES to set up an email notification system to send cognito user events from our custom diff --git a/backend/cosmetology-app/stacks/persistent_stack/users_table.py b/backend/cosmetology-app/stacks/persistent_stack/users_table.py index 7629a74de8..020c6b7513 100644 --- a/backend/cosmetology-app/stacks/persistent_stack/users_table.py +++ b/backend/cosmetology-app/stacks/persistent_stack/users_table.py @@ -11,9 +11,9 @@ ) from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan from constructs import Construct -from common_constructs.backup_plan import CCBackupPlan from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/cosmetology-app/stacks/reporting_stack.py b/backend/cosmetology-app/stacks/reporting_stack.py index 9b14f54817..5979c7142e 100644 --- a/backend/cosmetology-app/stacks/reporting_stack.py +++ b/backend/cosmetology-app/stacks/reporting_stack.py @@ -7,10 +7,10 @@ from aws_cdk.aws_events_targets import LambdaFunction from aws_cdk.aws_logs import QueryDefinition, QueryString from cdk_nag import NagSuppressions +from common_constructs.nodejs_function import NodejsFunction from common_constructs.stack import AppStack from constructs import Construct -from common_constructs.nodejs_function import NodejsFunction from stacks import persistent_stack as ps diff --git a/backend/cosmetology-app/stacks/search_persistent_stack/index_manager.py b/backend/cosmetology-app/stacks/search_persistent_stack/index_manager.py index e8e8108e58..21c8e1512b 100644 --- a/backend/cosmetology-app/stacks/search_persistent_stack/index_manager.py +++ b/backend/cosmetology-app/stacks/search_persistent_stack/index_manager.py @@ -7,11 +7,11 @@ from aws_cdk.aws_opensearchservice import Domain from aws_cdk.custom_resources import Provider from cdk_nag import NagSuppressions +from common_constructs.constants import PROD_ENV_NAME +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.constants import PROD_ENV_NAME -from common_constructs.python_function import PythonFunction from stacks.vpc_stack import VpcStack # Index configuration constants diff --git a/backend/cosmetology-app/stacks/search_persistent_stack/populate_provider_documents_handler.py b/backend/cosmetology-app/stacks/search_persistent_stack/populate_provider_documents_handler.py index 8f7989a275..cdcbc61921 100644 --- a/backend/cosmetology-app/stacks/search_persistent_stack/populate_provider_documents_handler.py +++ b/backend/cosmetology-app/stacks/search_persistent_stack/populate_provider_documents_handler.py @@ -8,10 +8,10 @@ from aws_cdk.aws_opensearchservice import Domain from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.vpc_stack import VpcStack diff --git a/backend/cosmetology-app/stacks/search_persistent_stack/provider_search_domain.py b/backend/cosmetology-app/stacks/search_persistent_stack/provider_search_domain.py index 785c407b31..7de6832b1a 100644 --- a/backend/cosmetology-app/stacks/search_persistent_stack/provider_search_domain.py +++ b/backend/cosmetology-app/stacks/search_persistent_stack/provider_search_domain.py @@ -18,10 +18,10 @@ ) from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.constants import PROD_ENV_NAME from common_constructs.stack import Stack from constructs import Construct -from common_constructs.constants import PROD_ENV_NAME from stacks.vpc_stack import PRIVATE_SUBNET_ONE_NAME, VpcStack PROD_EBS_VOLUME_SIZE = 25 diff --git a/backend/cosmetology-app/stacks/search_persistent_stack/provider_update_ingest_handler.py b/backend/cosmetology-app/stacks/search_persistent_stack/provider_update_ingest_handler.py index f5f7909760..3a52c5d263 100644 --- a/backend/cosmetology-app/stacks/search_persistent_stack/provider_update_ingest_handler.py +++ b/backend/cosmetology-app/stacks/search_persistent_stack/provider_update_ingest_handler.py @@ -10,11 +10,11 @@ from aws_cdk.aws_opensearchservice import Domain from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.queued_lambda_processor import QueuedLambdaProcessor from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction -from common_constructs.queued_lambda_processor import QueuedLambdaProcessor from stacks.persistent_stack import CompactConfigurationTable, ProviderTable from stacks.vpc_stack import VpcStack diff --git a/backend/cosmetology-app/stacks/search_persistent_stack/search_handler.py b/backend/cosmetology-app/stacks/search_persistent_stack/search_handler.py index 5c8ac35389..d13473118e 100644 --- a/backend/cosmetology-app/stacks/search_persistent_stack/search_handler.py +++ b/backend/cosmetology-app/stacks/search_persistent_stack/search_handler.py @@ -10,10 +10,10 @@ from aws_cdk.aws_s3 import IBucket from aws_cdk.aws_sns import ITopic from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from constructs import Construct -from common_constructs.python_function import PythonFunction from stacks.vpc_stack import VpcStack diff --git a/backend/cosmetology-app/stacks/state_api_stack/v1_api/bulk_upload_url.py b/backend/cosmetology-app/stacks/state_api_stack/v1_api/bulk_upload_url.py index 8499d2c8af..953095f938 100644 --- a/backend/cosmetology-app/stacks/state_api_stack/v1_api/bulk_upload_url.py +++ b/backend/cosmetology-app/stacks/state_api_stack/v1_api/bulk_upload_url.py @@ -5,10 +5,10 @@ from aws_cdk import Duration from aws_cdk.aws_apigateway import AuthorizationType, LambdaIntegration, MethodOptions, MethodResponse, Resource from aws_cdk.aws_iam import IRole +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps from .api_model import ApiModel diff --git a/backend/cosmetology-app/stacks/state_api_stack/v1_api/post_licenses.py b/backend/cosmetology-app/stacks/state_api_stack/v1_api/post_licenses.py index 8e9bf08404..4e1005d8ca 100644 --- a/backend/cosmetology-app/stacks/state_api_stack/v1_api/post_licenses.py +++ b/backend/cosmetology-app/stacks/state_api_stack/v1_api/post_licenses.py @@ -5,10 +5,10 @@ from aws_cdk import Duration from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource from aws_cdk.aws_iam import IRole +from common_constructs.python_function import PythonFunction from common_constructs.stack import Stack from common_constructs.cc_api import CCApi -from common_constructs.python_function import PythonFunction from stacks import persistent_stack as ps from .api_model import ApiModel diff --git a/backend/cosmetology-app/tests/app/base.py b/backend/cosmetology-app/tests/app/base.py index cb8d821c14..ab2dc81771 100644 --- a/backend/cosmetology-app/tests/app/base.py +++ b/backend/cosmetology-app/tests/app/base.py @@ -13,10 +13,10 @@ from aws_cdk.aws_kms import CfnKey from aws_cdk.aws_lambda import CfnEventSourceMapping from aws_cdk.aws_sqs import CfnQueue +from common_constructs.backup_plan import CCBackupPlan from common_constructs.stack import Stack from app import CompactConnectApp -from common_constructs.backup_plan import CCBackupPlan from pipeline import BackendStage from stacks.api_stack import ApiStack from stacks.persistent_stack import PersistentStack diff --git a/backend/cosmetology-app/tests/common_constructs/test_cognito_user_backup.py b/backend/cosmetology-app/tests/common_constructs/test_cognito_user_backup.py index 103b902547..b463d45307 100644 --- a/backend/cosmetology-app/tests/common_constructs/test_cognito_user_backup.py +++ b/backend/cosmetology-app/tests/common_constructs/test_cognito_user_backup.py @@ -19,10 +19,10 @@ from aws_cdk.aws_s3 import CfnBucket from aws_cdk.aws_sns import Topic from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.python_common_layer_versions import PythonCommonLayerVersions from common_constructs.stack import AppStack, StandardTags from common_constructs.cognito_user_backup import CognitoUserBackup -from common_constructs.python_common_layer_versions import PythonCommonLayerVersions from stacks.backup_infrastructure_stack import BackupInfrastructureStack diff --git a/backend/cosmetology-app/tests/common_constructs/test_data_migration.py b/backend/cosmetology-app/tests/common_constructs/test_data_migration.py deleted file mode 100644 index 9b86270366..0000000000 --- a/backend/cosmetology-app/tests/common_constructs/test_data_migration.py +++ /dev/null @@ -1,93 +0,0 @@ -from unittest import TestCase - -from aws_cdk import App, Stack -from aws_cdk.assertions import Template -from aws_cdk.aws_iam import Role, ServicePrincipal -from aws_cdk.aws_lambda import CfnFunction, Runtime - -# Use the dummy migration directory for testing -MIGRATION_DIR = 'dummy_migration' - - -class TestDataMigration(TestCase): - def test_data_migration_synthesizes(self): - from common_constructs.stack import AppStack, StandardTags - - from common_constructs.data_migration import DataMigration - from common_constructs.python_common_layer_versions import PythonCommonLayerVersions - - app = App() - # The persistent stack and layer are required for DataMigration, as an internal lambda depends on it. - # Use a non-pipeline environment name so domain_name is not required (avoids HostedZone.from_lookup in tests). - common_stack = AppStack( - app, - 'CommonStack', - environment_context={}, - environment_name='sandbox', - standard_tags=StandardTags(project='compact-connect', service='compact-connect', environment='test'), - ) - # Create common lambda layers - PythonCommonLayerVersions( - common_stack, - 'CommonLayers', - compatible_runtimes=[Runtime.PYTHON_3_14], - ) - - stack = Stack(app, 'Stack') - - # Create a role for the migration function - role = Role(stack, 'MigrationRole', assumed_by=ServicePrincipal('lambda.amazonaws.com')) - - # Create environment variables for the lambda - lambda_environment = {'ENV_VAR1': 'value1', 'ENV_VAR2': 'value2'} - - # Create custom resource properties - custom_resource_properties = {'TestProperty': 'test-value', 'AnotherProperty': 123} - - # Create the DataMigration construct - data_migration = DataMigration( - stack, - 'TestMigration', - migration_dir=MIGRATION_DIR, - lambda_environment=lambda_environment, - role=role, - custom_resource_properties=custom_resource_properties, - ) - - # Generate the CloudFormation template - template = Template.from_stack(stack) - - # Test that the migration function is created with the correct properties - template.has_resource( - CfnFunction.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'Handler': 'dummy_migration.main.on_event', - 'Timeout': 900, # 15 minutes in seconds - 'Environment': {'Variables': lambda_environment}, - 'Role': {'Fn::GetAtt': [stack.get_logical_id(role.node.default_child), 'Arn']}, - } - }, - ) - - # Test that the custom resource is created with the correct properties - template.has_resource( - 'Custom::DataMigration', - props={ - 'Properties': { - 'ServiceToken': { - 'Fn::GetAtt': [ - stack.get_logical_id( - data_migration.provider.node.find_child('framework-onEvent').node.default_child - ), - 'Arn', - ] - }, - 'TestProperty': 'test-value', - 'AnotherProperty': 123, - } - }, - ) - - # Test that the grant_principal property returns the migration function's grant_principal - self.assertEqual(data_migration.grant_principal, data_migration.migration_function.grant_principal) diff --git a/backend/cosmetology-app/tests/common_constructs/test_queue_event_listener.py b/backend/cosmetology-app/tests/common_constructs/test_queue_event_listener.py deleted file mode 100644 index ae2fc0d6e0..0000000000 --- a/backend/cosmetology-app/tests/common_constructs/test_queue_event_listener.py +++ /dev/null @@ -1,290 +0,0 @@ -from unittest import TestCase - -from aws_cdk import App, Duration, Stack -from aws_cdk.assertions import Template -from aws_cdk.aws_cloudwatch import CfnAlarm -from aws_cdk.aws_events import CfnRule, EventBus -from aws_cdk.aws_kms import Key -from aws_cdk.aws_lambda import CfnEventSourceMapping, Code, Function, Runtime -from aws_cdk.aws_sns import Topic -from aws_cdk.aws_sqs import CfnQueue - -from common_constructs.queue_event_listener import QueueEventListener - - -class TestQueueEventListener(TestCase): - def setUp(self): - self.app = App() - self.stack = Stack(self.app, 'TestStack') - - # Create test dependencies - self.key = Key(self.stack, 'TestKey') - self.topic = Topic(self.stack, 'TestTopic') - self.event_bus = EventBus(self.stack, 'TestEventBus') - self.function = Function( - self.stack, - 'TestFunction', - handler='handle', - runtime=Runtime.PYTHON_3_14, - code=Code.from_inline("""def handle(*args): return"""), - ) - - def test_creates_queue_event_listener_with_default_parameters(self): - """Test that QueueEventListener creates all required resources with default parameters.""" - listener = QueueEventListener( - self.stack, - 'TestListener', - data_event_bus=self.event_bus, - listener_function=self.function, - listener_detail_type='test.event', - encryption_key=self.key, - alarm_topic=self.topic, - ) - - template = Template.from_stack(self.stack) - - # Verify the lambda function failure alarm is created - template.has_resource( - CfnAlarm.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'AlarmDescription': f'{self.function.node.path} failed to process a message', - 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', - 'EvaluationPeriods': 1, - 'Threshold': 1, - 'TreatMissingData': 'notBreaching', - } - }, - ) - - # Verify the QueuedLambdaProcessor components are created (SQS queues) - queues = template.find_resources( - CfnQueue.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'KmsMasterKeyId': {'Fn::GetAtt': [self.stack.get_logical_id(self.key.node.default_child), 'Arn']} - } - }, - ) - # Should have 2 queues: main queue and DLQ - self.assertEqual(2, len(queues)) - - # Verify the main queue has correct configuration - template.has_resource( - CfnQueue.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'MessageRetentionPeriod': 12 * 3600, # 12 hours (default) - 'RedrivePolicy': { - 'deadLetterTargetArn': { - 'Fn::GetAtt': [ - self.stack.get_logical_id(listener.queue_processor.dlq.node.default_child), - 'Arn', - ] - }, - 'maxReceiveCount': 3, # default - }, - 'VisibilityTimeout': 5 * 60, # 5 minutes (default) - } - }, - ) - - # Verify EventBridge rule is created - template.has_resource( - CfnRule.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'EventBusName': {'Ref': self.stack.get_logical_id(self.event_bus.node.default_child)}, - 'EventPattern': {'detail-type': ['test.event']}, - 'State': 'ENABLED', - 'Targets': [ - { - 'Arn': { - 'Fn::GetAtt': [ - self.stack.get_logical_id(listener.queue_processor.queue.node.default_child), - 'Arn', - ] - }, - 'DeadLetterConfig': { - 'Arn': { - 'Fn::GetAtt': [ - self.stack.get_logical_id(listener.queue_processor.dlq.node.default_child), - 'Arn', - ] - } - }, - 'Id': 'Target0', - } - ], - } - }, - ) - - # Verify EventBridge rule failure alarm is created - template.has_resource( - CfnAlarm.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'MetricName': 'FailedInvocations', - 'Namespace': 'AWS/Events', - 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', - 'EvaluationPeriods': 1, - 'Threshold': 1, - 'TreatMissingData': 'notBreaching', - } - }, - ) - - # Verify event source mapping is created - template.has_resource( - CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'BatchSize': 10, # default - 'EventSourceArn': { - 'Fn::GetAtt': [ - self.stack.get_logical_id(listener.queue_processor.queue.node.default_child), - 'Arn', - ] - }, - 'FunctionName': {'Ref': self.stack.get_logical_id(self.function.node.default_child)}, - 'FunctionResponseTypes': ['ReportBatchItemFailures'], - 'MaximumBatchingWindowInSeconds': 15, # default - } - }, - ) - - def test_creates_queue_event_listener_with_custom_parameters(self): - """Test that QueueEventListener respects custom parameters.""" - listener = QueueEventListener( - self.stack, - 'CustomListener', - data_event_bus=self.event_bus, - listener_function=self.function, - listener_detail_type='custom.event', - encryption_key=self.key, - alarm_topic=self.topic, - visibility_timeout=Duration.minutes(10), - retention_period=Duration.hours(24), - max_batching_window=Duration.seconds(30), - max_receive_count=5, - batch_size=20, - dlq_count_alarm_threshold=5, - ) - - template = Template.from_stack(self.stack) - - # Verify the main queue has custom configuration - template.has_resource( - CfnQueue.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'MessageRetentionPeriod': 24 * 3600, # 24 hours (custom) - 'RedrivePolicy': { - 'deadLetterTargetArn': { - 'Fn::GetAtt': [ - self.stack.get_logical_id(listener.queue_processor.dlq.node.default_child), - 'Arn', - ] - }, - 'maxReceiveCount': 5, # custom - }, - 'VisibilityTimeout': 10 * 60, # 10 minutes (custom) - } - }, - ) - - # Verify event source mapping has custom configuration - template.has_resource( - CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'BatchSize': 20, # custom - 'EventSourceArn': { - 'Fn::GetAtt': [ - self.stack.get_logical_id(listener.queue_processor.queue.node.default_child), - 'Arn', - ] - }, - 'FunctionName': {'Ref': self.stack.get_logical_id(self.function.node.default_child)}, - 'FunctionResponseTypes': ['ReportBatchItemFailures'], - 'MaximumBatchingWindowInSeconds': 30, # custom - } - }, - ) - - # Verify EventBridge rule with custom detail type - template.has_resource( - CfnRule.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'EventBusName': {'Ref': self.stack.get_logical_id(self.event_bus.node.default_child)}, - 'EventPattern': {'detail-type': ['custom.event']}, - 'State': 'ENABLED', - } - }, - ) - - def test_exposes_expected_attributes(self): - """Test that QueueEventListener exposes the expected public attributes.""" - listener = QueueEventListener( - self.stack, - 'AttributeTestListener', - data_event_bus=self.event_bus, - listener_function=self.function, - listener_detail_type='attribute.test', - encryption_key=self.key, - alarm_topic=self.topic, - ) - - # Verify all expected attributes are accessible - self.assertIsNotNone(listener.lambda_failure_alarm) - self.assertIsNotNone(listener.queue_processor) - self.assertIsNotNone(listener.event_rule) - self.assertIsNotNone(listener.event_bridge_failure_alarm) - - # Verify that queue_processor exposes expected attributes - self.assertIsNotNone(listener.queue_processor.queue) - self.assertIsNotNone(listener.queue_processor.dlq) - self.assertIsNotNone(listener.queue_processor.process_function) - self.assertIsNotNone(listener.queue_processor.event_source_mapping) - - def test_alarms_count(self): - """Test that the correct number of alarms are created.""" - QueueEventListener( - self.stack, - 'AlarmTestListener', - data_event_bus=self.event_bus, - listener_function=self.function, - listener_detail_type='alarm.test', - encryption_key=self.key, - alarm_topic=self.topic, - ) - - template = Template.from_stack(self.stack) - - # Should create 4 alarms total: - # 1. Lambda failure alarm (from QueueEventListener) - # 2. EventBridge rule failure alarm (from QueueEventListener) - # 3. Queue message age alarm (from QueuedLambdaProcessor) - # 4. DLQ message count alarm (from QueuedLambdaProcessor) - alarms = template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) - self.assertEqual(4, len(alarms)) - - def test_construct_id_propagation(self): - """Test that construct_id is properly propagated to child constructs.""" - listener = QueueEventListener( - self.stack, - 'PropagationTest', - data_event_bus=self.event_bus, - listener_function=self.function, - listener_detail_type='propagation.test', - encryption_key=self.key, - alarm_topic=self.topic, - ) - - # Check that the construct IDs are properly formed - self.assertTrue(listener.lambda_failure_alarm.node.id.startswith('PropagationTest')) - self.assertTrue(listener.queue_processor.node.id.startswith('PropagationTest')) - self.assertTrue(listener.event_rule.node.id.startswith('PropagationTest')) - self.assertTrue(listener.event_bridge_failure_alarm.node.id.startswith('PropagationTest')) diff --git a/backend/cosmetology-app/tests/common_constructs/test_queued_lambda_processor.py b/backend/cosmetology-app/tests/common_constructs/test_queued_lambda_processor.py deleted file mode 100644 index 40ad201110..0000000000 --- a/backend/cosmetology-app/tests/common_constructs/test_queued_lambda_processor.py +++ /dev/null @@ -1,77 +0,0 @@ -from unittest import TestCase - -from aws_cdk import App, Duration, Stack -from aws_cdk.assertions import Template -from aws_cdk.aws_kms import Key -from aws_cdk.aws_lambda import CfnEventSourceMapping, Code, Function, Runtime -from aws_cdk.aws_sns import Topic -from aws_cdk.aws_sqs import CfnQueue - -from common_constructs.queued_lambda_processor import QueuedLambdaProcessor - - -class TestQueuedLambdaProcessor(TestCase): - def test_creates_queues_and_event_source(self): - app = App() - stack = Stack(app, 'Stack') - - key = Key(stack, 'Key') - topic = Topic(stack, 'Topic') - function = Function( - stack, - 'Function', - handler='handle', - runtime=Runtime.PYTHON_3_14, - code=Code.from_inline("""def handle(*args): return"""), - ) - processor = QueuedLambdaProcessor( - stack, - 'Processor', - process_function=function, - visibility_timeout=Duration.minutes(5), - retention_period=Duration.hours(12), - max_batching_window=Duration.minutes(4), - max_receive_count=3, - batch_size=6, - encryption_key=key, - alarm_topic=topic, - ) - - template = Template.from_stack(stack) - queues = template.find_resources( - CfnQueue.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': {'KmsMasterKeyId': {'Fn::GetAtt': [stack.get_logical_id(key.node.default_child), 'Arn']}} - }, - ) - # The DLQ and Queue should both be encrypted with the provided key - self.assertEqual(2, len(queues)) - - template.has_resource( - CfnQueue.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'MessageRetentionPeriod': 12 * 3600, - 'RedrivePolicy': { - 'deadLetterTargetArn': { - 'Fn::GetAtt': [stack.get_logical_id(processor.dlq.node.default_child), 'Arn'] - }, - 'maxReceiveCount': 3, - }, - 'VisibilityTimeout': 5 * 60, - } - }, - ) - - template.has_resource( - CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME, - props={ - 'Properties': { - 'BatchSize': 6, - 'EventSourceArn': {'Fn::GetAtt': [stack.get_logical_id(processor.queue.node.default_child), 'Arn']}, - 'FunctionName': {'Ref': stack.get_logical_id(function.node.default_child)}, - 'FunctionResponseTypes': ['ReportBatchItemFailures'], - 'MaximumBatchingWindowInSeconds': 4 * 60, - } - }, - ) diff --git a/backend/multi-account/README.md b/backend/multi-account/README.md index 7c9aae2557..93281bb99b 100644 --- a/backend/multi-account/README.md +++ b/backend/multi-account/README.md @@ -70,12 +70,15 @@ new AWS organization that we will set up here. Have them: ### Provision workflow accounts - Log into the AWS Management account console via your IAM Identity Center user - Go to the ControlTower service, Organization view -- Create a new OU structure as follows: +- Create a new OU structure as follows. Each compact gets its own OU under `Workflows`, with its own + `PreProd` and `Prod` sub-OUs inside it. Name the compact-level OU after the compact (e.g. `CompactConnect` + for the initial JCC compact): ```text └── Workflows ├── Deployment - ├── PreProd - └── Prod + └── CompactConnect + ├── PreProd + └── Prod ``` - Go to the ControlTower service, Account factory view - Create six new AWS accounts for the OUs in the following structure, with the following details. Use the @@ -85,21 +88,22 @@ new AWS organization that we will set up here. Have them: └── Workflows ├── Deployment │ └── Deploy - ├── PreProd - │ └── Test - │ └── Test Secondary (Backups and Disaster Recovery) - │ └── Beta - └── Prod - └── Production - └── Production Secondary (Backups and Disaster Recovery) + └── CompactConnect + ├── PreProd + │ ├── Test + │ ├── Test Secondary (Backups and Disaster Recovery) + │ └── Beta + └── Prod + ├── Production + └── Production Secondary (Backups and Disaster Recovery) ``` - Go to the IAM Identity Center service, Groups view - Create a new group called CSGAdmins and add yourself - Create a new group called CSGReadOnly and add yourself -- Go to the IAM Identity Center service, AWS Accounts view, check all AWS Accounts under the Workflow OU and select - Assign users or groups +- Go to the IAM Identity Center service, AWS Accounts view, check all AWS Accounts under the compact's OU + (e.g. `Workflows/CompactConnect`) and the Deploy account and select Assign users or groups - Select the CSGAdmin group, and the `AWSAdministratorAccess` permission set -- Select all the accounts under the Workflow OU and select Assign users or groups +- Select all those same accounts and select Assign users or groups again - Select the CSGReadOnly group, and the `AWSReadOnlyAccess` permission set - In the future, add any new IAM Identity Center users to these groups as appropriate (or create even more groups, with more granular permissions, as needed). @@ -287,3 +291,140 @@ After setting up the multi-account architecture, you can deploy the log aggregat This will set up a CloudTrail organization trail that logs read operations on DynamoDB tables with the `-DataEventsLog` suffix across all accounts in the organization. The logs will be stored in an S3 bucket in the Logs account, and the trail itself will be managed from the Management account. + +## Adding a new compact + +Each compact runs in its own set of application accounts under the existing AWS Organization, in its own dedicated OU under `Workflows`. The Deploy account is **shared** across all compacts, so you do not provision a new Deploy account when onboarding a new compact. You need to create a new compact-level OU (with `PreProd` and `Prod` sub-OUs inside it), provision the compact's application accounts within those OUs, and stand up the compact's pipeline stacks inside the existing Deploy account. + +The follow documentation mirrors the process described above for setting up the original JCC compact, but with details specific for new compacts. + +### Create email distribution lists for the new compact +Following the same pattern established for the initial compact (see +[Deploy the multi-account app](#deploy-the-multi-account-app)), create (or have your IT department create) email +distribution lists that allow external senders for the new compact's application accounts. Include the compact +identifier in the list name so it is easy to distinguish from the other compacts' accounts. For example, for a +hypothetical `` compact: +- `cc--prod@` +- `cc--prod-secondary@` (for backups and disaster recovery) +- `cc--beta@` +- `cc--test@` +- `cc--test-secondary@` (for backups and disaster recovery) + +Note you do **not** need a new `-deploy@` distribution list. The Deploy account provisioned during the initial setup is +shared across all compacts. + +### Provision the new compact's application accounts +- Log into the AWS Management account console via your IAM Identity Center user +- Go to the ControlTower service, Organization view +- Create a new compact-level OU under `Workflows` named after the compact (e.g. `Cosmetology`), and within it + create `PreProd` and `Prod` sub-OUs, following the same pattern as the original compact's OU structure +- Go to the ControlTower service, Account factory view +- Create five new AWS accounts within the new compact's OUs. + Use the corresponding email distribution list from the previous step as the account address, give each account a + Display name that clearly identifies both the compact and the environment, and assign your own IAM Identity Center + user for Access configuration. The resulting OU structure should look like the following (the original compact's + OU is shown for reference, and the new compact's OU sits alongside it): +```text +└── Workflows + ├── Deployment + │ └── Deploy (shared, already exists) + ├── CompactConnect (original compact, already exists) + │ ├── PreProd + │ │ ├── Test + │ │ ├── Test Secondary + │ │ └── Beta + │ └── Prod + │ ├── Production + │ └── Production Secondary + └── (new compact OU) + ├── PreProd + │ ├── Test (new) + │ ├── Test Secondary (new) + │ └── Beta (new) + └── Prod + ├── Production (new) + └── Production Secondary (new) +``` + +### Grant IAM Identity Center access to the new accounts +- Go to the IAM Identity Center service, AWS Accounts view +- Select the five newly created application accounts under the new compact's OU (e.g. + `Workflows//PreProd` and `Workflows//Prod`) and select Assign users or groups +- Assign the existing `CSGAdmins` group with the `AWSAdministratorAccess` permission set +- Select those same accounts again and select Assign users or groups +- Assign the existing `CSGReadOnly` group with the `AWSReadOnlyAccess` permission set +- The `DenyComputeBackupAndResourceModifications` inline policy described in + [Configure Permission Set Inline Policies](#configure-permission-set-inline-policies) is attached at the permission + set level, so it automatically applies to the new accounts with no further action. +- The `Disallow actions as a root user` control configured in [Disallow Root](#disallow-root) is enabled at the OU + level, so it automatically applies to the new compact's OU as well. + +### Deploy the new compact's pipeline stacks +The new compact's pipeline stacks are deployed into the **existing** Deploy account alongside the original compact's +pipeline stacks. Each compact's CDK app defines its own set of pipeline stacks (for example, the Cosmetology compact +defines `TestBackendCosmetology`, `BetaBackendCosmetology`, and `ProdBackendCosmetology`), so the pipelines for +different compacts do not collide. + +- Navigate to the new compact's CDK project directory (for example, `backend/cosmetology-app` or + `backend/social-work-app`) +- Follow that project's deployment instructions for the pipelined environments. The Cosmetology compact's instructions + are a good reference for any new compact and live at + [Cosmetology README - First deploy to the pipelined environments](../cosmetology-app/README.md#first-deploy-to-the-pipelined-environments). + In particular, you generally will need to perform the following: + - Complete the StatSig Feature Flag Setup for each environment (test, beta, prod) + - Create Route53 hosted zones for the new compact's domain names in each of its Test, Beta, and Production accounts + - Populate the compact-specific `cdk.context.json` files with the new account IDs and push them to SSM via + `bin/put_ssm_context.sh ` + - Configure your CLI to use the Deploy account and run the appropriate `cdk deploy` command to create the new + compact's pipeline stacks (e.g. + `cdk deploy --context action=bootstrapDeploy TestBackendCosmetology BetaBackendCosmetology ProdBackendCosmetology` + for the Cosmetology compact; substitute the equivalent stack names for any other compact) + +**Important**: As with the initial compact setup, the new compact's pipeline stacks create cross-account roles in the +Deploy account (e.g. `CompactConnect-test-Cosmetology-CrossAccountRole`) that the new compact's application account +bootstrap templates trust. These pipeline stacks must be deployed before bootstrapping the new compact's application +accounts in the next step. + +### Bootstrap the new compact's application accounts +Each compact ships its own custom bootstrap templates that trust only the pipeline roles for that specific compact, +under `/resources/bootstrap-stack-{test,beta,prod}.yaml`. Update the role names in those templates (not the original compact's templates) to reference the new cross-account roles and then use the templates when bootstrapping the new compact's application accounts so the resulting bootstrap roles trust the correct cross-account roles. + +- For each of the new compact's Test, Beta, and Production accounts: + - Configure your CLI to use the target account + - Run the secure bootstrap command with the new compact's environment-specific template. For example, for the + Cosmetology compact: + + ```bash + # Run these commands from the backend/cosmetology-app directory + + # For the new compact's Test account + cdk bootstrap /us-east-1 --force \ + --template resources/bootstrap-stack-test.yaml \ + --trust \ + --cloudformation-execution-policies 'arn:aws:iam::aws:policy/AdministratorAccess' + + # For the new compact's Beta account + cdk bootstrap /us-east-1 --force \ + --template resources/bootstrap-stack-beta.yaml \ + --trust \ + --cloudformation-execution-policies 'arn:aws:iam::aws:policy/AdministratorAccess' + + # For the new compact's Production account + cdk bootstrap /us-east-1 --force \ + --template resources/bootstrap-stack-prod.yaml \ + --trust \ + --cloudformation-execution-policies 'arn:aws:iam::aws:policy/AdministratorAccess' + ``` + + For a different compact, run the commands from that compact's CDK project directory and use its + `resources/bootstrap-stack-*.yaml` templates. + +After the secure bootstrap completes, perform the first manual application deploy into each of the new compact's +application accounts and trigger the first pipeline run as described in the compact's README (for the Cosmetology +compact, see +[Cosmetology README - First deploy to the pipelined environments](../cosmetology-app/README.md#first-deploy-to-the-pipelined-environments)). + +### Bootstrap the new compact's secondary accounts +The Test Secondary and Production Secondary accounts host the new compact's backups and disaster recovery resources. +See [`backend/multi-account/backups/README.md`](./backups/README.md) for instructions on bootstrapping these secondary +accounts and deploying the backup resources for the new compact. diff --git a/backend/multi-account/backups/requirements-dev.txt b/backend/multi-account/backups/requirements-dev.txt index 4c11f1644c..f9a670b911 100644 --- a/backend/multi-account/backups/requirements-dev.txt +++ b/backend/multi-account/backups/requirements-dev.txt @@ -4,38 +4,34 @@ # # pip-compile --no-emit-index-url --no-strip-extras backups/requirements-dev.in # -boto3==1.42.91 +boto3==1.43.12 # via moto -botocore==1.42.91 +botocore==1.43.12 # via # boto3 # moto # s3transfer -certifi==2026.2.25 +certifi==2026.5.20 # via requests cffi==2.0.0 # via cryptography charset-normalizer==3.4.7 # via requests -cryptography==46.0.7 +cryptography==48.0.0 # via moto -idna==3.11 +idna==3.15 # via requests iniconfig==2.3.0 # via pytest -jinja2==3.1.6 - # via moto jmespath==1.1.0 # via # boto3 # botocore markupsafe==3.0.3 - # via - # jinja2 - # werkzeug -moto==5.1.22 + # via werkzeug +moto==5.2.1 # via -r backups/requirements-dev.in -packaging==26.1 +packaging==26.2 # via pytest pluggy==1.6.0 # via pytest @@ -46,18 +42,16 @@ pygments==2.20.0 pytest==9.0.3 # via -r backups/requirements-dev.in python-dateutil==2.9.0.post0 - # via - # botocore - # moto + # via botocore pyyaml==6.0.3 # via responses -requests==2.33.1 +requests==2.34.2 # via # moto # responses responses==0.26.0 # via moto -s3transfer==0.16.0 +s3transfer==0.17.0 # via boto3 six==1.17.0 # via python-dateutil diff --git a/backend/multi-account/backups/requirements.txt b/backend/multi-account/backups/requirements.txt index 503dc4193c..5bb315e784 100644 --- a/backend/multi-account/backups/requirements.txt +++ b/backend/multi-account/backups/requirements.txt @@ -10,21 +10,21 @@ attrs==25.4.0 # jsii aws-cdk-asset-awscli-v1==2.2.273 # via aws-cdk-lib -aws-cdk-asset-node-proxy-agent-v6==2.1.1 +aws-cdk-asset-node-proxy-agent-v6==2.1.2 # via aws-cdk-lib -aws-cdk-cloud-assembly-schema==53.20.0 +aws-cdk-cloud-assembly-schema==53.27.0 # via aws-cdk-lib -aws-cdk-lib==2.251.0 - # via -r requirements.in +aws-cdk-lib==2.256.1 + # via -r backups/requirements.in cattrs==25.3.0 # via jsii constructs==10.6.0 # via - # -r requirements.in + # -r backups/requirements.in # aws-cdk-lib importlib-resources==7.1.0 # via jsii -jsii==1.128.0 +jsii==1.131.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-node-proxy-agent-v6 diff --git a/backend/multi-account/control-tower/requirements-dev.txt b/backend/multi-account/control-tower/requirements-dev.txt index 6ac9d21472..1f12f6576a 100644 --- a/backend/multi-account/control-tower/requirements-dev.txt +++ b/backend/multi-account/control-tower/requirements-dev.txt @@ -6,21 +6,21 @@ # boolean-py==5.0 # via license-expression -build==1.4.3 +build==1.5.0 # via pip-tools cachecontrol[filecache]==0.14.4 # via # cachecontrol # pip-audit -certifi==2026.2.25 +certifi==2026.5.20 # via requests charset-normalizer==3.4.7 # via requests -click==8.3.2 +click==8.4.0 # via pip-tools -coverage[toml]==7.13.5 +coverage[toml]==7.14.0 # via - # -r requirements-dev.in + # -r control-tower/requirements-dev.in # pytest-cov cyclonedx-python-lib==11.7.0 # via pip-audit @@ -28,13 +28,13 @@ defusedxml==0.7.1 # via py-serializable filelock==3.29.0 # via cachecontrol -idna==3.11 +idna==3.15 # via requests iniconfig==2.3.0 # via pytest license-expression==30.4.4 # via cyclonedx-python-lib -markdown-it-py==4.0.0 +markdown-it-py==4.2.0 # via rich mdurl==0.1.2 # via markdown-it-py @@ -42,7 +42,7 @@ msgpack==1.1.2 # via cachecontrol packageurl-python==0.17.6 # via cyclonedx-python-lib -packaging==26.1 +packaging==26.2 # via # build # pip-audit @@ -52,11 +52,11 @@ packaging==26.1 pip-api==0.0.34 # via pip-audit pip-audit==2.10.0 - # via -r requirements-dev.in + # via -r control-tower/requirements-dev.in pip-requirements-parser==32.0.1 # via pip-audit pip-tools==7.5.3 - # via -r requirements-dev.in + # via -r control-tower/requirements-dev.in platformdirs==4.9.6 # via pip-audit pluggy==1.6.0 @@ -77,18 +77,18 @@ pyproject-hooks==1.2.0 # pip-tools pytest==9.0.3 # via - # -r requirements-dev.in + # -r control-tower/requirements-dev.in # pytest-cov pytest-cov==7.1.0 - # via -r requirements-dev.in -requests==2.33.1 + # via -r control-tower/requirements-dev.in +requests==2.34.2 # via # cachecontrol # pip-audit rich==15.0.0 # via pip-audit -ruff==0.15.12 - # via -r requirements-dev.in +ruff==0.15.13 + # via -r control-tower/requirements-dev.in sortedcontainers==2.4.0 # via cyclonedx-python-lib tomli==2.4.1 @@ -97,7 +97,7 @@ tomli-w==1.2.0 # via pip-audit urllib3==2.7.0 # via requests -wheel==0.46.3 +wheel==0.47.0 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/backend/multi-account/control-tower/requirements.txt b/backend/multi-account/control-tower/requirements.txt index 73124ad7b0..97aa309007 100644 --- a/backend/multi-account/control-tower/requirements.txt +++ b/backend/multi-account/control-tower/requirements.txt @@ -10,26 +10,26 @@ attrs==25.4.0 # jsii aws-cdk-asset-awscli-v1==2.2.273 # via aws-cdk-lib -aws-cdk-asset-node-proxy-agent-v6==2.1.1 +aws-cdk-asset-node-proxy-agent-v6==2.1.2 # via aws-cdk-lib -aws-cdk-cloud-assembly-schema==53.20.0 +aws-cdk-cloud-assembly-schema==53.27.0 # via aws-cdk-lib -aws-cdk-lib==2.251.0 +aws-cdk-lib==2.256.1 # via - # -r requirements.in + # -r control-tower/requirements.in # cdk-nag cattrs==25.3.0 # via jsii -cdk-nag==2.38.1 - # via -r requirements.in +cdk-nag==2.38.2 + # via -r control-tower/requirements.in constructs==10.6.0 # via - # -r requirements.in + # -r control-tower/requirements.in # aws-cdk-lib # cdk-nag importlib-resources==7.1.0 # via jsii -jsii==1.128.0 +jsii==1.131.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-node-proxy-agent-v6 diff --git a/backend/multi-account/log-aggregation/requirements-dev.txt b/backend/multi-account/log-aggregation/requirements-dev.txt index ef20a91323..31bf5fb3c3 100644 --- a/backend/multi-account/log-aggregation/requirements-dev.txt +++ b/backend/multi-account/log-aggregation/requirements-dev.txt @@ -6,7 +6,7 @@ # iniconfig==2.3.0 # via pytest -packaging==26.1 +packaging==26.2 # via pytest pluggy==1.6.0 # via pytest diff --git a/backend/multi-account/log-aggregation/requirements.txt b/backend/multi-account/log-aggregation/requirements.txt index abc2e171f4..b3d02a02ad 100644 --- a/backend/multi-account/log-aggregation/requirements.txt +++ b/backend/multi-account/log-aggregation/requirements.txt @@ -10,21 +10,21 @@ attrs==25.4.0 # jsii aws-cdk-asset-awscli-v1==2.2.273 # via aws-cdk-lib -aws-cdk-asset-node-proxy-agent-v6==2.1.1 +aws-cdk-asset-node-proxy-agent-v6==2.1.2 # via aws-cdk-lib -aws-cdk-cloud-assembly-schema==53.20.0 +aws-cdk-cloud-assembly-schema==53.27.0 # via aws-cdk-lib -aws-cdk-lib==2.251.0 - # via -r requirements.in +aws-cdk-lib==2.256.1 + # via -r log-aggregation/requirements.in cattrs==25.3.0 # via jsii constructs==10.6.0 # via - # -r requirements.in + # -r log-aggregation/requirements.in # aws-cdk-lib importlib-resources==7.1.0 # via jsii -jsii==1.128.0 +jsii==1.131.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-node-proxy-agent-v6 diff --git a/backend/social-work-app/.coveragerc b/backend/social-work-app/.coveragerc new file mode 100644 index 0000000000..921193bc82 --- /dev/null +++ b/backend/social-work-app/.coveragerc @@ -0,0 +1,15 @@ +[run] +data_file = ../.coverage + +omit = + */site-packages/* + */cdk.out/* + */smoke-test/* + */tests/* + */bin/* + +[report] +skip_empty = true + +[html] +directory = coverage diff --git a/backend/social-work-app/README.md b/backend/social-work-app/README.md index ab73e902ac..0c2d233b08 100644 --- a/backend/social-work-app/README.md +++ b/backend/social-work-app/README.md @@ -1,3 +1,518 @@ -# Social Work Compact App +# CompactConnect - Social Work App Backend developer documentation -This is the future home of the CompactConnect app version, customized for SocialWork +## Looking for technical user documentation? +[Find it here](./docs/README.md) + +## Introduction + +This is an [AWS-CDK](https://aws.amazon.com/cdk/) based project for the backend components of the licensure compact system. + +## Table of Contents +- **[Prerequisites](#prerequisites)** +- **[Environment Config](#environment-config)** +- **[Installing Dependencies](#installing-dependencies)** +- **[Local Development](#local-development)** +- **[Tests](#tests)** +- **[Deployment](#deployment)** +- **[Decommissioning](#decommissioning)** +- **[More Info](#more-info)** + +## Prerequisites +[Back to top](#compact-connect---backend-developer-documentation) + +To deploy this app, you will need: +1) Access to an AWS account +2) Python>=3.14 installed on your machine, preferably through a virtual environment management tool like + [pyenv](https://github.com/pyenv/pyenv), for clean management of virtual environments across multiple Python + versions. +3) Otherwise, follow the [Prerequisites section](https://cdkworkshop.com/15-prerequisites.html) of the CDK workshop to + prepare your system to work with AWS-CDK, including a NodeJS install. +4) Follow the steps in the [Installing Dependencies](#installing-dependencies) section. + +## Environment Config +[Back to top](#compact-connect---backend-developer-documentation) + +The `cdk.json` file tells the CDK Toolkit how to execute your app, including configuration specific to any given target +deployment. You can add local configuration that will be merged into the `cdk.json['context']` values with a +`cdk.context.json` file that you will not check in. + +### `ui_domain_name_override` + +**Important:** Because the Social Work backend is hosted on a different domain than the shared frontend UI application (e.g. the +backend hosted zone is `socialwork.compactconnect.org` but the UI lives at `app.compactconnect.org`), each +environment's context must include a `ui_domain_name_override` field that specifies the correct UI domain name. Without +this override, the UI domain would be incorrectly derived from the backend's hosted zone (e.g. +`app.socialwork.compactconnect.org` instead of `app.compactconnect.org`). This value is used for CORS allowed origins, +Cognito callback/logout URLs, and email template links. + +Example: +```json +{ + "domain_name": "socialwork.compactconnect.org", + "ui_domain_name_override": "app.compactconnect.org" +} +``` + +This project is set up like a standard Python project. To use it, create and activate a python virtual environment +using the tools of your choice (`pyenv` and `venv` are common). + +Once the virtualenv is activated, you can install the required dependencies. + +## Installing Dependencies +[Back to top](#compact-connect---backend-developer-documentation) + +Python requirements are pinned in [`requirements.txt`](requirements.txt). Install them using `pip`: + +``` +$ pip install -r requirements.txt +``` + +Node.js requirements (for some selected Lambda runtimes) are defined in [`package.json`](./lambdas/nodejs). Install them using `yarn`. + +```shell +$ cd lambdas/nodejs +$ yarn install +``` + +At this point you can now synthesize the CloudFormation template(s) for this code. + +``` +$ cdk synth +``` + +For development work there are additional requirements in `requirements-dev.txt` to install with +`pip install -r requirements-dev.txt`. + +To add additional dependencies, for example other CDK libraries, just add them to the `requirements.in` file and rerun +`pip-compile requirements.in`, then `pip install -r requirements.txt` command. + +### Convenience scripts + +To simplify dependency installation in this project, which includes many runtimes with similar dependencies, maintain +the dependency files with two convenience scripts, which manage the file contents for the runtimes, +[compile_requirements.sh](./bin/compile_requirements.sh), and installs the defined dependencies, +[sync_deps.sh](./bin/sync_deps.sh). + +## Local Development +[Back to top](#compact-connect---backend-developer-documentation) + +Local development can be done by editing the python code and `cdk.json`. For development purposes, this is simply a +Python project that can be exercised with local tests. Be sure to install the development requirements: + +``` +pip install -r requirements-dev.txt +``` + +Note that this project is a cloud-native app that has many small modular runtimes and integrations. Simulating that +distributed environment locally is not feasible, so the project relies heavily on test-driven development and solid +unit/functional tests to be incorporated in the development workflow. If you want to deploy this app to see how it runs +in the cloud, you can do so by configuring context for your own sandbox AWS account with context variables in +`cdk.context.json` and running the appropriate `cdk deploy` command. + +Once the deployment completes, you may want to run a local frontend. To do so, you must [populate a `.env` +file](../../webroot/README.md#environment-variables) with data on certain AWS resources (for example, AWS Cognito auth +domains and client IDs). A quick way to do that is to run `bin/sandbox_fetch_aws_resources.py --as-env` from the +`backend/compact-connect` directory and copy/paste the output into `webroot/.env`. To see more data on your deployment +in human-readable format (for example, DynamoDB table names), run `bin/fetch_aws_resources.py` without any additional +flags. + +## Tests +[Back to top](#compact-connect---backend-developer-documentation) + +Being a cloud project whose infrastructure is written in Python, establishing tests, using the python `unittest` +library is critical to maintaining reliability and velocity. Be sure that any updates you add are covered +by tests, so we don't introduce bugs or cost time identifying testable bugs after deployment. Note that all +unit/functional tests bundled with this app should be designed to execute with zero requirements for environmental +setup (including environment variables) beyond simply installing the dependencies in `requirements*.txt` files. CDK +tests are defined under the [tests](./tests) directory. Runtime code tests should be similarly bundled within the +lambda folders. Code that is common across all lambdas should be abstracted to a common code asset and tested there, to +reduce duplication and ensure consistency across the app. + +To execute the tests, simply run `bin/sync_deps.sh` then `bin/run_tests.sh` from the `backend` directory. + +## Documentation +[Back to top](#compact-connect---backend-developer-documentation) + +Keeping documentation current is an important part of feature development in this project. If the feature involves a +non-trivial amount of architecture or other technical design, be sure that the design and considerations are captured +in the [design documentation](./docs/design). If any updates are made to the API, be sure to follow these steps to keep +the documentation current: +1) Export a fresh api specification (OAS 3.0) is exported from API Gateway and used to update + [the Open API Specification JSON file](./docs/api-specification/latest-oas30.json). +2) Run `bin/trim_oas30.py` to organize and trim the API to include only supported API endpoints (and update the script + itself, if needed). +3) If you exported the api specification from somewhere other than the CSG Test environment, be sure to set the + `servers[0].url` entry back to the correct base URL for the CSG Test environment. +4) Use `bin/update_postman_collection.py` to update the [Postman Collection and Environment](./docs/postman), based on + your new api spec, as appropriate. + +## Deployment +[Back to top](#compact-connect---backend-developer-documentation) + +### AWS Service Quota Increases +Before deploying to any environment (sandbox, test, beta, or production), you'll need to request service quota +increases for the following AWS services: + +#### 1. Resource Servers Per User Pool (Amazon Cognito) +The Staff Users pool in CompactConnect uses resource servers for every jurisdiction (50+ states/territories). It also +has resource servers for each compact to implement granular permission scopes. As detailed in +[User Architecture documentation](./docs/design/README.md#user-architecture), resource server scopes are defined at +both the jurisdiction level (ie for state administrators) and the compact level (ie for compact administrators), +allowing for fine-grained access control tailored to each entity's specific needs. + +**Required Steps:** +1. Visit the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/home) in each AWS account you'll be deploying to +2. Search for "Amazon Cognito User Pools" +3. Find "Resource servers per user pool" (default value is 25) +4. Request an increase to at least 100 resource servers per user pool +5. Wait for AWS to approve the increase before attempting deployment + +This increase gives sufficient capacity for all jurisdictions (50+ states/territories) plus all compacts, with room for +future expansion. + +#### 2. Concurrent Executions (AWS Lambda) +CompactConnect uses numerous Lambda functions to power its backend services. By default, new AWS accounts have a very +low concurrent execution limit. + +**Required Steps:** +1. Visit the [AWS Service Quotas console](https://console.aws.amazon.com/servicequotas/home) in each AWS account you'll be deploying to +2. Search for "AWS Lambda" +3. Find "Concurrent executions" (default value is 10 for new accounts) +4. Request an increase to at least 1,000 concurrent executions +5. Wait for AWS to approve the increase before attempting deployment + +This increase ensures that your Lambda functions can scale appropriately during periods of high traffic without throttling. + +### First deploy to a Sandbox environment +The very first deploy to a new environment (like your personal sandbox account) requires a few steps to fully set up +its environment: +1) *Optional:* Create a new Route53 HostedZone in your AWS sandbox account for the DNS domain name you want to use for + your app. See [About Route53 Hosted Zones](#about-route53-hosted-zones) for more. Note: Without this step, you will + not be able to log in to the UI hosted in CloudFront. The Oauth2 authentication process requires a predictable + callback url to be pre-configured, which the domain name provides. You can still run a local UI against this app, + so long as you leave the `allow_local_ui` context value set to `true` and remove the `domain_name` param in your + environment's context. +2) *Optional if testing SES email notifications with custom domain:* By default, AWS does not allow sending emails to + unverified email + addresses. If you need to test SES email notifications and do not want to request AWS to remove your account from + the SES sandbox, you will need to set up a verified SES email identity for each address you want to send emails to. + See [Creating an email address identity](https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html#verify-email-addresses-procedure). Alternatively, you can request AWS to remove your account + from the SES sandbox, which will allow you to send emails to addresses that are not verified. See + [SES Sandbox](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html). + If you do not specify the `domain_name` field in your environment context, cognito will use its default email + configuration. + See [Default User Pool Email Settings](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-email.html#user-pool-email-default) +3) Copy [cdk.context.sandbox-example.json](./cdk.context.sandbox-example.json) to `cdk.context.json`. +4) At the top level of the JSON structure update the `"environment_name"` field to your own name. +5) Update the environment entry under `ssm_context.environments` to your own name and your own AWS sandbox account id + (which you can find by following + [these instructions](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-identifiers.html#FindAccountId)), + and domain name, if you set one up. **If you opted not to create a HostedZone, remove the `domain_name` field.** + The key under `environments` must match the value you put under `environment_name`. +6) Configure your aws cli to authenticate against your own account. There are several ways to do this based on the + type of authentication you use to login to your account. See the [AWS CLI Configuration Guide](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html). +7) Complete the [StatSig Feature Flag Setup](#statsig-feature-flag-setup) steps for your sandbox environment. +8) Run `cdk bootstrap` to add some base CDK support infrastructure to your AWS account. See + [Custom bootstrap stack](#custom-bootstrap-stack) below for optional custom stack deployment. +9) Run `cdk deploy 'Sandbox/*'` to get the initial backend stack resources deployed. +10) *Optional:* If you have a domain name configured for your sandbox environment, once the backend stacks have + successfully deployed, you can deploy the frontend UI app as well. See the + [UI app for details](../compact-connect-ui-app/README.md). + +### Subsequent sandbox deploys: +For any future deploys, everything is set up so a simple `cdk deploy 'Sandbox/*'` should update all your infrastructure +to reflect the changes in your code. Full deployment steps are: +1) Make sure your python environment is active. +2) Run `bin/sync_deps.sh` from `backend/` to ensure you have the latest requirements installed. +3) Configure your aws cli to authenticate against your own account. +4) Run `cdk deploy 'Sandbox/*'` to deploy the app to your AWS account. + +### Custom bootstrap stack + +The pipelined environments leverage a custom bootstrap stack, which includes cross-account trusts to the deploy account +as well as a permissions boundary around the CloudFormation execution role. If new AWS services are added to the app +architecture, that permissions boundary will need to be updated to allow access to the new service. See the +[multi-account documentation](../multi-account/README.md#bootstrap-the-application-accounts) for details on how to +deploy the custom bootstrap stack. If you want to test the bootstrap stack customizations in your sandbox, for example, +to make sure the new resources you are creating in your sandbox won't be blocked by the CloudFormation execution role's +permission boundary, you can deploy the custom stack to a sandbox account for testing, using the same steps. + +### Verifying SES configuration for Cognito User Notifications +If your account is in the SES sandbox, the simplest way to verify that SES is integrated with your cognito user pool is +to first go the AWS SES console and create an SES verified email identity for the email address you want to send a test +message to, See [Creating an email address identity](https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html#verify-email-addresses-procedure). + +Once you have verified your email address, go to the AWS Cognito console and find your user pool. From there, you have +the option to create a new user using your verified email address, and select the option to send an email invite. Once +you create the user, you should receive an email notification from Cognito, and you can verify that +the FROM address is using your custom domain. The DMARC authentication will reject any emails from your domain that are +not properly configured using SPF and DKIM, so if you get the email notification from Cognito, you've verified that the +authentication is working as expected. + +### First deploy to the pipelined environments +The production environment requires a few steps to fully set up before deploys can be automated. Refer to the +[README.md](../multi-account/README.md) for details on setting up a full multi-account architecture environment. Once +that is done, perform the following steps to deploy the CI/CD pipelines into the appropriate AWS account: +- Complete the [StatSig Feature Flag Setup](#statsig-feature-flag-setup) steps for each environment you will be deploying to (test, beta, prod). +- If the GitHub repository has not previously been connected to the deploy account, have someone with suitable permissions in the GitHub organization that hosts this code navigate to the AWS Console + for the Deploy account, go to the + [AWS CodeStar Connections](https://us-east-1.console.aws.amazon.com/codesuite/settings/connections) page and create a + connection that grants AWS permission to receive GitHub events. Note the ARN of the resulting connection for + the next step. +- In the `SocialWork Prod` account, create a new Route53 hosted zone for `socialwork.compactconnect.org`, and set up the appropriate DNS records in each of the production, beta, and test AWS accounts. See [About Route53 hosted zones](#about-route53-hosted-zones) below for more detail. +- With the [aws-cli](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), set up your local machine to authenticate against the Deploy account as an administrator. +- For every environment, copy the appropriate example context file (`cdk.context.deploy-example.json` for the `Deploy` + account, `cdk.context.test-example.json` for the `Test` account, `cdk.context.beta-example.json` for the `Beta` + account, or `cdk.context.prod-example.json` for the `Prod` account) to `cdk.context.json` and update the values to + match your respective accounts and other identifiers, including the code star connection you just created to match + the identifiers for your actual accounts and resources. You will then need to run the + `bin/put_ssm_context.sh ` script to push relevant content from your `cdk.context.json` script into an + SSM Parameter in your Deploy account. Replace `` with the target environment. For example, to set up for + the test environment: `bin/put_ssm_context.sh test`. + For example, to set up for the test environment: `bin/put_ssm_context.sh test`. +- Optional: If a Slack integration is desired for operational support, have someone with permission to install Slack + apps in your workspace and Admin access to each of the Test, Beta, Prod, and Deploy accounts log into each AWS account + and go to the Chatbot service. Select 'Slack' under the **Configure a chat client** box and click **Configure + client**, then follow the Slack authorization prompts. This will authorize AWS to integrate with the channels you + identify in your `cdk.context.json` file. For each Slack channel you want to integrate, be sure to invite your new + AWS app to those channels. Update the `notifications.slack` sections of the `cdk.context.json` file with the details + for your Slack workspace and channels. + If you opt not to integrate with Slack, remove the `slack` fields from the file. + +- For each environment (test, beta, prod): + - Configure your terminal to use the credentials for that specific environment account + - bootstrap the environment account with the default template (you must do this for every environment account before attempting to deploy the pipeline stacks in step 2): + ``` + cdk bootstrap --trust $deploy_account --cloudformation-execution-policies 'arn:aws:iam::aws:policy/AdministratorAccess' + ``` +- Setup the pipeline stacks + - Configure your terminal to use the credentials for the deploy account + - Set cli-environment variables `CDK_DEFAULT_ACCOUNT` and `CDK_DEFAULT_REGION` to your deploy account id and `us-east-1`, respectively. + - deploy the backend pipeline stacks (note: you will need to approve the + permission change requests for each stack deployment in the terminal): + ``` + cdk deploy --context action=bootstrapDeploy TestBackendSocialWork BetaBackendSocialWork ProdBackendSocialWork + ``` + +- To get the application stood up for the first time, manually use cdk to deploy the app into the environment account for each environment account (test, beta, and prod): + - While authenticated into the deploy account, synthesize the application: + ``` + # For Test environment: + cdk synth --context pipelineStack=TestBackendSocialWork --context action=pipelineSynth + + # For Beta environment (after **deploying** the test app): + cdk synth --context pipelineStack=BetaBackendSocialWork --context action=pipelineSynth + + # For Prod environment (after **deploying** the beta app): + cdk synth --context pipelineStack=ProdBackendSocialWork --context action=pipelineSynth + ``` + - Authenticate into the environment account and deploy the synthesized application: + ```sh + cd cdk.out + + # For Test environment: + cdk deploy --app . --no-rollback --require-approval never 'TestBackendSocialWork/Test/*' + + # For Beta environment (after deploying the test app): + cdk deploy --app . --no-rollback --require-approval never 'BetaBackendSocialWork/Beta/*' + + # For Prod environment (after deploying the beta app): + cdk deploy --app . --no-rollback --require-approval never 'ProdBackendSocialWork/Prod/*' + ``` + Some AWS services are not perfectly graceful in handling their initial provisioning. There are three expected failures that you just retry to get past. If these happen, log into the AWS console, find the failed stack and just click *retry*. Once the failed stack creation succeeds, you can re-run the `cdk deploy` command to proceed: +- In the PersistentStack: StaffUsersGreenUserPoolRiskConfiguration may fail to provision. This is due to a service linked role that Cognito creates at creation-time, which it doesn't reliably create before it fails its own process. +- In the SearchPersistentStack: ProviderSearchDomain, may fail for a similar issue. The Opensearch service linked role is created at creation-time and a retry should succeed. +- In the ApiStack: CloudFormation will attempt to create all of the API Gateway resources too quickly and will fail with a `429` (too many requests) error when API Gateway rate-limits CloudFormation. Again, retry the stack creation to get past this error. + +5. Restrict the environment account's bootstrap role access to the pipeline's cross-account role by deploying the + [custom bootstrap stack](#custom-bootstrap-stack). + +6. Once the application is fully deployed to each environment, create a tag/release in GitHub that matches the pattern + for each environment to trigger an initial CI/CD deploy and test the end-to-end pipeline functionality. + +7. Request AWS to remove your account from the SES sandbox and wait for them to complete this request. + See [SES Sandbox](https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html). Note that this must be + done _after_ first deploy of the application to each application account, as they require SES to be configured + before they will process the support request. + +### Bootstrap the secondary accounts +See backend/multi-account/backups/README for instructions on setting up the secondary accounts and backup resources. + +### Subsequent production deploys + +Once the pipelines are established with the above steps, deployments will be automatically handled: + +- Tags pushed with the pattern, `cc-test-*` will trigger the backend `test` pipeline to deploy +- Tags pushed with the pattern, `cc-prod-*` will trigger the backend `beta` and `prod` pipelines to deploy + +> *Note:* The frontend app has dependencies on the backend, in the form of parameters like +> S3 bucket urls, cognito domains, etc. If those change, you will need to explicitly plan +> the deploys so that the backend completes before the frontend starts to resolve the dependency. +> +> Currently, we include a [GitHub Action](../../.github/workflows/auto-tag-test-deployments.yml) that automatically +> tags all pushed commits to `main` with a `cc-test-*` and `ui-test-*` tag. Because there is no coordination between +> pipelines for these, now independent, services, they go out in parallel to the `test` environment. If these +> cross-app dependencies change, you will need to manually create an additional `ui-test-*` tag after the backend +> deploy completes, to resolve the cross-app dependencies. + +## Frontend Configuration Synchronization +[Back to top](#compact-connect---backend-developer-documentation) + +The Social Work backend application creates SSM parameters containing frontend configuration values (Cognito domains, API endpoints, etc.) that the frontend application needs during deployment. Since the frontend deploys from a different AWS account (the pipeline/deployment account), these SSM parameters must be **manually copied** to the frontend account whenever they are created **or updated**. + +> **Note:** If you do not have access to the frontend/pipeline account, coordinate with a JCC AWS administrator who has access to perform the copy operation. You can provide them with the parameter value from Step 1 below. + +### Synchronization Process + +**Step 1: Get the parameter value from the Social Work account** + +```bash +# Authenticate your cli credentials for the respective Social Work account + +# Get the parameter value +VALUE=$(aws ssm get-parameter \ + --name /app/social-work/deployment/persistent-stack/frontend_app_configuration \ + --query 'Parameter.Value' \ + --output text \ + --profile ) +``` + +**Step 2: Copy the parameter to the frontend/pipeline account** + +```bash +# Set your cli credentials to the respective frontend account +# Create/update the parameter in the frontend account +aws ssm put-parameter \ + --name /app/social-work/deployment/persistent-stack/frontend_app_configuration \ + --value "$VALUE" \ + --type String \ + --overwrite \ + --profile +``` + +If you do not have access to the frontend account, provide the `$VALUE` from Step 1 to a JCC AWS administrator who can perform this step on your behalf. + +**Step 3: Verify the copy** + +```bash +# Verify the parameter exists in the frontend account +aws ssm get-parameter \ + --name /app/social-work/deployment/persistent-stack/frontend_app_configuration \ + --profile +``` + +## StatSig Feature Flag Setup +[Back to top](#compact-connect---backend-developer-documentation) + +The feature flag system uses StatSig to manage feature flags across different environments. Follow these steps to set up StatSig for your environment: + +1. **Create a StatSig Account** + - Visit [StatSig](https://www.statsig.com/) and create an account + - Set up your project and organization + +2. **Generate API Keys** + - Navigate to the [API Keys section](https://docs.statsig.com/guides/first-feature/#step-4---create-a-new-client-api-key) of the StatSig console + - You'll need to create three types of API keys: + - **Server Secret Key**: Used for server-side feature flag evaluation + - **Client API Key**: Used for client-side feature flag evaluation (optional for this backend setup) + - **Console API Key**: Used for programmatic management of feature flags via the Console API + +3. **Store Credentials in AWS Secrets Manager** + - For each environment (test, beta, prod), create a secret in AWS Secrets Manager with the following naming pattern: + ``` + compact-connect/env/{environment_name}/statsig/credentials + ``` + - The secret value should be a JSON object with the following structure: + ```json + { + "serverKey": "", + "consoleKey": "" + } + ``` + - You can create the secret for each environment account by logging into the respective environment account and using the AWS CLI: + ```bash + aws secretsmanager create-secret \ + --name "compact-connect/env/{test | beta | prod}/statsig/credentials" \ + --secret-string '{"serverKey": "", "consoleKey": ""}' + ``` + +### Useful commands + +* `cdk ls` list all stacks in the app +* `cdk synth` emits the synthesized CloudFormation template +* `cdk deploy` deploy this stack to your default AWS account/region +* `cdk diff` compare deployed stack with current state +* `cdk docs` open CDK documentation + +## Decommissioning +[Back to top](#compact-connect---backend-developer-documentation) + +You can tear down resources associated with any of the CloudFormation stacks for this application with +`cdk destroy `. Most persistent resources with data remain in the Persistent stack, so you can freely +destroy the others without losing users or data. If you wish to destroy the Persistent stack as well, be aware that +some resources may be left behind as CloudFormation is designed to err on the side of orphaning resources over data +loss. You can identify any resources that weren't destroyed by watching the stack deletion from the AWS CloudFormation +Console, then looking at the resources after its delete is complete, to look for any with a `Delete Skipped` status. + +## About Route53 hosted zones + +A Hosted Zone in Route53 represents a collection of DNS records for a particular domain and its subdomains, managed +together. See the [Route53 FAQs for more](https://aws.amazon.com/route53/faqs/). When creating a hosted zone, you have +to also configure the domain name registrar (be it AWS or some other vendor) to point to the name servers associated +with your hosted zone, before the records in the zone will have any effect. When deploying this app, creating a hosted +zone in the AWS account for the UI and API domains is part of the environment setup. + +### Domain ownership and the compactconnect.org base domain + +The base domain `compactconnect.org` is owned by CSG. Its authoritative hosted zone lives in the +**`Prod` account of the `CompactConnect` OU** in the AWS organization (i.e. the original compact's production account). +All compact-specific subdomains (e.g. `socialwork.compactconnect.org`) are delegated from that base hosted zone. + +This means that when you create the hosted zone for this compact's production environment in the `SocialWork Prod` account, +you must also add a corresponding NS delegation record in the `compactconnect.org` hosted zone in the CompactConnect +`Prod` account so that DNS queries for your compact's subdomain are routed to the correct hosted zone. + +If you use the common approach of having your test and beta environments be subdomains of your compact's production +domain (i.e. `socialwork.compactconnect.org` for prod, `test.socialwork.compactconnect.org` for test, and +`beta.socialwork.compactconnect.org` for beta), you need to delegate nameserver authority from the compact's production +hosted zone down to the test and beta hosted zones. To do this, create the production hosted zone first +(`socialwork.compactconnect.org`) in the compact's `Prod` account, then create each pre-production hosted zone in its +respective account (`test.socialwork.compactconnect.org` in the `Test` account, +`beta.socialwork.compactconnect.org` in the `Beta` account), and then delegate nameserver authority from the production +zone to each subdomain zone. + +To delegate nameserver authority, find the NS record associated with the subdomain hosted zone and copy its value, +which should look something like: +```text +ns-1.awsdns-19.co.uk. +ns-2.awsdns-18.com. +ns-5.awsdns-15.net. +ns-6.awsdns-16.org. +``` + +Copy those name server values and, in the compact's **production** hosted zone (`socialwork.compactconnect.org`), +create a new NS record for the subdomain (i.e. Record Name: `test.socialwork.compactconnect.org`, Type: `NS`, +Value: ``). Repeat for the beta subdomain. Once that is done, the subdomain hosted zones are ready +for use by the app. + +In summary, coordinate with a team member who has access to the necessary accounts to complete the following steps if you do not have access yourself: + +1. In the **`SocialWork Prod` account**, create the `socialwork.compactconnect.org` hosted zone. Find the NS record for the hosted zone and copy them. +2. In the **CompactConnect `Prod` account** go to the `compactconnect.org` hosted zone and add an NS record delegating + `socialwork.compactconnect.org` to the compact's `Prod` account hosted zone. +3. In the SocialWork test and SocialWork beta accounts, create hosted zones for the respective subdomain (`test.socialwork.compactconnect.org` and `beta.socialwork.compactconnect.org`). Copy the NS record values for each respective hosted zone. +4. In the **`SocialWork Prod` account** (`socialwork.compactconnect.org` hosted zone): add NS records delegating + `test.socialwork.compactconnect.org` to the `Test` account and `beta.socialwork.compactconnect.org` to the `Beta` + account. + +> [!WARNING] +> Additionally, when setting up a Route53 HostedZone, you need to add an A record at your environment's +> HostedZone's base domain (i.e. `socialwork.compactconnect.org` for prod and `test.socialwork.compactconnect.org` for test) if there is not one already there. The target of the A record is not important — we simply need an A record at the base domain to prove domain ownership. This is necessary to +> create auth subdomains for the user pools. We have been pointing the A record at the IP of `compactconnect.org`, +> which can be obtained by running the command `dig compactconnect.org +short`. + +## More Info +[Back to top](#compact-connect---backend-developer-documentation) + +- [cdk-workshop](https://cdkworkshop.com/): If you are new to CDK, it is highly recommend you go through the CDK Workshop for a quick + introduction to the technology and its concepts before getting too deep into any particular project. diff --git a/backend/social-work-app/app.py b/backend/social-work-app/app.py new file mode 100644 index 0000000000..40c8c3479f --- /dev/null +++ b/backend/social-work-app/app.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +import os +import sys + +from aws_cdk import App, Environment + +# Make the `common_constructs` namespace package under `common-cdk` available to Python +sys.path.insert(0, os.path.abspath(os.path.join('..', 'common-cdk'))) + +from common_constructs.base_pipeline_stack import DEPLOY_ENVIRONMENT_NAME +from common_constructs.deployment_resources_stack import DeploymentResourcesStack +from common_constructs.stack import StandardTags + +from pipeline import ( + ACTION_CONTEXT_KEY, + PIPELINE_STACK_CONTEXT_KEY, + PIPELINE_SYNTH_ACTION, + BetaBackendPipelineStack, + ProdBackendPipelineStack, + TestBackendPipelineStack, +) +from pipeline.backend_stage import BackendStage + +# Pipeline stack name constants for DRY code +TEST_BACKEND_PIPELINE_STACK = 'TestBackendSocialWork' +BETA_BACKEND_PIPELINE_STACK = 'BetaBackendSocialWork' +PROD_BACKEND_PIPELINE_STACK = 'ProdBackendSocialWork' +DEPLOYMENT_RESOURCES_STACK = 'DeploymentResourcesSocialWork' + +# CDK path +CDK_PATH = 'backend/social-work-app' + + +class CompactConnectApp(App): + """ + CompactConnect CDK Application + + This application implements a CDK Pipeline deployment architecture with + performance optimizations for faster synthesis and deployment workflows. + + Architecture: + ------------ + 1. Backend Pipelines: Deploy infrastructure resources and backend components + 2. Frontend Pipelines: Deploy frontend application assets with backend configuration values + + Pipeline Execution Flow: + ---------------------- + - GitHub push → Backend Pipeline → Frontend Pipeline + + Stack Structure: + --------------- + - Backend Pipeline Stacks: TestBackendPipelineStack, BetaBackendPipelineStack, ProdBackendPipelineStack + - DeploymentResourcesStack: Shared resources needed by all pipeline stacks + + Each pipeline type is in its own dedicated stack to avoid self-mutation conflicts. + + Environment Deployments: + ------------------------------- + see README.md for instructions on how to deploy to a sandbox or pipeline environment. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sandbox_environment = self.node.try_get_context('sandbox') + + # Toggle for developers to deploy to a sandbox account without the pipeline + if self.sandbox_environment: + self._setup_sandbox_environment() + else: + self._setup_pipeline_environment() + + def _setup_sandbox_environment(self): + """Set up sandbox environment stacks""" + # ssm_context must be provided locally for a sandbox deploy + ssm_context = self.node.get_context('ssm_context') + environment_name = self.node.get_context('environment_name') + environment_context = ssm_context['environments'][environment_name] + app_name = ssm_context['app_name'] + + self.sandbox_backend_stage = BackendStage( + self, + 'Sandbox', + app_name=app_name, + environment_name=environment_name, + environment_context=environment_context, + backup_config=ssm_context.get('backup_config', {}), + ) + + def _setup_pipeline_environment(self): + """ + Set up pipeline environment stacks based on action and pipeline stack context + + This method implements the conditional stack creation pattern that is key to optimizing + synthesis performance in the CI/CD pipelines. It follows these rules: + + 1. For bootstrapDeploy: Creates all stacks to ensure permissions are correctly set when deploying resources + 2. For pipelineSynth: Creates only the specific stack requested to minimize synthesis time + + This approach dramatically reduces synthesis time in the pipeline while maintaining + all necessary permissions and relationships between stacks during bootstrap deployments. + """ + self.tags = self.node.get_context('tags') + self.action = self.node.try_get_context(ACTION_CONTEXT_KEY) + self.pipeline_stack_name = self.node.try_get_context(PIPELINE_STACK_CONTEXT_KEY) + + # Validate when in pipeline synth mode + if self.action == PIPELINE_SYNTH_ACTION and not self.pipeline_stack_name: + raise ValueError( + f"When action is '{PIPELINE_SYNTH_ACTION}', '{PIPELINE_STACK_CONTEXT_KEY}' context must be specified." + ) + + self.environment = Environment( + account=os.environ['CDK_DEFAULT_ACCOUNT'], + region=os.environ['CDK_DEFAULT_REGION'], + ) + + self.add_all_pipeline_stacks() + + def add_all_pipeline_stacks(self): + """ + add all pipeline stacks for deployment + + This is needed so that permissions set by the DeploymentResourcesStack are properly added for the pipeline + stack resources in every environment. + """ + # This stack must be declared first, as all other pipeline stacks depend on it. + self.add_deployment_resources_stack() + + self.add_test_backend_pipeline_stack() + self.add_beta_backend_pipeline_stack() + self.add_prod_backend_pipeline_stack() + + def add_deployment_resources_stack(self): + """add the deployment resources stack""" + self.deployment_resources_stack = DeploymentResourcesStack( + self, + DEPLOYMENT_RESOURCES_STACK, + pipeline_context_parameter_name=f'{DEPLOY_ENVIRONMENT_NAME}-socialwork-context', + env=self.environment, + standard_tags=StandardTags(**self.tags, environment='deploy'), + ) + + def add_test_backend_pipeline_stack(self): + """add and return the Test Backend Pipeline Stack""" + self.test_backend_pipeline_stack = TestBackendPipelineStack( + self, + TEST_BACKEND_PIPELINE_STACK, + pipeline_shared_encryption_key=self.deployment_resources_stack.pipeline_shared_encryption_key, + pipeline_alarm_topic=self.deployment_resources_stack.pipeline_alarm_topic, + pipeline_access_logs_bucket=self.deployment_resources_stack.pipeline_access_logs_bucket, + env=self.environment, + standard_tags=StandardTags(**self.tags, environment='pipeline'), + cdk_path=CDK_PATH, + ) + return self.test_backend_pipeline_stack + + def add_beta_backend_pipeline_stack(self): + """add and return the Beta Backend Pipeline Stack""" + self.beta_backend_pipeline_stack = BetaBackendPipelineStack( + self, + BETA_BACKEND_PIPELINE_STACK, + pipeline_shared_encryption_key=self.deployment_resources_stack.pipeline_shared_encryption_key, + pipeline_alarm_topic=self.deployment_resources_stack.pipeline_alarm_topic, + pipeline_access_logs_bucket=self.deployment_resources_stack.pipeline_access_logs_bucket, + env=self.environment, + standard_tags=StandardTags(**self.tags, environment='pipeline'), + cdk_path=CDK_PATH, + ) + return self.beta_backend_pipeline_stack + + def add_prod_backend_pipeline_stack(self): + """add and return the Production Backend Pipeline Stack""" + self.prod_backend_pipeline_stack = ProdBackendPipelineStack( + self, + PROD_BACKEND_PIPELINE_STACK, + pipeline_shared_encryption_key=self.deployment_resources_stack.pipeline_shared_encryption_key, + pipeline_alarm_topic=self.deployment_resources_stack.pipeline_alarm_topic, + pipeline_access_logs_bucket=self.deployment_resources_stack.pipeline_access_logs_bucket, + env=self.environment, + standard_tags=StandardTags(**self.tags, environment='pipeline'), + cdk_path=CDK_PATH, + ) + return self.prod_backend_pipeline_stack + + +if __name__ == '__main__': + app = CompactConnectApp() + app.synth() diff --git a/backend/social-work-app/app_clients/.gitignore b/backend/social-work-app/app_clients/.gitignore new file mode 100644 index 0000000000..94060587e1 --- /dev/null +++ b/backend/social-work-app/app_clients/.gitignore @@ -0,0 +1,2 @@ +*.pem +*.pub diff --git a/backend/social-work-app/app_clients/README.md b/backend/social-work-app/app_clients/README.md new file mode 100644 index 0000000000..a6c1a3c7c4 --- /dev/null +++ b/backend/social-work-app/app_clients/README.md @@ -0,0 +1,136 @@ +# App Client Management for Staff Users + +## Overview + +This document is a guide for technical staff for managing Cognito app clients for machine-to-machine authentication in +the State API. All app clients must be documented in the external 'CompactConnect App Client Registry' Google Sheet +(If you do not have access to said registry, contact a maintainer of the project and request access). + +## Creating a New App Client + +### 1. Prerequisites + +Before creating a new app client, ensure you have: +- Jurisdiction requirements documented (compact and state) +- Contact information for the consuming team +- Approval to grant the app client with the requested scopes +- AWS credentials configured with permissions to create app clients for the State Auth user pool in the needed AWS + accounts +- Python 3.10+ installed with boto3 dependency (`pip install boto3`) + +### 2. Update Registry + +Add the new app client information to the external Google Sheet registry for tracking and disaster recovery purposes +(ie a deployment error or AWS region outage causes app client data to be lost so it must be recreated). + +#### **Scope Configuration** + +Scopes are the permissions that the app client will have. There are two tiers of scopes: + +##### **Compact-Level Scopes:** + +These are the scopes that are scoped to a specific compact. Granting these scopes will allow the app client to perform +actions across all jurisdictions within that compact. Generally, the only scope that should be granted at the compact +level is the `{compact}/readGeneral` scope if needed. + +The following scopes are available at the compact level: +``` +{compact}/admin +{compact}/readGeneral +{compact}/readSSN +{compact}/write +``` + +##### **Jurisdiction-Level Scopes:** + +These are the scopes that are scoped to a specific jurisdiction/compact combination. Granting these scopes will allow +the app client to perform actions within a specific jurisdiction/compact combination. You should only grant these +scopes if the consuming team has a specific need for a jurisdiction/compact combination. + +The following scopes are available at the jurisdiction level: +```text +{jurisdiction}/{compact}.admin +{jurisdiction}/{compact}.write +{jurisdiction}/{compact}.readPrivate +{jurisdiction}/{compact}.readSSN +``` + +Currently, the most common scope needed by app clients is `{jurisdiction}/{compact}.write`, which allows uploading +license data for a jurisdiction/compact combination. + +### 3. Create App Client Using Interactive Python Script + +**Use the provided Python script in the bin directory for streamlined app client creation:** + + +```bash +python3 bin/create_app_client.py -u +``` + +**Interactive Process:** +The script will prompt you for: +- App client name (e.g., "example-ky-app-client-v1") +- Compact (socw) +- State postal abbreviation (e.g., "ky", "la") +- Additional scopes (optional) + +**Automatic Scope Generation:** +The script automatically creates these standard scopes: +- `{compact}/readGeneral` - General read access for the compact +- `{state}/{compact}.write` - Write access for the specific state/compact combination + +### 4. **Send Credentials to Consuming Team** + +**When using the Python script (recommended):** +The script will output two separate sections: + +**A. Credentials JSON (for one-time link service):** +```json +{ + "clientId": "6g34example89j", + "clientSecret": "1234example567890" +} +``` +**Important:** These credentials should be securely transmitted to the consuming team via an encrypted channel +(i.e., a one-time use link). Copy this JSON and use it with your one-time secret link generator. Once you have sent +the credentials over to the IT staff, ensure you remove all remnants of the credentials from your device. + +**B. Email Template:** +The script will also generate an email template with contextual information (compact name, state, auth URL, license +upload URL) that you can copy/paste into your email client. This template includes a placeholder for the one-time +link that you'll generate separately. + + +#### Email Instructions for consuming team + +As part of the email message sent to the consuming team, be sure to include the onboarding instructions document from +the `it_staff_onboarding_instructions/` directory. + +## Rotating App Client Credentials + +Unfortunately, AWS Cognito does not support rotating app client credentials for an existing app client. The only way +to rotate credentials is to create a new app client with a new clientId and clientSecret and then delete the old one. +The following process should be performed if credentials are accidentally exposed or in the event of a security breach +where the old credentials are compromised. + +### 1. Pre-rotation Tasks + +- Contact consuming team to schedule rotation +- Follow "Creating a New App Client" steps above using either the Python script (recommended) or AWS CLI, you will +increment clientName version suffix by 1 (e.g. "example-ky-app-client-v1" -> "example-ky-app-client-v2") +- Follow “Creating a New App Client” using the Python script (recommended) or AWS CLI. Increment the client name’s + version suffix by 1 (e.g., “example-ky-app-client-v1” -> “example-ky-app-client-v2”). +- Update the external Google Sheet registry with new client information + +### 2. Migration + +- Provide new client id and client secret to consuming team +- Consuming team will need to confirm that the new credentials are deployed in their systems, the old app client is +not in use, and their systems are working as expected. + +### 3. Cleanup + +- Delete old app client from Cognito using the following cli command: +``` +aws cognito-idp delete-user-pool-client --user-pool-id '' --client-id '' +``` diff --git a/backend/social-work-app/app_clients/bin/create_app_client.py b/backend/social-work-app/app_clients/bin/create_app_client.py new file mode 100755 index 0000000000..ea37912d20 --- /dev/null +++ b/backend/social-work-app/app_clients/bin/create_app_client.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201 we use print statements for scripts run locally + +""" +Script to create AWS Cognito app clients interactively. + +This script prompts users for the necessary information to create app clients +in different environments (test, beta, prod) and automatically generates +the standard scopes based on compact and state inputs. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +import boto3 +from botocore.exceptions import ClientError, NoCredentialsError + + +def load_cdk_config(): + """Load configuration from cdk.json file.""" + # Find cdk.json file - look in parent directories + current_dir = Path(__file__).parent + cdk_json_path = None + + # Look up the directory tree for cdk.json + for parent in [current_dir] + list(current_dir.parents): + potential_path = parent / 'cdk.json' + if potential_path.exists(): + cdk_json_path = potential_path + break + + if not cdk_json_path: + raise FileNotFoundError('Could not find cdk.json file in current directory or parent directories') + + with open(cdk_json_path) as f: + cdk_config = json.load(f) + + context = cdk_config.get('context', {}) + + return { + 'compacts': context.get('compacts', []), + 'active_compact_member_jurisdictions': context.get('active_compact_member_jurisdictions', {}), + } + + +# Load configuration from cdk.json +CDK_CONFIG = load_cdk_config() +VALID_COMPACTS = CDK_CONFIG['compacts'] +ACTIVE_COMPACT_JURISDICTIONS = CDK_CONFIG['active_compact_member_jurisdictions'] + + +# Valid scope patterns for validation +VALID_SCOPE_PATTERNS = [ + r'^[a-z]+/readGeneral$', + r'^[a-z]+/readSSN$', + r'^[a-z]+/write$', + r'^[a-z]+/admin$', + r'^[a-z]{2}/[a-z]+\.write$', + r'^[a-z]{2}/[a-z]+\.readPrivate$', + r'^[a-z]{2}/[a-z]+\.readSSN$', + r'^[a-z]{2}/[a-z]+\.admin$', +] + + +def validate_compact(compact): + """Validate compact input.""" + compact = compact.lower().strip() + if compact not in VALID_COMPACTS: + raise ValueError(f'Invalid compact: {compact}. Valid compacts are: {", ".join(VALID_COMPACTS)}') + return compact + + +def validate_state(state, compact): + """Validate state postal abbreviation for the given compact.""" + state = state.lower().strip() + + # Get valid states for this compact + valid_states = ACTIVE_COMPACT_JURISDICTIONS.get(compact, []) + + if state not in valid_states: + raise ValueError( + f'Invalid state: {state}. Valid states for {compact.upper()} compact are: {", ".join(sorted(valid_states))}' + ) + return state + + +def validate_scope(scope): + """Validate a single scope against known patterns.""" + scope = scope.strip() + for pattern in VALID_SCOPE_PATTERNS: + if re.match(pattern, scope): + return True + return False + + +def validate_additional_scopes(scopes_input): + """Validate additional scopes input.""" + if not scopes_input.strip(): + return [] + + scopes = [scope.strip() for scope in scopes_input.split(',')] + invalid_scopes = [] + + for scope in scopes: + if not validate_scope(scope): + invalid_scopes.append(scope) + + if invalid_scopes: + print(f'\nInvalid scopes detected: {", ".join(invalid_scopes)}') + print('Valid scope patterns:') + print(' Compact-level: {compact}/readGeneral, {compact}/readSSN, {compact}/write, {compact}/admin') + print( + 'Jurisdiction-level: {state}/{compact}.write, {state}/{compact}.readPrivate, {state}/{compact}.readSSN, ' + '{state}/{compact}.admin' + ) + raise ValueError('Invalid scopes provided') + + return scopes + + +def get_user_input(): + """Get user input for app client configuration.""" + print('=== App Client Configuration ===\n') + + # Get environment + while True: + try: + print('Valid environments: test, beta, prod') + environment = input('Enter the environment: ').strip().lower() + if environment not in ['test', 'beta', 'prod']: + raise ValueError('Invalid environment. Must be one of: test, beta, prod') + break + except ValueError as e: + print(f'Error: {e}') + + # Get client name + client_name = input("Enter the app client name (e.g., 'example-ky-app-client-v1'): ").strip() + if not client_name: + raise ValueError('Client name is required') + + # Get compact + while True: + try: + print(f'\nValid compacts: {", ".join(VALID_COMPACTS)}') + compact = input('Enter the compact: ').strip() + compact = validate_compact(compact) + break + except ValueError as e: + print(f'Error: {e}') + + # Get state + while True: + try: + valid_states = ACTIVE_COMPACT_JURISDICTIONS.get(compact, []) + print(f'\nValid states for {compact.upper()} compact: {", ".join(sorted(valid_states))}') + state = input("Enter the state postal abbreviation (e.g., 'ky', 'la'): ").strip() + state = validate_state(state, compact) + break + except ValueError as e: + print(f'Error: {e}') + + # Get additional scopes (optional) + print('\nThe following scope will be automatically included:') + print(f' - {state}/{compact}.write') + + additional_scopes = [] + while True: + try: + scopes_input = input('\nEnter any additional scopes (comma-separated, or press Enter for none): ').strip() + additional_scopes = validate_additional_scopes(scopes_input) + break + except ValueError as e: + print(f'Error: {e}') + continue + + # Generate final scope list + scopes = [f'{state}/{compact}.write'] + scopes.extend(additional_scopes) + + # Remove duplicates + deduped_scopes = list(set(scopes)) + + print('\nFinal configuration:') + print(f' Client Name: {client_name}') + print(f' Compact: {compact}') + print(f' State: {state}') + print(f' Scopes: {", ".join(deduped_scopes)}') + + confirm = input('\nProceed with this configuration? (y/N): ').strip().lower() + if confirm != 'y': + print('Configuration cancelled.') + sys.exit(0) + + return { + 'environment': environment, + 'clientName': client_name, + 'compact': compact, + 'state': state, + 'scopes': deduped_scopes, + } + + +def create_app_client(user_pool_id, config): + """Create the app client using boto3 Cognito client.""" + client_name = config['clientName'] + scopes = config['scopes'] + + print(f'\nCreating app client: {client_name}') + print(f'With scopes: {", ".join(scopes)}') + + try: + # Create boto3 Cognito IDP client + cognito_client = boto3.client('cognito-idp', region_name='us-east-1') + + # Create the user pool client + return cognito_client.create_user_pool_client( + UserPoolId=user_pool_id, + ClientName=client_name, + PreventUserExistenceErrors='ENABLED', + GenerateSecret=True, + TokenValidityUnits={'AccessToken': 'minutes'}, + AccessTokenValidity=15, + AllowedOAuthFlowsUserPoolClient=True, + AllowedOAuthFlows=['client_credentials'], + AllowedOAuthScopes=scopes, + ) + + except NoCredentialsError: + print('Error: AWS credentials not found. Please configure your AWS credentials.') + print("You can use 'aws configure' or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.") + sys.exit(1) + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + print(f'Error creating app client: {error_code} - {error_message}') + sys.exit(1) + + +def print_credentials(client_id, client_secret): + """Print only the sensitive credentials in JSON format for secure copy/paste.""" + credentials = { + 'clientId': client_id, + 'clientSecret': client_secret, + } + + print('\n' + '=' * 60) + print('APP CLIENT CREDENTIALS (FOR ONE-TIME LINK SERVICE)') + print('=' * 60) + print(json.dumps(credentials, indent=2)) + print('=' * 60) + print('Please copy the JSON above and use it with your one-time secret link generator.') + print('Do not leave these credentials in terminal history or logs.') + print('=' * 60) + + +def print_email_template(environment, compact, state): + """Print an email template with contextual information for the consuming team.""" + # Get environment-specific URLs + auth_urls = { + 'beta': 'https://socialwork-compact-connect-state-auth-beta.auth.us-east-1.amazoncognito.com', + 'prod': 'https://socialwork-compact-connect-state-auth.auth.us-east-1.amazoncognito.com', + 'test': 'https://socialwork-compact-connect-state-auth-beta.auth.us-east-1.amazoncognito.com', + } + + api_base_urls = { + 'beta': 'https://state-api.beta.socialwork.compactconnect.org', + 'prod': 'https://state-api.socialwork.compactconnect.org', + 'test': 'https://state-api.test.socialwork.compactconnect.org.org', + } + + # Compact name mapping + compact_names = { + 'socw': 'Social Work', + } + + compact_name = compact_names.get(compact, compact.upper()) + auth_url = auth_urls.get(environment) + license_upload_url = f'{api_base_urls.get(environment)}/v1/compacts/{compact}/jurisdictions/{state}/licenses' + + email_template = f""" +Thank you for integrating with CompactConnect! You have been designated as the IT professional who is able to handle +credentials for secure machine-to-machine authentication between your state and CompactConnect. + +Details for these credentials are: +Compact: {compact_name} +State: {state.upper()} +Auth URL: {auth_url} +License Upload URL: {license_upload_url} + +Follow this link to your API credentials as soon as you are ready to securely store them. They will only be viewable +once: + + +**Please respond to this email to confirm that you have received and securely stored the credentials. This link will +expire in 7 days.** + +For more information on CompactConnect and how to integrate your state IT system with ours, see the documentation +here: +https://github.com/csg-org/CompactConnect/blob/main/backend/compact-connect/docs/it_staff_onboarding_instructions.md +""" + + print('\n' + '=' * 60) + print('EMAIL TEMPLATE (COPY/PASTE INTO EMAIL CLIENT)') + print('=' * 60) + print(email_template.strip()) + print('=' * 60) + + +def main(): + parser = argparse.ArgumentParser(description='Create AWS Cognito app client interactively') + parser.add_argument('-u', '--user-pool-id', required=True, help='AWS Cognito User Pool ID') + + args = parser.parse_args() + + try: + print(f'User Pool ID: {args.user_pool_id}\n') + + # Get configuration from user input (including environment) + config = get_user_input() + + print(f'\nCreating app client for {config["environment"]} environment...') + + # Create the app client + response = create_app_client(args.user_pool_id, config) + + # Extract credentials from response + user_pool_client = response.get('UserPoolClient', {}) + client_id = user_pool_client.get('ClientId') + client_secret = user_pool_client.get('ClientSecret') + client_name = user_pool_client.get('ClientName') + + if not client_id or not client_secret: + print('Error: Could not extract client ID or secret from AWS response') + sys.exit(1) + + print('\n✅ App client created successfully!') + print(f'Client Name: {client_name}') + print(f'Client ID: {client_id}') + + # Print credentials for secure copy/paste + print_credentials(client_id, client_secret) + + # Print email template + print_email_template(config['environment'], config['compact'], config['state']) + + print('\n📝 Remember to add this app client to your external registry!') + + except Exception as e: # noqa: BLE001 + print(f'Error: {e}') + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/app_clients/bin/manage_signature_keys.py b/backend/social-work-app/app_clients/bin/manage_signature_keys.py new file mode 100755 index 0000000000..50b4696cfa --- /dev/null +++ b/backend/social-work-app/app_clients/bin/manage_signature_keys.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201 we use print statements for scripts run locally + +""" +Script to manage SIGNATURE public keys in the compact configuration database. + +This script allows users to create and delete SIGNATURE public keys for different +compact/jurisdiction combinations. It follows the same interactive style as +the create_app_client.py script. +""" + +import argparse +import json +import sys +from datetime import UTC, datetime +from pathlib import Path + +import boto3 +from botocore.exceptions import ClientError, NoCredentialsError + + +def load_cdk_config(): + """Load configuration from cdk.json file.""" + # Find cdk.json file - look in parent directories + current_dir = Path(__file__).parent + cdk_json_path = None + + # Look up the directory tree for cdk.json + for parent in [current_dir] + list(current_dir.parents): + potential_path = parent / 'cdk.json' + if potential_path.exists(): + cdk_json_path = potential_path + break + + if not cdk_json_path: + raise FileNotFoundError('Could not find cdk.json file in current directory or parent directories') + + with open(cdk_json_path) as f: + cdk_config = json.load(f) + + context = cdk_config.get('context', {}) + + return { + 'compacts': context.get('compacts', []), + 'active_compact_member_jurisdictions': context.get('active_compact_member_jurisdictions', {}), + } + + +# Load configuration from cdk.json +CDK_CONFIG = load_cdk_config() +VALID_COMPACTS = CDK_CONFIG['compacts'] +ACTIVE_COMPACT_JURISDICTIONS = CDK_CONFIG['active_compact_member_jurisdictions'] + + +def validate_compact(compact): + """Validate compact input.""" + compact = compact.lower().strip() + if compact not in VALID_COMPACTS: + raise ValueError(f'Invalid compact: {compact}. Valid compacts are: {", ".join(VALID_COMPACTS)}') + return compact + + +def validate_state(state, compact): + """Validate state postal abbreviation for the given compact.""" + state = state.lower().strip() + + # Get valid states for this compact + valid_states = ACTIVE_COMPACT_JURISDICTIONS.get(compact, []) + + if state not in valid_states: + raise ValueError( + f'Invalid state: {state}. Valid states for {compact.upper()} compact are: {", ".join(sorted(valid_states))}' + ) + return state + + +def validate_key_id(key_id): + """Validate key ID input.""" + key_id = key_id.strip() + if not key_id: + raise ValueError('Key ID cannot be empty') + if len(key_id) > 100: # Reasonable limit for key ID + raise ValueError('Key ID is too long (max 100 characters)') + if not key_id.replace('-', '').replace('_', '').isalnum(): + raise ValueError('Key ID can only contain alphanumeric characters, hyphens, and underscores') + return key_id + + +def get_user_input_for_create(): + """Get user input for creating a SIGNATURE public key.""" + print('=== SIGNATURE Public Key Creation ===\n') + + # Get compact + while True: + try: + print(f'Valid compacts: {", ".join(VALID_COMPACTS)}') + compact = input('Enter the compact: ').strip() + compact = validate_compact(compact) + break + except ValueError as e: + print(f'Error: {e}') + + # Get state + while True: + try: + valid_states = ACTIVE_COMPACT_JURISDICTIONS.get(compact, []) + print(f'\nValid states for {compact.upper()} compact: {", ".join(sorted(valid_states))}') + state = input("Enter the state postal abbreviation (e.g., 'ky', 'la'): ").strip() + state = validate_state(state, compact) + break + except ValueError as e: + print(f'Error: {e}') + + # Get key ID + while True: + try: + key_id = input('\nEnter the key ID (e.g., "client-key-001"): ').strip() + key_id = validate_key_id(key_id) + break + except ValueError as e: + print(f'Error: {e}') + + print('\nConfiguration:') + print(f' Compact: {compact}') + print(f' State: {state}') + print(f' Key ID: {key_id}') + print(f' Public key file: {key_id}.pub') + + confirm = input('\nProceed with this configuration? (y/N): ').strip().lower() + if confirm != 'y': + print('Configuration cancelled.') + sys.exit(0) + + return {'compact': compact, 'state': state, 'key_id': key_id} + + +def read_public_key_file(key_id): + """Read the public key from the specified file.""" + file_path = Path(f'{key_id}.pub') + + if not file_path.exists(): + raise FileNotFoundError( + f'Public key file "{file_path}" not found. Please ensure the file exists in the current directory.' + ) + + try: + with open(file_path) as f: + public_key_content = f.read().strip() + + if not public_key_content: + raise ValueError('Public key file is empty') + + # Basic validation that this looks like a PEM public key + if not public_key_content.startswith('-----BEGIN PUBLIC KEY-----'): + raise ValueError( + 'Public key file does not appear to be in PEM format (should start with "-----BEGIN PUBLIC KEY-----")' + ) + + if not public_key_content.endswith('-----END PUBLIC KEY-----'): + raise ValueError( + 'Public key file does not appear to be in PEM format (should end with "-----END PUBLIC KEY-----")' + ) + + return public_key_content + + except (FileNotFoundError, ValueError) as e: + raise ValueError(f'Error reading public key file: {e}') from e + + +def create_signature_key(table_name, config): + """Create the SIGNATURE public key in DynamoDB.""" + compact = config['compact'] + state = config['state'] + key_id = config['key_id'] + + print(f'\nCreating SIGNATURE public key: {key_id}') + print(f'For compact: {compact}, state: {state}') + + try: + # Create boto3 DynamoDB client + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(table_name) + + # Check if key already exists + pk = f'{compact}#SIGNATURE_KEYS#{state}' + sk = f'{compact}#JURISDICTION#{state}#{key_id}' + + response = table.get_item(Key={'pk': pk, 'sk': sk}) + + if 'Item' in response: + print(f'\n⚠️ Warning: A key with ID "{key_id}" already exists for {compact}/{state}') + overwrite = input('Do you want to overwrite it? (y/N): ').strip().lower() + if overwrite != 'y': + print('Operation cancelled.') + sys.exit(0) + + # Read the public key file + print(f'\nReading public key from {key_id}.pub...') + public_key_pem = read_public_key_file(key_id) + + # Create the item + item = { + 'pk': pk, + 'sk': sk, + 'publicKey': public_key_pem, + 'compact': compact, + 'jurisdiction': state, + 'keyId': key_id, + 'createdAt': datetime.now(tz=UTC).isoformat(), + } + + # Write to DynamoDB + table.put_item(Item=item) + + print('\n✅ SIGNATURE public key created successfully!') + print(f'Key ID: {key_id}') + print(f'Compact: {compact}') + print(f'State: {state}') + + except NoCredentialsError: + print('Error: AWS credentials not found. Please configure your AWS credentials.') + print("You can use 'aws configure' or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.") + raise + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + print(f'Error creating SIGNATURE key: {error_code} - {error_message}') + raise + + +def get_user_input_for_delete(): + """Get user input for deleting a SIGNATURE public key.""" + print('=== SIGNATURE Public Key Deletion ===\n') + + # Get compact + while True: + try: + print(f'Valid compacts: {", ".join(VALID_COMPACTS)}') + compact = input('Enter the compact: ').strip() + compact = validate_compact(compact) + break + except ValueError as e: + print(f'Error: {e}') + + # Get state + while True: + try: + valid_states = ACTIVE_COMPACT_JURISDICTIONS.get(compact, []) + print(f'\nValid states for {compact.upper()} compact: {", ".join(sorted(valid_states))}') + state = input("Enter the state postal abbreviation (e.g., 'ky', 'la'): ").strip() + state = validate_state(state, compact) + break + except ValueError as e: + print(f'Error: {e}') + + return {'compact': compact, 'state': state} + + +def list_existing_keys(table_name, config): + """List existing SIGNATURE keys for the given compact/state combination.""" + compact = config['compact'] + state = config['state'] + + try: + # Create boto3 DynamoDB client + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(table_name) + + # Query for existing keys + pk = f'{compact}#SIGNATURE_KEYS#{state}' + sk_prefix = f'{compact}#JURISDICTION#{state}#' + + response = table.query( + KeyConditionExpression='pk = :pk AND begins_with(sk, :sk_prefix)', + ExpressionAttributeValues={':pk': pk, ':sk_prefix': sk_prefix}, + ) + + items = response.get('Items', []) + + if not items: + print(f'\nNo SIGNATURE keys found for {compact}/{state}') + return [] + + print(f'\nExisting SIGNATURE keys for {compact}/{state}:') + for i, item in enumerate(items, 1): + key_id = item['sk'].split('#')[-1] + created_at = item.get('createdAt', 'Unknown') + print(f' {i}. Key ID: {key_id} (Created: {created_at})') + + return items + + except NoCredentialsError: + print('Error: AWS credentials not found. Please configure your AWS credentials.') + print("You can use 'aws configure' or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.") + raise + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + print(f'Error listing SIGNATURE keys: {error_code} - {error_message}') + raise + + +def delete_signature_key(table_name, config, key_id): + """Delete the specified SIGNATURE public key from DynamoDB.""" + compact = config['compact'] + state = config['state'] + + print(f'\nDeleting SIGNATURE public key: {key_id}') + print(f'For compact: {compact}, state: {state}') + + try: + # Create boto3 DynamoDB client + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(table_name) + + # Delete the item + pk = f'{compact}#SIGNATURE_KEYS#{state}' + sk = f'{compact}#JURISDICTION#{state}#{key_id}' + + table.delete_item(Key={'pk': pk, 'sk': sk}) + + print('\n✅ SIGNATURE public key deleted successfully!') + print(f'Key ID: {key_id}') + print(f'Compact: {compact}') + print(f'State: {state}') + + except NoCredentialsError: + print('Error: AWS credentials not found. Please configure your AWS credentials.') + print("You can use 'aws configure' or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.") + raise + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + print(f'Error deleting SIGNATURE key: {error_code} - {error_message}') + raise + + +def main(): + parser = argparse.ArgumentParser(description='Manage SIGNATURE public keys in the compact configuration database') + parser.add_argument('action', choices=['create', 'delete'], help='Action to perform (create or delete)') + parser.add_argument('-t', '--table-name', required=True, help='DynamoDB table name for compact configuration') + + args = parser.parse_args() + + print(f'Managing SIGNATURE keys for {args.table_name} table...\n') + + if args.action == 'create': + # Create flow + config = get_user_input_for_create() + + create_signature_key(args.table_name, config) + + elif args.action == 'delete': + # Delete flow + config = get_user_input_for_delete() + + # List existing keys + existing_keys = list_existing_keys(args.table_name, config) + + if not existing_keys: + print('\nNo keys to delete.') + sys.exit(0) + + # Get key ID to delete + while True: + key_id = input('\nEnter the exact key ID to delete: ').strip() + key_id = validate_key_id(key_id) + + # Check if key exists + key_exists = any(item['sk'].split('#')[-1] == key_id for item in existing_keys) + if not key_exists: + print(f'Error: Key ID "{key_id}" not found in the list above') + continue + + break + + # Final confirmation + print(f'\n⚠️ You are about to delete SIGNATURE key "{key_id}" for {config["compact"]}/{config["state"]}') + print('This action cannot be undone.') + + confirm = input('\nAre you sure you want to delete this key? Type "DELETE" to confirm: ').strip() + if confirm != 'DELETE': + print('Deletion cancelled.') + sys.exit(0) + + delete_signature_key(args.table_name, config, key_id) + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/app_clients/it_staff_onboarding_instructions/README.md b/backend/social-work-app/app_clients/it_staff_onboarding_instructions/README.md new file mode 100644 index 0000000000..f28dc34c44 --- /dev/null +++ b/backend/social-work-app/app_clients/it_staff_onboarding_instructions/README.md @@ -0,0 +1 @@ +[Moved Here](../../docs/it_staff_onboarding_instructions.md) diff --git a/backend/social-work-app/bin/compile_requirements.sh b/backend/social-work-app/bin/compile_requirements.sh new file mode 100755 index 0000000000..7635767061 --- /dev/null +++ b/backend/social-work-app/bin/compile_requirements.sh @@ -0,0 +1,25 @@ +set -e + +pip-compile --no-emit-index-url --upgrade --no-strip-extras requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/cognito-backup/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/cognito-backup/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/compact-configuration/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/compact-configuration/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/common/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/common/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/custom-resources/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/custom-resources/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/data-events/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/data-events/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/disaster-recovery/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/disaster-recovery/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/provider-data-v1/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/provider-data-v1/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/search/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/search/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/staff-user-pre-token/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/staff-user-pre-token/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/staff-users/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras lambdas/python/staff-users/requirements.in +bin/sync_deps.sh diff --git a/backend/social-work-app/bin/count_providers.py b/backend/social-work-app/bin/count_providers.py new file mode 100644 index 0000000000..0abffb24c7 --- /dev/null +++ b/backend/social-work-app/bin/count_providers.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201 we use print statements for local scripts +""" +Script to count the number of providers in the system by querying the provider table +for all records where the type field equals 'provider'. + +Run from 'backend/compact-connect' like: +bin/count_providers.py --table-name test-provider-table +""" + +import argparse +import sys + +import boto3 +from boto3.dynamodb.conditions import Attr +from botocore.config import Config + + +def count_providers(provider_table) -> int: + """Count all provider records in the table where type='provider'.""" + count = 0 + scanned_count = 0 + + scan_kwargs = { + 'FilterExpression': Attr('type').eq('provider'), + 'Select': 'COUNT', # Only return count, not full items + } + + print('Scanning provider table for records with type="provider"...') + + done = False + start_key = None + + while not done: + if start_key: + scan_kwargs['ExclusiveStartKey'] = start_key + + response = provider_table.scan(**scan_kwargs) + + count += response.get('Count', 0) + scanned_count += response.get('ScannedCount', 0) + + print(f'Found {count} providers so far (scanned {scanned_count} items)...') + + start_key = response.get('LastEvaluatedKey') + done = start_key is None + + print(f'Total providers: {count} (scanned {scanned_count} total items)') + return count + + +def main(): + parser = argparse.ArgumentParser(description='Count providers in the provider table') + parser.add_argument('--table-name', required=True, help='The full provider table name') + + args = parser.parse_args() + + # Initialize DynamoDB resources + dynamodb_config = Config(retries=dict(max_attempts=10)) + dynamodb = boto3.resource('dynamodb', config=dynamodb_config) + + provider_table = dynamodb.Table(args.table_name) + + print(f'Counting providers in table: {args.table_name}\n') + + try: + count = count_providers(provider_table) + print(f'\n✓ Total number of providers: {count}') + sys.exit(0) + except Exception as e: # noqa: BLE001 + print(f'\n✗ Error counting providers: {e}') + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/bin/create_staff_user.py b/backend/social-work-app/bin/create_staff_user.py new file mode 100755 index 0000000000..3bc342432e --- /dev/null +++ b/backend/social-work-app/bin/create_staff_user.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Staff user generation helper script. Run from `backend/compact-connect`. + +Note: This script requires the boto3 library and two environment variables: +USER_POOL_ID=us-east-1_7zzexample +USER_TABLE_NAME=Sandbox-PersistentStack-StaffUsersUsersTableB4F6C7C8-example + +The CLI must also be configured with AWS credentials that have appropriate access to Cognito and DynamoDB +""" + +import json +import os +import sys + +import boto3 +from botocore.exceptions import ClientError + +provider_data_path = os.path.join('lambdas', 'python', 'staff-users') +common_lib_path = os.path.join('lambdas', 'python', 'common') + +sys.path.append(provider_data_path) +sys.path.append(common_lib_path) + +with open('cdk.json') as context_file: + _context = json.load(context_file)['context'] +JURISDICTIONS = _context['jurisdictions'] +COMPACTS = _context['compacts'] + +os.environ['COMPACTS'] = json.dumps(COMPACTS) +os.environ['JURISDICTIONS'] = json.dumps(JURISDICTIONS) +# The environment name has no bearing on the staff user creation process, but we need a value to be set +# for the data model to work. +os.environ['ENVIRONMENT_NAME'] = 'test' + +# We have to import this after we've mucked with our path and environment +from cc_common.data_model.schema.common import StaffUserStatus # noqa: E402 +from cc_common.data_model.schema.user.record import UserRecordSchema # noqa: E402 + +USER_POOL_ID = os.environ['USER_POOL_ID'] +USER_TABLE_NAME = os.environ['USER_TABLE_NAME'] + + +cognito_client = boto3.client('cognito-idp') +user_table = boto3.resource('dynamodb').Table(USER_TABLE_NAME) +schema = UserRecordSchema() + + +def create_compact_ed_user(*, email: str, compact: str, user_attributes: dict, permanent_password: str | None = None): + sys.stdout.write(f"Creating Compact ED user, '{email}', in {compact}\n") + sub = create_cognito_user(email=email, permanent_password=permanent_password) + user_table.put_item( + Item=schema.dump( + { + 'type': 'user', + 'userId': sub, + 'status': StaffUserStatus.ACTIVE.value, + 'compact': compact, + 'attributes': user_attributes, + 'permissions': {'actions': {'read', 'admin'}, 'jurisdictions': {}}, + }, + ), + ) + + +def create_board_ed_user( + *, email: str, compact: str, jurisdiction: str, user_attributes: dict, permanent_password: str | None = None +): + sys.stdout.write(f"Creating Board ED user, '{email}', in {compact}/{jurisdiction}\n") + sub = create_cognito_user(email=email, permanent_password=permanent_password) + user_table.put_item( + Item=schema.dump( + { + 'type': 'user', + 'userId': sub, + 'status': StaffUserStatus.ACTIVE.value, + 'compact': compact, + 'attributes': user_attributes, + 'permissions': {'actions': {'read'}, 'jurisdictions': {jurisdiction: {'write', 'admin'}}}, + }, + ), + ) + + +def create_cognito_user(*, email: str, permanent_password: str | None): + """Create a Cognito user with the given email address and password. + + If provided, sets the password as the user's permanent password. Since this circumvents default password policies + (i.e., password reset), this should only be used in testing/sandbox environments. + """ + + def get_sub_from_attributes(user_attributes: list): + for attribute in user_attributes: + if attribute['Name'] == 'sub': + return attribute['Value'] + raise ValueError('Failed to find user sub!') + + try: + # By including the TemporaryPassword on user creation, we avoid creating a user if the desired permanent + # password does not adhere to the password policy. Either no user is created, or a user is created with + # the desired password. + kwargs = {'TemporaryPassword': permanent_password} if permanent_password is not None else {} + user_data = cognito_client.admin_create_user( + UserPoolId=USER_POOL_ID, + Username=email, + UserAttributes=[ + {'Name': 'email', 'Value': email}, + {'Name': 'email_verified', 'Value': 'True'}, + ], + DesiredDeliveryMediums=['EMAIL'], + **kwargs, + ) + + if permanent_password is not None: + cognito_client.admin_set_user_password( + UserPoolId=USER_POOL_ID, Username=email, Password=permanent_password, Permanent=True + ) + return get_sub_from_attributes(user_data['User']['Attributes']) + + except ClientError as e: + if e.response['Error']['Code'] == 'UsernameExistsException': + sys.stdout.write('User already exists, returning existing user') + user_data = cognito_client.admin_get_user(UserPoolId=USER_POOL_ID, Username=email) + return get_sub_from_attributes(user_data['UserAttributes']) + if e.response['Error']['Code'] == 'InvalidPasswordException': + sys.stderr.write(f'Invalid password: {e.response["Error"]["Message"]}') + sys.exit(2) + else: + sys.stderr.write(f'Failed to create user: {e.response["Error"]["Message"]}') + sys.exit(2) + + +if __name__ == '__main__': + from argparse import ArgumentParser + + # Pull compacts and jurisdictions from cdk.json + with open('cdk.json') as f: + context = json.load(f)['context'] + jurisdictions = context['jurisdictions'] + compacts = context['compacts'] + + parser = ArgumentParser( + description='Create a staff user', + epilog='example: bin/create_staff_user.py -e justin@example.org -f williams -g justin -t board-ed -c socw -j oh', # noqa: E501 line-too-long + ) + parser.add_argument('-e', '--email', help="The new user's email address", required=True) + parser.add_argument('-f', '--family-name', help="The new user's family name", required=True) + parser.add_argument('-g', '--given-name', help="The new user's given name", required=True) + parser.add_argument('-t', '--type', help="The new user's type", required=True, choices=['compact-ed', 'board-ed']) + parser.add_argument('-c', '--compact', help="The new user's compact", required=True, choices=compacts) + parser.add_argument( + '-j', + '--jurisdiction', + help="The new user's jurisdiction, required for board users", + required=False, + choices=jurisdictions, + ) + + args = parser.parse_args() + + _user_attributes = {'email': args.email, 'familyName': args.family_name, 'givenName': args.given_name} + + match args.type: + case 'compact-ed': + create_compact_ed_user(email=args.email, compact=args.compact, user_attributes=_user_attributes) + case 'board-ed': + if not args.jurisdiction: + sys.stdout.write('jurisdiction is required for board-ed users.') + sys.exit(2) + create_board_ed_user( + email=args.email, + compact=args.compact, + jurisdiction=args.jurisdiction, + user_attributes=_user_attributes, + ) + case _: + sys.stdout.write(f'Unsupported user type: {args.type}') + sys.exit(2) diff --git a/backend/social-work-app/bin/download_oas30.py b/backend/social-work-app/bin/download_oas30.py new file mode 100755 index 0000000000..8869da8f01 --- /dev/null +++ b/backend/social-work-app/bin/download_oas30.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +""" +Download OpenAPI v3 specifications from AWS API Gateway for both StateApi and LicenseApi. + +This script uses boto3 and CLI-configured credentials to find the APIs and download +their OpenAPI specifications to the appropriate local files. +""" + +import argparse +import json +import os +import sys + +import boto3 +from botocore.exceptions import ClientError + + +def find_api_by_name(apigateway_client, api_name: str) -> str: + """ + Find an API Gateway API by name and return its ID. + + :param apigateway_client: Boto3 API Gateway client + :param api_name: Name of the API to find + :return: API ID if found, None otherwise + """ + try: + response = apigateway_client.get_rest_apis() + for api in response['items']: + if api['name'] == api_name: + return api['id'] + raise RuntimeError(f'API {api_name} not found') + except ClientError as e: + raise RuntimeError(f'Error finding API {api_name}: {e}') from e + + +def get_api_stages(apigateway_client, api_id: str) -> list[str]: + """ + Get all stages for an API. + + :param apigateway_client: Boto3 API Gateway client + :param api_id: ID of the API + :return: List of stage names + """ + try: + response = apigateway_client.get_stages(restApiId=api_id) + return [stage['stageName'] for stage in response['item']] + except ClientError as e: + raise RuntimeError(f'Error getting stages for API {api_id}: {e}') from e + + +def download_openapi_spec(apigateway_client, api_id: str, stage_name: str) -> dict: + """ + Download OpenAPI v3 specification from API Gateway. + + :param apigateway_client: Boto3 API Gateway client + :param api_id: ID of the API + :param stage_name: Name of the stage + :return: OpenAPI specification as dict + """ + try: + response = apigateway_client.get_export( + restApiId=api_id, stageName=stage_name, exportType='oas30', accepts='application/json' + ) + + # Parse the response body + spec_json = response['body'].read().decode('utf-8') + return json.loads(spec_json) + + except ClientError as e: + raise RuntimeError(f'Error downloading OpenAPI spec for API {api_id}, stage {stage_name}: {e}') from e + except json.JSONDecodeError as e: + raise RuntimeError(f'Error parsing OpenAPI spec JSON for API {api_id}, stage {stage_name}: {e}') from e + + +def save_spec_to_file(spec: dict, file_path: str) -> None: + """ + Save OpenAPI specification to a JSON file. + + :param spec: OpenAPI specification as dict + :param file_path: Path to save the file + """ + try: + # Ensure the directory exists + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + with open(file_path, 'w') as f: + json.dump(spec, f, indent=2) + f.write('\n') + + except Exception as e: + raise RuntimeError(f'Error saving spec to {file_path}: {e}') from e + + +def update_server_urls(spec: dict, api_name: str) -> None: + """ + Update the server URLs to use consistent beta domains. + + :param spec: OpenAPI specification as dict + :param api_name: Name of the API to determine the correct URL + """ + if 'servers' not in spec: + return + + # Determine the correct base URL based on API name + if api_name == 'StateApi': + base_url = 'https://state-api.beta.compactconnect.org' + elif api_name == 'LicenseApi': + base_url = 'https://api.beta.compactconnect.org' + elif api_name == 'SearchApi': + base_url = 'https://search.beta.compactconnect.org' + else: + # Keep original URL if API name is not recognized + return + + # Update all server URLs + for server in spec['servers']: + if 'url' in server: + server['url'] = base_url + + sys.stdout.write(f'Updated server URLs to use: {base_url}\n') + + +def download_api_spec(api_name: str, output_path: str) -> None: + """ + Download OpenAPI specification for a specific API. + + :param api_name: Name of the API to download + :param output_path: Path to save the specification + """ + apigateway_client = boto3.client('apigateway') + + # Find the API + api_id = find_api_by_name(apigateway_client, api_name) + + sys.stdout.write(f'Found API "{api_name}" with ID: {api_id}\n') + + # Get stages + stages = get_api_stages(apigateway_client, api_id) + + # Use the first stage (assuming single stage) + if len(stages) != 1: + raise RuntimeError('API has an unexpected number of stages!') + stage_name = stages[0] + sys.stdout.write(f'Using stage: {stage_name}\n') + + # Download the specification + spec = download_openapi_spec(apigateway_client, api_id, stage_name) + + # Update server URLs to use consistent beta domains + update_server_urls(spec, api_name) + + # Save to file + save_spec_to_file(spec, output_path) + + +def main(): + parser = argparse.ArgumentParser(description='Download OpenAPI v3 specifications from AWS API Gateway') + parser.add_argument('--state-api-only', action='store_true', help='Download only the StateApi specification') + parser.add_argument('--license-api-only', action='store_true', help='Download only the LicenseApi specification') + parser.add_argument('--search-api-only', action='store_true', help='Download only the SearchApi specification') + + args = parser.parse_args() + + # Define paths relative to the script location + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_dir = os.path.dirname(script_dir) + + # Define output paths + state_api_path = os.path.join(workspace_dir, 'docs', 'api-specification', 'latest-oas30.json') + license_api_path = os.path.join(workspace_dir, 'docs', 'internal', 'api-specification', 'latest-oas30.json') + search_api_path = os.path.join(workspace_dir, 'docs', 'search-internal', 'api-specification', 'latest-oas30.json') + + # Download StateApi (external API) + if not args.license_api_only and not args.search_api_only: + sys.stdout.write('\n=== Downloading StateApi specification ===\n') + download_api_spec('StateApi', state_api_path) + + # Download LicenseApi (internal API) + if not args.state_api_only and not args.search_api_only: + sys.stdout.write('\n=== Downloading LicenseApi specification ===\n') + download_api_spec('LicenseApi', license_api_path) + + # Download SearchApi (search internal API) + if not args.state_api_only and not args.license_api_only: + sys.stdout.write('\n=== Downloading SearchApi specification ===\n') + download_api_spec('SearchApi', search_api_path) + + sys.stdout.write('\nAll specifications downloaded successfully!\n') + sys.exit(0) + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/bin/generate_mock_license_csv_upload_file.py b/backend/social-work-app/bin/generate_mock_license_csv_upload_file.py new file mode 100755 index 0000000000..c1163a3057 --- /dev/null +++ b/backend/social-work-app/bin/generate_mock_license_csv_upload_file.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +# Quick script to generate some mock data in CSV or JSON format for test environments +# The CSV file can be uploaded into the system using the bulk upload process. +# The JSON file can be used for API testing or other purposes. +# +# Run from 'backend/compact-connect' like: +# bin/generate_mock_license_csv_upload_file.py --count 100 --compact socw --jurisdiction al --format csv +# bin/generate_mock_license_csv_upload_file.py --count 100 --compact socw --jurisdiction al --format json +import json +import os +import sys +from csv import DictWriter +from datetime import UTC, datetime, timedelta +from random import choice, randint + +from faker import Faker + +# We have to do some set-up before we can import everything we need +# Add the provider data lambda runtime to our pythonpath +provider_data_path = os.path.join('lambdas', 'python', 'common') +sys.path.append(provider_data_path) + +with open('cdk.json') as context_file: + _context = json.load(context_file)['context'] +JURISDICTIONS = _context['jurisdictions'] +COMPACTS = _context['compacts'] +LICENSE_TYPES = {compact: [t['name'] for t in types] for compact, types in _context['license_types'].items()} + + +os.environ['COMPACTS'] = json.dumps(COMPACTS) +os.environ['JURISDICTIONS'] = json.dumps(JURISDICTIONS) +# The environment name has no bearing on the staff user creation process, but we need a value to be set +# for the data model to work. +os.environ['ENVIRONMENT_NAME'] = 'test' + +from cc_common.data_model.schema.common import ActiveInactiveStatus, CompactEligibilityStatus # noqa: E402 +from cc_common.data_model.schema.license.api import LicensePostRequestSchema # noqa: E402 + +# This will be overridden based on command line arguments +name_faker = None +faker = Faker(['en_US']) + +schema = LicensePostRequestSchema() + +FIELDS = ( + 'ssn', + 'licenseNumber', + 'licenseType', + 'licenseStatus', + 'licenseStatusName', + 'compactEligibility', + 'givenName', + 'middleName', + 'familyName', + 'suffix', + 'dateOfIssuance', + 'dateOfRenewal', + 'dateOfExpiration', + 'dateOfBirth', + 'homeAddressStreet1', + 'homeAddressStreet2', + 'homeAddressCity', + 'homeAddressState', + 'homeAddressPostalCode', + 'emailAddress', + 'phoneNumber', +) + + +def generate_mock_data_file( + count, + *, + compact: str, + jurisdiction: str = None, + file_format: str = 'csv', + ssn_prefix: str, + allow_inactive: bool = False, +): + """Generate mock license data and write it to a file in the specified format.""" + if file_format == 'csv': + generate_mock_csv_file( + count, compact=compact, jurisdiction=jurisdiction, ssn_prefix=ssn_prefix, allow_inactive=allow_inactive + ) + elif file_format == 'json': + generate_mock_json_file( + count, compact=compact, jurisdiction=jurisdiction, ssn_prefix=ssn_prefix, allow_inactive=allow_inactive + ) + else: + raise ValueError(f'Unsupported format: {file_format}') + + +def generate_mock_csv_file( + count, *, compact: str, jurisdiction: str = None, ssn_prefix: str, allow_inactive: bool = False +): + """Generate mock license data in CSV format.""" + filename = f'{compact}-{jurisdiction}-mock-data.csv' + with open(filename, 'w', encoding='utf-8') as data_file: + writer = DictWriter(data_file, fieldnames=FIELDS) + writer.writeheader() + for row in generate_license_records( + count, compact=compact, jurisdiction=jurisdiction, ssn_prefix=ssn_prefix, allow_inactive=allow_inactive + ): + writer.writerow(row) + + +def generate_mock_json_file( + count, *, compact: str, jurisdiction: str = None, ssn_prefix: str, allow_inactive: bool = False +): + """Generate mock license data in JSON format.""" + licenses = list( + generate_license_records( + count, compact=compact, jurisdiction=jurisdiction, ssn_prefix=ssn_prefix, allow_inactive=allow_inactive + ) + ) + # Remove any fields that are None or empty strings, since API doesn't accept them + licenses = [{k: v for k, v in license_record.items() if v is not None and v != ''} for license_record in licenses] + + filename = f'{compact}-{jurisdiction}-mock-data.json' + with open(filename, 'w', encoding='utf-8') as data_file: + json.dump(licenses, data_file, indent=2, ensure_ascii=False, default=str) + + sys.stdout.write(f'Generated {len(licenses)} license records in {filename}') + + +def generate_license_records( + count, *, compact: str, jurisdiction: str = None, ssn_prefix: str, allow_inactive: bool = False +): + """Generate a specified number of mock license records.""" + i = 0 + while i < count: + yield get_mock_license( + i, compact=compact, jurisdiction=jurisdiction, ssn_prefix=ssn_prefix, allow_inactive=allow_inactive + ) + i += 1 + if i % 1000 == 0: + sys.stdout.write(f'Generated {i} records\n') + sys.stdout.write(f'Final record count: {i}\n') + + +def get_mock_license( + i: int, *, compact: str, jurisdiction: str = None, ssn_prefix: str, allow_inactive: bool = False +) -> dict: + if jurisdiction is None: + jurisdiction = faker.state_abbr().lower() + license_data = { + # |Zero padded 4 digit int| + 'ssn': f'{ssn_prefix}-{(i // 10_000) % 100:02}-{(i % 10_000):04}', + # licenseNumber is required + 'licenseNumber': generate_mock_license_number(), + 'licenseType': choice(LICENSE_TYPES[compact]), + 'givenName': name_faker.first_name(), + 'middleName': name_faker.first_name(), + 'familyName': name_faker.last_name(), + # A few will have a suffix + 'suffix': name_faker.suffix() if randint(0, 10) == 10 else None, + 'homeAddressStreet1': faker.street_address(), + # Flip a coin, add secondary address line? + 'homeAddressStreet2': faker.secondary_address() if choice([True, False]) else None, + 'homeAddressCity': faker.city(), + # Some have email addresses, some don't + 'emailAddress': faker.email() if choice([True, False]) else None, + 'phoneNumber': f'+1{randint(1_000_000_000, 9_999_999_999)}', + } + license_data = _set_address_state(license_data, jurisdiction) + license_data = _set_dates_and_statuses(license_data, allow_inactive=allow_inactive) + return schema.dump(license_data) + + +def generate_mock_license_number() -> str: + license_str = '' + size = randint(5, 20) + + for _ in range(size): + if choice([True, False]): + if randint(0, 9) > 2: + license_str += chr(randint(ord('A'), ord('Z'))) + else: + license_str += '-' + else: + license_str += str(randint(0, 9)) + return license_str + + +def _set_address_state(license_data: dict, jurisdiction: str) -> dict: + license_data.update( + { + 'homeAddressState': jurisdiction, + 'homeAddressPostalCode': faker.zipcode_in_state(state_abbr=jurisdiction.upper()), + }, + ) + return license_data + + +def _set_dates_and_statuses(license_data: dict, allow_inactive: bool = False) -> dict: + date_of_birth = faker.date_of_birth() + # Issuance between when they were ~22 and ~40 years old, but still in the past + now = datetime.now(tz=UTC).date() + date_of_issuance = min(date_of_birth + timedelta(days=randint(22 * 365, 40 * 365)), now - timedelta(days=1)) + + # By default, all licenses are active and eligible + if not allow_inactive: + is_active = True + # We'll have renewal be within the last year, but on or after issuance. + date_of_renewal = max(now - timedelta(days=randint(1, 365)), date_of_issuance) + # Expiry, one year from renewal + date_of_expiry = date_of_renewal + timedelta(days=365) + compact_eligibility = CompactEligibilityStatus.ELIGIBLE + # If we are mixing in inactive records, for simplicity, we'll assume that under-70-year-olds are active, + # over are inactive. + elif date_of_birth + 70 * timedelta(days=365) > now: + is_active = True + # We'll have renewal be within the last year, but on or after issuance. + date_of_renewal = max(now - timedelta(days=randint(1, 365)), date_of_issuance) + # Expiry, one year from renewal + date_of_expiry = date_of_renewal + timedelta(days=365) + # Licensees can only be eligible if they are also active + compact_eligibility = ( + CompactEligibilityStatus.ELIGIBLE if choice([True, False]) else CompactEligibilityStatus.INELIGIBLE + ) + else: + is_active = False + # They renewed at some point in the last 20 years, but on or after their issuance date. + date_of_renewal = max(date_of_issuance, now - randint(1, 20) * timedelta(days=365)) + # Their license expired a year after renewal, but no later than yesterday. + date_of_expiry = min(date_of_renewal + timedelta(days=365), now - timedelta(days=1)) + compact_eligibility = CompactEligibilityStatus.INELIGIBLE + license_data.update( + { + 'licenseStatus': ActiveInactiveStatus.ACTIVE if is_active else ActiveInactiveStatus.INACTIVE, + 'compactEligibility': compact_eligibility, + 'dateOfBirth': date_of_birth, + 'dateOfIssuance': date_of_issuance, + 'dateOfRenewal': date_of_renewal, + 'dateOfExpiration': date_of_expiry, + }, + ) + + # Flip a coin, include a license status name? + active_status_names = ['ACTIVE', 'ACTIVE_IN_RENEWAL'] + inactive_status_names = ['INACTIVE', 'SUSPENDED', 'EXPIRED', 'REVOKED', 'ON_PROBATION'] + if choice([True, False]): + license_data['licenseStatusName'] = choice(active_status_names if is_active else inactive_status_names) + return license_data + + +def _initialize_name_faker(include_international_names: bool = False): + """Initialize the name_faker based on localization preference.""" + global name_faker + if include_international_names: + # We'll grab three different localizations to provide a variety of names/characters + name_faker = Faker(['en_US', 'ja_JP', 'es_MX']) + else: + # Only use US English names by default + name_faker = Faker(['en_US']) + + +if __name__ == '__main__': + import logging + from argparse import ArgumentParser + + logging.basicConfig() + + parser = ArgumentParser(description='Generate mock license data in CSV or JSON format') + parser.add_argument('--count', help='The count of licenses to generate', required=True, type=int) + parser.add_argument('--compact', help='The compact these licenses will be for', required=True, choices=COMPACTS) + parser.add_argument( + '-j', + '--jurisdiction', + help='The jurisdiction these licenses will be for', + required=False, + choices=JURISDICTIONS, + ) + parser.add_argument( + '--format', + help='Output format for the generated data', + choices=['csv', 'json'], + default='csv', + required=False, + ) + parser.add_argument( + '--include-international-names', + help='Include international names (Japanese, Mexican) in addition to US English (default: US names only)', + action='store_true', + required=False, + ) + parser.add_argument( + '--ssn-prefix', + help='Three-digit prefix for SSN generation (default: 000)', + default='000', + required=False, + ) + parser.add_argument( + '--allow-inactive', + help='Allow some licenses to be inactive and ineligible (default: all active and eligible)', + action='store_true', + required=False, + ) + + args = parser.parse_args() + if len(args.ssn_prefix) != 3 or not args.ssn_prefix.isdigit(): + parser.error('--ssn-prefix must be exactly 3 digits (000-999).') + + _initialize_name_faker(args.include_international_names) + + generate_mock_data_file( + args.count, + compact=args.compact, + jurisdiction=args.jurisdiction, + file_format=args.format, + ssn_prefix=args.ssn_prefix, + allow_inactive=args.allow_inactive, + ) diff --git a/backend/social-work-app/bin/purge_provider_table.py b/backend/social-work-app/bin/purge_provider_table.py new file mode 100755 index 0000000000..6bfb323ef7 --- /dev/null +++ b/backend/social-work-app/bin/purge_provider_table.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +This script is used to purge the provider table of all items. +It is intended to be run from the command line and will delete all items from the table. Obviously, we never want to run +this in production. + +To run this script, set the PROVIDER_TABLE_NAME environment variable to the name of the table you want to purge. + +Example: +PROVIDER_TABLE_NAME=compact-connect-provider-table-dev ./bin/purge_provider_table.py +""" + +import logging +import os + +import boto3 + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def get_table_name() -> str: + """Get the table name from environment variable.""" + table_name = os.environ.get('PROVIDER_TABLE_NAME') + if not table_name: + raise ValueError('Please set PROVIDER_TABLE_NAME environment variable') + + if table_name.startswith('Prod'): + raise ValueError('This script should not be run against production tables. Aborting.') + return table_name + + +def purge_table(table_name: str) -> None: + """Delete all items from the specified DynamoDB table.""" + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(table_name) + + # Initialize counters + deleted_count = 0 + error_count = 0 + + # Scan the table + scan_pagination = {} + while True: + try: + response = table.scan(**scan_pagination) + items = response.get('Items', []) + + if not items: + logger.info('No more items to delete') + break + + logger.info('Found %d items to delete in current batch', len(items)) + + # Delete each item + for item in items: + try: + key = {'pk': item['pk'], 'sk': item['sk']} + table.delete_item(Key=key) + deleted_count += 1 + if deleted_count % 100 == 0: + logger.info('Deleted %d items so far', deleted_count) + except Exception as e: # noqa: BLE001 + logger.error('Error deleting item %s: %s', key, str(e)) + error_count += 1 + + # Check if we need to continue pagination + last_evaluated_key = response.get('LastEvaluatedKey') + if not last_evaluated_key: + break + + scan_pagination = {'ExclusiveStartKey': last_evaluated_key} + + except Exception as e: # noqa: BLE001 + logger.error('Error during scan: %s', str(e)) + error_count += 1 + break + + # Log final statistics + logger.info('Purge completed. Successfully deleted %d items', deleted_count) + if error_count > 0: + logger.warning('Encountered %d errors during purge', error_count) + + +if __name__ == '__main__': + try: + table_name = get_table_name() + logger.info('Starting purge of table: %s', table_name) + purge_table(table_name) + except Exception as e: # noqa: BLE001 + logger.error('Script failed: %s', e) + exit(1) diff --git a/backend/social-work-app/bin/put_ssm_context.sh b/backend/social-work-app/bin/put_ssm_context.sh new file mode 100755 index 0000000000..6e9230b72f --- /dev/null +++ b/backend/social-work-app/bin/put_ssm_context.sh @@ -0,0 +1,25 @@ +# Requires that jq and aws-cli be installed +# +# 1) Copy cdk.context.-example.json to cdk.context.json +# 2) Edit the values for your configuration +# 3) Configure your aws-cli to connect to your deployment AWS account +# 4) Run this script to push your local configuration to SSM for the pipeline to pick up + + +# check which context file to put in SSM using argument, can be prod, beta, test, or deploy +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +# check if the argument is valid +if [ "$1" != "prod" ] && [ "$1" != "beta" ] && [ "$1" != "test" ] && [ "$1" != "deploy" ]; then + echo "Invalid argument: $1" + echo "Usage: $0 " + exit 1 +fi + +# put the context file into SSM +echo "Reading configuration from cdk.context.json (ensure you've copied from cdk.context.$1-example.json)" +val="$(jq '.ssm_context' 0: + pytest_args.append(f'-{"v" * args.verbose}') + + # Run tests for each directory with the correct working directory + for test_dir in TEST_DIRS: + dir_path = APP_DIR / test_dir + if not dir_path.exists(): + sys.stdout.write(f'Warning: Directory {dir_path} does not exist, skipping\n') + continue + + tests_dir = dir_path / 'tests' + if not tests_dir.exists(): + sys.stdout.write(f'Warning: Tests directory {tests_dir} does not exist, skipping\n') + continue + + sys.stdout.write('\n' + '=' * 80 + '\n') + sys.stdout.write(f'Running tests in {test_dir}\n') + sys.stdout.write('=' * 80 + '\n') + + # Save the original working directory and sys.path + original_dir = os.getcwd() + original_path = sys.path.copy() + original_env = os.environ.copy() + + try: + # Change to the test directory + os.chdir(dir_path) + + # Set up PYTHONPATH for common code if needed + if test_dir != 'lambdas/python/common' and 'python' in test_dir: + common_path = APP_DIR / 'lambdas/python/common' + if str(common_path) not in sys.path: + sys.path.insert(0, str(common_path)) + + # Clean up modules before running tests + clean_modules() + + # Run pytest for this directory + test_result = pytest.main(pytest_args) + + # Update exit code if any tests fail + if test_result != 0 and exit_code == 0: + return test_result + + # Restore the original environment + os.environ = original_env # noqa: B003 + + # Thorough module cleanup after each test suite + clean_modules() + + finally: + # Restore the original working directory + os.chdir(original_dir) + + # Restore the original sys.path + sys.path = original_path + finally: + # Stop coverage measurement + cov.stop() + cov.save() + + return exit_code + + +def main(): + parser = ArgumentParser(description='Run all Python tests in a unified pytest session') + parser.add_argument('--report', action='store_true', help='Generate HTML coverage report') + parser.add_argument('--open-report', action='store_true', help='Open HTML coverage report after generation') + parser.add_argument('--fail-under', type=float, default=90, help='Fail if coverage is under this percentage') + parser.add_argument('--verbose', '-v', action='count', default=0, help='Increase verbosity') + args = parser.parse_args() + + cov = get_coverage() + exit_code = run_tests(cov, args) + + # Generate coverage report if requested + if args.report: + sys.stdout.write('\nGenerating coverage report...\n') + cov.html_report() + + if args.open_report: + # Open the coverage report + report_path = APP_DIR / 'coverage' / 'index.html' + webbrowser.open(f'file://{report_path}') + + # Check coverage against threshold + sys.stdout.write('\nCalculating coverage...\n') + coverage_percent = cov.report() + if coverage_percent < args.fail_under: + sys.stdout.write( + f'Coverage {coverage_percent:.2f}% is below the required threshold of {args.fail_under:.2f}%\n' + ) + exit_code = 1 + + if exit_code > 0: + sys.stdout.write('\n================= TESTS FAILED =================\n') + return exit_code + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/backend/social-work-app/bin/run_tests.sh b/backend/social-work-app/bin/run_tests.sh new file mode 100755 index 0000000000..ad8dcb5ee2 --- /dev/null +++ b/backend/social-work-app/bin/run_tests.sh @@ -0,0 +1,129 @@ +#!/bin/bash +set -e + +# Default values +LANGUAGE="all" +REPORT=true +OPEN_REPORT=true +VERBOSE="" +FAIL_UNDER=90 + +# Print usage information +usage() { + echo "Usage: $0 [options]" + echo "Options:" + echo " -l, --language LANG Specify language to test (nodejs, python, all) [default: all]" + echo " -n, --no-report Don't generate coverage report" + echo " -o, --no-open Don't open coverage report in browser" + echo " -v, --verbose Increase python verbosity (can be used multiple times: -vv)" + echo " -f, --fail-under PCT Set minimum python coverage percentage [default: 90]" + echo " -h, --help Display this help message" + exit 1 +} + +# Parse command line arguments +while getopts "l:nov:f:h-:" opt; do + case $opt in + -) + case "${OPTARG}" in + language) + LANGUAGE="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + ;; + no-report) + REPORT=false + ;; + no-open) + OPEN_REPORT=false + ;; + verbose) + VERBOSE="v${VERBOSE}" + ;; + fail-under) + FAIL_UNDER="${!OPTIND}"; OPTIND=$(( $OPTIND + 1 )) + ;; + help) + usage + ;; + *) + echo "Invalid option: --${OPTARG}" >&2 + usage + ;; + esac + ;; + l) + LANGUAGE="$OPTARG" + ;; + n) + REPORT=false + ;; + o) + OPEN_REPORT=false + ;; + v) + VERBOSE="v${VERBOSE}" + ;; + f) + FAIL_UNDER="$OPTARG" + ;; + h) + usage + ;; + \?) + echo "Invalid option: -$OPTARG" >&2 + usage + ;; + esac +done + +# Validate language argument +if [[ "$LANGUAGE" != "nodejs" && "$LANGUAGE" != "python" && "$LANGUAGE" != "all" ]]; then + echo "Error: Language must be 'nodejs', 'python', or 'all'" >&2 + usage +fi + +# Set this to 1 ahead of running tests, so this script will fail if neither node or python tests ran +EXIT=1 + +# Run NodeJS tests if requested +if [[ "$LANGUAGE" == 'nodejs' || "$LANGUAGE" == 'all' ]]; then + echo "Running NodeJS tests..." + ( + cd lambdas/nodejs + yarn test || exit "$?" + if [[ "$REPORT" == true && "$OPEN_REPORT" == true ]]; then + open 'coverage/lcov-report/index.html' + fi + ) || exit "$?" + # If this didn't exit already, we'll set our exit status to success for now + EXIT=0 +fi + +# Run Python tests if requested +if [[ "$LANGUAGE" == 'python' || "$LANGUAGE" == 'all' ]]; then + echo "Running Python tests..." + + # Build Python test arguments + PYTHON_ARGS=() + + # Add report flags + if [[ "$REPORT" == true ]]; then + PYTHON_ARGS+=("--report") + if [[ "$OPEN_REPORT" == true ]]; then + PYTHON_ARGS+=("--open-report") + fi + fi + + # Add verbosity flag + if [[ -n "$VERBOSE" ]]; then + PYTHON_ARGS+=("-${VERBOSE}") + fi + + # Add fail-under threshold + PYTHON_ARGS+=("--fail-under" "$FAIL_UNDER") + + # Run the Python tests + python3 bin/run_python_tests.py "${PYTHON_ARGS[@]}" || exit "$?" + EXIT=0 +fi + +exit "$EXIT" diff --git a/backend/social-work-app/bin/sandbox_bootstrap_staff_users.py b/backend/social-work-app/bin/sandbox_bootstrap_staff_users.py new file mode 100755 index 0000000000..325d8026ea --- /dev/null +++ b/backend/social-work-app/bin/sandbox_bootstrap_staff_users.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Script to bootstrap staff users in a sandbox environment with predefined credentials to simplify testing. +Run this script from `backend/compact-connect`. + +The AWS CLI must be configured with AWS credentials that have appropriate access to Cognito and DynamoDB. +""" + +import os +import sys +from argparse import ArgumentParser + +import sandbox_fetch_aws_resources + +SANDBOX_USER_PASSWORD = 'Test12345678' # noqa: S105 this script is used in sandbox environments only + + +def bootstrap_board_ed_user(compact: str, jurisdiction: str, email_username: str, email_domain: str): + email = f'{email_username}+board-ed-{compact}-{jurisdiction}@{email_domain}' + _user_attributes = { + 'email': email, + 'familyName': f'{compact.upper()}-{jurisdiction.upper()}', + 'givenName': 'TEST BOARD ED', + } + create_staff_user.create_board_ed_user( + email=email, + compact=compact, + jurisdiction=jurisdiction, + user_attributes=_user_attributes, + permanent_password=SANDBOX_USER_PASSWORD, + ) + + +def bootstrap_compact_ed_user(compact: str, email_username: str, email_domain: str): + email = f'{email_username}+compact-ed-{compact}@{email_domain}' + _user_attributes = {'email': email, 'familyName': compact.upper(), 'givenName': 'TEST COMPACT ED'} + create_staff_user.create_compact_ed_user( + email=email, + compact=compact, + user_attributes=_user_attributes, + permanent_password=SANDBOX_USER_PASSWORD, + ) + + +if __name__ == '__main__': + parser = ArgumentParser( + description='Bootstraps a sandbox environment with a static set of staff users using a base email address.', + epilog='example: bin/sandbox_bootstrap_staff_users.py -e justin@example.org', + ) + parser.add_argument('-e', '--email', help='The base email address', required=True) + args = parser.parse_args() + + # Validate email format and split safely + if '@' not in args.email or args.email.count('@') > 1: + sys.stderr.write(f'Invalid email format: {args.email}\n') + sys.exit(1) + + email_parts = args.email.split('@') + email_username = email_parts[0] + email_domain = email_parts[1] + + # Set environment variables required by create_staff_user + _, _, staff_details = sandbox_fetch_aws_resources.fetch_resources() + os.environ['ENVIRONMENT_NAME'] = 'sandbox' + os.environ['USER_TABLE_NAME'] = staff_details['dynamodb_table'] # we want this to fail if the key doesn't exist + os.environ['USER_POOL_ID'] = staff_details['user_pool_id'] # we want this to fail if the key doesn't exist + + # Import create_staff_user after setting environment variables that it expects + import create_staff_user + + bootstrap_board_ed_user(compact='socw', jurisdiction='oh', email_username=email_username, email_domain=email_domain) + bootstrap_compact_ed_user(compact='socw', email_username=email_username, email_domain=email_domain) diff --git a/backend/social-work-app/bin/sandbox_fetch_aws_resources.py b/backend/social-work-app/bin/sandbox_fetch_aws_resources.py new file mode 100755 index 0000000000..79dd221902 --- /dev/null +++ b/backend/social-work-app/bin/sandbox_fetch_aws_resources.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201 we use print statements for local scripts +"""Script to fetch AWS resources names and IDs required for local deployment. Run from `backend/compact-connect`. + +To display in human-readable format: + python fetch_aws_resources.py + +To output in .env format: + python fetch_aws_resources.py --as-env + +The CLI must also be configured with AWS credentials that have appropriate access to Cognito and DynamoDB +""" + +import argparse + +import boto3.session + +# Initialize AWS clients +cognito_client = boto3.client('cognito-idp') +cloudformation_client = boto3.client('cloudformation') + +# Fetch the AWS region +aws_region = boto3.session.Session().region_name + +# List of stack names +STACK_NAMES = [ + 'Sandbox-TransactionMonitoringStack', + 'Sandbox-APIStack', + 'Sandbox-IngestStack', + 'Sandbox-PersistentStack', +] + + +def get_stack_outputs(stack_name): + """Fetch outputs from CloudFormation stack""" + try: + response = cloudformation_client.describe_stacks(StackName=stack_name) + stack = response['Stacks'][0] + return {output['OutputKey']: output['OutputValue'] for output in stack.get('Outputs', [])} + except Exception as e: # noqa: BLE001 + print(f'Error retrieving stack {stack_name}: {e}') + return {} + + +def get_cognito_details(user_pool_id): + """Fetch Cognito User Pool Name and Client ID""" + try: + pool_response = cognito_client.describe_user_pool(UserPoolId=user_pool_id) + user_pool_name = pool_response['UserPool']['Name'] + + client_response = cognito_client.list_user_pool_clients(UserPoolId=user_pool_id) + client_id = ( + client_response['UserPoolClients'][0]['ClientId'] + if client_response['UserPoolClients'] + else 'No Client ID Found' + ) + + return user_pool_name, client_id + except Exception as e: # noqa: BLE001 + print(f'Error retrieving Cognito details for {user_pool_id}: {e}') + return 'Unknown', 'Unknown' + + +def extract_table_name(value): + """Extracts the actual DynamoDB table name from an ARN""" + if value.startswith('arn:aws:dynamodb:'): + return value.split(':')[-1].split('/')[-1] + return value + + +def fetch_resources(): + """Fetch all required AWS resources""" + api_gateway_url = None + provider_details = {} + staff_details = {} + + for stack in STACK_NAMES: + outputs = get_stack_outputs(stack) + + for key, value in outputs.items(): + # API Gateway Endpoint + if 'ApiGateway' in key or 'Endpoint' in key: + if value.startswith('https://') and 'execute-api' in value: + api_gateway_url = value + + # Provider Users (Cognito + DynamoDB) + if 'ProviderUsersGreen' in key: + if 'UserPoolId' in key: + provider_details['user_pool_id'] = value + provider_details['user_pool_name'], provider_details['client_id'] = get_cognito_details(value) + elif 'ProviderUsersGreenLicenseeUserPoolDomainName' in key: + provider_details['login_url'] = value + + # Staff Users (Cognito + DynamoDB) + if 'StaffUsersGreen' in key: + if 'UserPoolId' in key: + staff_details['user_pool_id'] = value + staff_details['user_pool_name'], staff_details['client_id'] = get_cognito_details(value) + elif 'StaffUsersGreenStaffUserPoolDomainName' in key: + staff_details['login_url'] = value + + # Find associated DynamoDB tables + if 'Table' in key: + if 'ProviderTable' in value: + provider_details['dynamodb_table'] = extract_table_name(value) + if 'StaffUsersGreen' in value: + staff_details['dynamodb_table'] = extract_table_name(value) + + return api_gateway_url, provider_details, staff_details + + +def print_human_readable(api_gateway_url, provider_details, staff_details): + """Prints data in a human-readable format""" + print('\n\033[1;34m=== AWS Resource Information ===\033[0m\n') # Blue header + + # Print API Gateway URL + if api_gateway_url: + print(f'\033[1;32mAPI Gateway Endpoint:\033[0m {api_gateway_url}\n') # Green header + + # Print Provider User Pool Details + print('\033[1;36m=== Provider Users ===\033[0m') # Cyan header + if provider_details: + print(f'\033[1mLogin URL:\033[0m {provider_details.get("login_url", "N/A")}') + print(f'\033[1mCognito User Pool Name:\033[0m {provider_details.get("user_pool_name", "N/A")}') + print(f'\033[1mCognito User Pool ID:\033[0m {provider_details.get("user_pool_id", "N/A")}') + print(f'\033[1mClient ID:\033[0m {provider_details.get("client_id", "N/A")}') + print(f'\033[1mDynamoDB Table:\033[0m {provider_details.get("dynamodb_table", "N/A")}\n') + else: + print('No Provider user pool found.\n') + + # Print Staff User Pool Details + print('\033[1;36m=== Staff Users ===\033[0m') # Cyan header + if staff_details: + print(f'\033[1mLogin URL:\033[0m {staff_details.get("login_url", "N/A")}') + print(f'\033[1mCognito User Pool Name:\033[0m {staff_details.get("user_pool_name", "N/A")}') + print(f'\033[1mCognito User Pool ID:\033[0m {staff_details.get("user_pool_id", "N/A")}') + print(f'\033[1mClient ID:\033[0m {staff_details.get("client_id", "N/A")}') + print(f'\033[1mDynamoDB Table:\033[0m {staff_details.get("dynamodb_table", "N/A")}\n') + else: + print('No Staff user pool found.\n') + + +def print_env_format(api_gateway_url, provider_details, staff_details): + """Prints data in .env format""" + provider_login_url = provider_details.get('login_url', 'N/A').removesuffix('/login') + staff_login_url = staff_details.get('login_url', 'N/A').removesuffix('/login') + staff_client_id = staff_details.get('client_id', 'N/A') + provider_client_id = provider_details.get('client_id', 'N/A') + staff_table = staff_details.get('dynamodb_table', 'N/A') + provider_table = provider_details.get('dynamodb_table', 'N/A') + + print(f'VUE_APP_API_STATE_ROOT={api_gateway_url}') + print(f'VUE_APP_API_LICENSE_ROOT={api_gateway_url}') + print(f'VUE_APP_API_USER_ROOT={api_gateway_url}') + print(f'VUE_APP_COGNITO_REGION={aws_region}') + print(f'VUE_APP_COGNITO_AUTH_DOMAIN_STAFF={staff_login_url}') + print(f'VUE_APP_COGNITO_CLIENT_ID_STAFF={staff_client_id}') + print(f'VUE_APP_COGNITO_AUTH_DOMAIN_LICENSEE={provider_login_url}') + print(f'VUE_APP_COGNITO_CLIENT_ID_LICENSEE={provider_client_id}') + print(f'VUE_APP_DYNAMO_TABLE_PROVIDER={provider_table}') + print(f'VUE_APP_DYNAMO_TABLE_STAFF={staff_table}') + + +if __name__ == '__main__': + # Argument parser for --as-env flag + parser = argparse.ArgumentParser(description='Fetch AWS resource details.') + parser.add_argument('--as-env', action='store_true', help='Output in .env format') + args = parser.parse_args() + + # Fetch resources + api_gateway_url, provider_details, staff_details = fetch_resources() + + # Output in the requested format + if args.as_env: + print_env_format(api_gateway_url, provider_details, staff_details) + else: + print_human_readable(api_gateway_url, provider_details, staff_details) diff --git a/backend/social-work-app/bin/sync_deps.sh b/backend/social-work-app/bin/sync_deps.sh new file mode 100755 index 0000000000..d3bfea152e --- /dev/null +++ b/backend/social-work-app/bin/sync_deps.sh @@ -0,0 +1,32 @@ +( + cd lambdas/nodejs + yarn install +) + +pip-sync \ + requirements-dev.txt \ + requirements.txt \ + lambdas/python/cognito-backup/requirements-dev.txt \ + lambdas/python/cognito-backup/requirements.txt \ + lambdas/python/compact-configuration/requirements-dev.txt \ + lambdas/python/compact-configuration/requirements.txt \ + lambdas/python/common/requirements-dev.txt \ + lambdas/python/common/requirements.txt \ + lambdas/python/custom-resources/requirements-dev.txt \ + lambdas/python/custom-resources/requirements.txt \ + lambdas/python/data-events/requirements-dev.txt \ + lambdas/python/data-events/requirements.txt \ + lambdas/python/disaster-recovery/requirements-dev.txt \ + lambdas/python/disaster-recovery/requirements.txt \ + lambdas/python/provider-data-v1/requirements-dev.txt \ + lambdas/python/provider-data-v1/requirements.txt \ + lambdas/python/search/requirements-dev.txt \ + lambdas/python/search/requirements.txt \ + lambdas/python/staff-user-pre-token/requirements-dev.txt \ + lambdas/python/staff-user-pre-token/requirements.txt \ + lambdas/python/staff-users/requirements-dev.txt \ + lambdas/python/staff-users/requirements.txt +# We have to manage the purchases lambda Python environment separately +# because it is held back to an older version than the rest of the project +# lambdas/python/purchases/requirements-dev.txt \ +# lambdas/python/purchases/requirements.txt \ diff --git a/backend/social-work-app/bin/trim_oas30.py b/backend/social-work-app/bin/trim_oas30.py new file mode 100755 index 0000000000..53d80cc1af --- /dev/null +++ b/backend/social-work-app/bin/trim_oas30.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""A quick convenience script for trimming auto-generated schema to supported API paths""" + +import argparse +import json +import os +from collections import OrderedDict + + +def strip_sort_paths(oas30: dict) -> dict: + paths = oas30['paths'] + new_paths = OrderedDict() + trimmed_path_keys = sorted([key for key in paths.keys() if key.startswith('/v1/')]) + for path_key in trimmed_path_keys: + new_paths[path_key] = paths[path_key] + oas30['paths'] = new_paths + return oas30 + + +def strip_options_endpoints(oas30: dict) -> dict: + """ + The OPTIONS endpoints add a lot of noise to the spec and are not important to developers, so we'll omit them. + """ + for path_key, path_value in oas30['paths'].items(): + oas30['paths'][path_key] = { + method_key: method_value for method_key, method_value in path_value.items() if method_key != 'options' + } + # Remove now empty paths + oas30['paths'] = {path_key: path_value for path_key, path_value in oas30['paths'].items() if path_value} + return oas30 + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Trim OpenAPI specification to supported API paths') + parser.add_argument( + '-i', '--internal', action='store_true', help='Use internal API specification files instead of regular ones' + ) + parser.add_argument('-s', '--search', action='store_true', help='Use search API specification files') + + args = parser.parse_args() + + # Get script directory and workspace directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_dir = os.path.dirname(script_dir) + + # Determine the base directory based on the flags + if args.search: + base_dir = os.path.join('docs', 'search-internal', 'api-specification') + elif args.internal: + base_dir = os.path.join('docs', 'internal', 'api-specification') + else: + base_dir = os.path.join('docs', 'api-specification') + file_path = os.path.join(workspace_dir, base_dir, 'latest-oas30.json') + + with open(file_path) as f: + original_spec = json.load(f) + + new_spec = strip_sort_paths(original_spec) + new_spec = strip_options_endpoints(new_spec) + + with open(file_path, 'w') as f: + json.dump(new_spec, f, indent=2) + f.write('\n') diff --git a/backend/social-work-app/bin/update_api_docs.sh b/backend/social-work-app/bin/update_api_docs.sh new file mode 100755 index 0000000000..5f8537b9e2 --- /dev/null +++ b/backend/social-work-app/bin/update_api_docs.sh @@ -0,0 +1,205 @@ +#!/bin/bash + +# Update API documentation workflow +# Downloads, trims, and updates Postman collections for StateApi, LicenseApi, and SearchApi + +set -e # Exit immediately if any command fails + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if a command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to check if required tools are installed +check_requirements() { + print_status "Checking requirements..." + + local missing_tools=() + + if ! command_exists python3; then + missing_tools+=("python3") + fi + + if ! command_exists openapi2postmanv2; then + missing_tools+=("openapi2postmanv2") + fi + + if ! command_exists aws; then + missing_tools+=("aws") + fi + + if [ ${#missing_tools[@]} -ne 0 ]; then + print_error "Missing required tools: ${missing_tools[*]}" + print_error "Please install missing tools and try again." + exit 1 + fi + + # Check AWS credentials + if ! aws sts get-caller-identity >/dev/null 2>&1; then + print_error "AWS credentials not configured or invalid" + print_error "Please run 'aws configure' or set up your credentials" + exit 1 + fi + + print_success "All requirements satisfied" +} + +# Function to download API specifications +download_specs() { + print_status "Downloading API specifications..." + + if ! python3 bin/download_oas30.py; then + print_error "Failed to download API specifications" + exit 1 + fi + + print_success "API specifications downloaded successfully" +} + +# Function to trim API specifications +trim_specs() { + print_status "Trimming API specifications..." + + # Trim regular API spec + print_status "Trimming StateApi specification..." + if ! python3 bin/trim_oas30.py; then + print_error "Failed to trim StateApi specification" + exit 1 + fi + print_success "StateApi specification trimmed" + + # Trim internal API spec + print_status "Trimming LicenseApi specification..." + if ! python3 bin/trim_oas30.py --internal; then + print_error "Failed to trim LicenseApi specification" + exit 1 + fi + print_success "LicenseApi specification trimmed" + + # Trim search API spec + print_status "Trimming SearchApi specification..." + if ! python3 bin/trim_oas30.py --search; then + print_error "Failed to trim SearchApi specification" + exit 1 + fi + print_success "SearchApi specification trimmed" +} + +# Function to update Postman collections +update_postman() { + print_status "Updating Postman collections..." + + # Update regular Postman collection + print_status "Updating StateApi Postman collection..." + if ! python3 bin/update_postman_collection.py; then + print_error "Failed to update StateApi Postman collection" + exit 1 + fi + print_success "StateApi Postman collection updated" + + # Update internal Postman collection + print_status "Updating LicenseApi Postman collection..." + if ! python3 bin/update_postman_collection.py --internal; then + print_error "Failed to update LicenseApi Postman collection" + exit 1 + fi + print_success "LicenseApi Postman collection updated" + + # Update search Postman collection + print_status "Updating SearchApi Postman collection..." + if ! python3 bin/update_postman_collection.py --search; then + print_error "Failed to update SearchApi Postman collection" + exit 1 + fi + print_success "SearchApi Postman collection updated" +} + +# Function to verify files exist +verify_files() { + print_status "Verifying generated files..." + + local files=( + "docs/api-specification/latest-oas30.json" + "docs/internal/api-specification/latest-oas30.json" + "docs/search-internal/api-specification/latest-oas30.json" + "docs/postman/postman-collection.json" + "docs/internal/postman/postman-collection.json" + "docs/search-internal/postman/postman-collection.json" + ) + + for file in "${files[@]}"; do + if [ ! -f "$file" ]; then + print_error "Required file not found: $file" + exit 1 + fi + done + + print_success "All required files verified" +} + +# Main execution +main() { + echo "==========================================" + echo " API Documentation Update Workflow" + echo "==========================================" + echo + + print_status "Starting API documentation update workflow..." + echo + + # Execute workflow steps + check_requirements + echo + + download_specs + echo + + trim_specs + echo + + update_postman + echo + + verify_files + echo + + print_success "API documentation update workflow completed successfully!" + echo + print_status "Updated files:" + echo " - docs/api-specification/latest-oas30.json" + echo " - docs/internal/api-specification/latest-oas30.json" + echo " - docs/search-internal/api-specification/latest-oas30.json" + echo " - docs/postman/postman-collection.json" + echo " - docs/internal/postman/postman-collection.json" + echo " - docs/search-internal/postman/postman-collection.json" +} + +# Handle script interruption +trap 'print_error "Script interrupted by user"; exit 1' INT TERM + +# Run main function +main "$@" diff --git a/backend/social-work-app/bin/update_postman_collection.py b/backend/social-work-app/bin/update_postman_collection.py new file mode 100755 index 0000000000..1ffe060a2a --- /dev/null +++ b/backend/social-work-app/bin/update_postman_collection.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Reads a new `latest-oas30.json` file, uses the cli tool, `openapi2postmanv2`, to convert it to a Postman collection, +then merges the new collection with the existing Postman collection and fixes the auth data. + +Note: This script requires the openapi2postmanv2 CLI tool to be installed. +You can install it with: npm install -g openapi-to-postmanv2 +""" + +import argparse +import json +import os +import subprocess +import sys +import traceback +from typing import Any + + +def generate_postman_collection(openapi_path: str, output_path: str): + """Generate a new Postman collection from OpenAPI spec using openapi2postmanv2.""" + try: + # Since this is just a CLI tool, run locally on data we trust, we will trust the subprocess call + subprocess.run( # noqa: S603 + ['openapi2postmanv2', '-s', openapi_path, '-o', output_path], # noqa: S607 + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + sys.stderr.write(f'Failed to generate Postman collection: {e.stderr}\n') + sys.exit(1) + except FileNotFoundError: + sys.stderr.write('openapi2postmanv2 not found. Please install it with: npm install -g openapi-to-postmanv2\n') + sys.exit(1) + + +def is_folder(item: dict[str, Any]) -> bool: + """Check if an item is a folder (has only name and item fields).""" + return set(item.keys()) == {'name', 'item'} + + +def is_http_method(item: dict[str, Any]) -> bool: + """Check if an item represents an HTTP method (has either a request with method or direct method field).""" + return 'request' in item + + +def remove_incorrect_auth(collection: dict[str, Any]): + """Recursively remove incorrect auth fields from HTTP methods.""" + + def process_items(items: list[dict[str, Any]]): + for item in items: + if is_http_method(item): + sys.stdout.write(f'HTTP method: {item["name"]}\n') + # Remove incorrect auth field if it exists and is of type 'apikey' + request = item['request'] + if request.get('auth') and request['auth'].get('type') == 'apikey': + sys.stdout.write('Removing auth field\n') + # Removing auth will cause the request to inherit the parent folder's auth + del request['auth'] + if 'auth' in request and request['auth'] is None: + sys.stdout.write('Setting no auth\n') + request['auth'] = {'type': 'noauth'} + elif 'item' in item: + # Recursively process nested items + process_items(item['item']) + + process_items(collection['item']) + + +def find_folder_by_name(items: list[dict[str, Any]], name: str) -> dict[str, Any]: + """Find a folder by name in the items list.""" + for item in items: + if item.get('name') == name: + return item + if 'item' in item: + result = find_folder_by_name(item['item'], name) + if result: + return result + return None + + +def find_request_by_path(items: list[dict[str, Any]], path_fragment: str) -> dict[str, Any]: + """Find a request by a fragment of its path.""" + for item in items: + if 'request' in item and 'name' in item and path_fragment in item['name']: + return item + if 'item' in item: + result = find_request_by_path(item['item'], path_fragment) + if result: + return result + return None + + +def preserve_bulk_upload_script(new_collection: dict[str, Any], existing_collection: dict[str, Any]): + """Preserve the script content in the GET bulk-upload request.""" + # Find the bulk-upload request in both collections + existing_bulk_upload = find_request_by_path(existing_collection['item'], '/licenses/bulk-upload') + new_bulk_upload = find_request_by_path(new_collection['item'], '/licenses/bulk-upload') + + if existing_bulk_upload and new_bulk_upload: + # Check if the existing request has a test script + if 'event' in existing_bulk_upload: + for event in existing_bulk_upload['event']: + if event.get('listen') == 'test' and 'script' in event: + # Copy the script to the new request + sys.stdout.write('Preserving bulk-upload test script\n') + + # Ensure the new request has an event array + if 'event' not in new_bulk_upload: + new_bulk_upload['event'] = [] + + # Check if the new request already has a test event + test_event_exists = False + for i, event in enumerate(new_bulk_upload['event']): + if event.get('listen') == 'test': + # Replace the existing test script + test_event_exists = True + new_bulk_upload['event'][i] = next( + (e for e in existing_bulk_upload['event'] if e.get('listen') == 'test'), event + ) + break + + # If no test event exists, add it + if not test_event_exists: + new_bulk_upload['event'].append( + next(e for e in existing_bulk_upload['event'] if e.get('listen') == 'test') + ) + break + + +def copy_upload_document_request(new_collection: dict[str, Any], existing_collection: dict[str, Any]): + """Copy the Upload Document request from the existing collection to the new one.""" + # Find the Upload Document request in the existing collection + upload_document = next( + (item for item in existing_collection['item'] if item.get('name') == 'Upload Document'), None + ) + + if upload_document: + sys.stdout.write('Copying Upload Document request\n') + + # Check if the request already exists in the new collection + existing_upload_document = next( + (item for item in new_collection['item'] if item.get('name') == 'Upload Document'), None + ) + + if existing_upload_document: + # Replace the existing request + for i, item in enumerate(new_collection['item']): + if item.get('name') == 'Upload Document': + new_collection['item'][i] = upload_document + break + else: + # Add the request to the new collection + new_collection['item'].append(upload_document) + + +def merge_collections(new_collection: dict[str, Any], existing_collection: dict[str, Any]): + """Merge the existing collection's auth data into the new collection.""" + # Copy top-level auth + new_collection['auth'] = existing_collection['auth'] + + # Copy Staff-Auth folder + staff_auth = next((item for item in existing_collection['item'] if item['name'] == 'Staff-Auth'), None) + if staff_auth: + new_collection['item'].insert(0, staff_auth) + + # Copy provider-users folder auth + v1_folder = find_folder_by_name(new_collection['item'], 'v1') + if v1_folder: + new_provider_users = find_folder_by_name(v1_folder['item'], 'provider-users') + existing_provider_users = find_folder_by_name(existing_collection['item'], 'provider-users') + if new_provider_users and existing_provider_users and 'auth' in existing_provider_users: + new_provider_users['auth'] = existing_provider_users['auth'] + + # Preserve the bulk-upload script + preserve_bulk_upload_script(new_collection, existing_collection) + + # Copy the Upload Document request + copy_upload_document_request(new_collection, existing_collection) + + +def set_standard_fields(collection: dict[str, Any]): + """Set the standard fields for the collection.""" + collection['info']['name'] = 'CompactConnect API' + + +def cleanup_collection(collection: dict[str, Any]): + """Remove unnecessary top-level fields from the collection.""" + for field in ['event', 'variable']: + if field in collection: + del collection[field] + + +def main(): + parser = argparse.ArgumentParser(description='Update Postman collection from OpenAPI specification') + parser.add_argument( + '-i', '--internal', action='store_true', help='Use internal API specification files instead of regular ones' + ) + parser.add_argument('-s', '--search', action='store_true', help='Use search API specification files') + + args = parser.parse_args() + + # Define paths relative to the script location + script_dir = os.path.dirname(os.path.abspath(__file__)) + workspace_dir = os.path.dirname(script_dir) + + # Determine the base directory based on the flags + if args.search: + base_dir = os.path.join('search-internal', 'api-specification') + postman_dir = os.path.join('search-internal', 'postman') + elif args.internal: + base_dir = os.path.join('internal', 'api-specification') + postman_dir = os.path.join('internal', 'postman') + else: + base_dir = os.path.join('api-specification') + postman_dir = os.path.join('postman') + + openapi_path = os.path.join(workspace_dir, 'docs', base_dir, 'latest-oas30.json') + tmp_path = os.path.join(workspace_dir, 'tmp.json') + postman_path = os.path.join(workspace_dir, 'docs', postman_dir, 'postman-collection.json') + + # Generate new collection from OpenAPI spec + generate_postman_collection(openapi_path, tmp_path) + + try: + # Load the generated collection + with open(tmp_path) as f: + new_collection = json.load(f) + with open(postman_path) as f: + existing_collection = json.load(f) + + # Process the new collection + remove_incorrect_auth(new_collection) + merge_collections(new_collection, existing_collection) + set_standard_fields(new_collection) + cleanup_collection(new_collection) + + # Write the updated collection + with open(postman_path, 'w') as f: + json.dump(new_collection, f, sort_keys=True, indent=4) + f.write('\n') + + # Clean up temporary file + os.remove(tmp_path) + + except FileNotFoundError as e: + sys.stderr.write(f'Failed to find required file: {e.filename}\n') + sys.exit(1) + except json.JSONDecodeError as e: + sys.stderr.write(f'Failed to parse JSON: {str(e)}\n') + sys.exit(1) + except Exception as e: # noqa: BLE001 + sys.stderr.write(f'Unexpected error: {str(e)}\n') + sys.stderr.write(traceback.format_exc()) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/cdk.context.beta-example.json b/backend/social-work-app/cdk.context.beta-example.json new file mode 100644 index 0000000000..8f906b42ba --- /dev/null +++ b/backend/social-work-app/cdk.context.beta-example.json @@ -0,0 +1,63 @@ +{ + "ssm_context": { + "github_repo_string": "csg-org/CompactConnect", + "app_name": "social-work-compact-connect", + "environments": { + "pipeline": { + "account_id": "000000000000", + "region": "us-east-1", + "connection_arn": "arn:aws:codestar-connections:us-east-1:000000000000:connection/11111111-1111-1111-111111111111" + }, + "beta": { + "account_id": "222233334444", + "region": "us-east-1", + "domain_name": "beta.socialwork.compactconnect.org", + "ui_domain_name_override": "app.beta.compactconnect.org", + "backup_enabled": false, + "notifications": { + "ses_operations_support_email": "justin@example.com", + "email": [ + "justin@example.com" + ], + "slack": [ + { + "channel_name": "ops-monitoring", + "channel_id": "C0123456789", + "workspace_id": "T01234567" + } + ] + }, + "backup_policies": { + "general_data": { + "schedule": { + "week_day": "5", + "year": "*", + "month": "*", + "hour": "5", + "minute": "0" + }, + "delete_after_days": 180, + "cold_storage_after_days": 30 + }, + "frequent_updates": { + "schedule": { + "week_day": "5", + "year": "*", + "month": "*", + "hour": "5", + "minute": "0" + }, + "delete_after_days": 180, + "cold_storage_after_days": 30 + } + } + } + }, + "backup_config": { + "backup_account_id": "111122223333", + "backup_region": "us-west-2", + "general_vault_name": "CompactConnectBackupVault", + "ssn_vault_name": "CompactConnectBackupVault-SSN" + } + } +} diff --git a/backend/social-work-app/cdk.context.deploy-example.json b/backend/social-work-app/cdk.context.deploy-example.json new file mode 100644 index 0000000000..0c6cf9fd23 --- /dev/null +++ b/backend/social-work-app/cdk.context.deploy-example.json @@ -0,0 +1,22 @@ +{ + "ssm_context": { + "environments": { + "deploy": { + "account_id": "000000000000", + "region": "us-east-1", + "notifications": { + "email": [ + "justin@example.com" + ], + "slack": [ + { + "channel_name": "ops-monitoring", + "channel_id": "C0123456789", + "workspace_id": "T01234567" + } + ] + } + } + } + } +} diff --git a/backend/social-work-app/cdk.context.prod-example.json b/backend/social-work-app/cdk.context.prod-example.json new file mode 100644 index 0000000000..9d71e5bf78 --- /dev/null +++ b/backend/social-work-app/cdk.context.prod-example.json @@ -0,0 +1,63 @@ +{ + "ssm_context": { + "github_repo_string": "csg-org/CompactConnect", + "app_name": "social-work-compact-connect", + "environments": { + "pipeline": { + "account_id": "000000000000", + "region": "us-east-1", + "connection_arn": "arn:aws:codestar-connections:us-east-1:000000000000:connection/11111111-1111-1111-111111111111" + }, + "prod": { + "account_id": "000011112222", + "region": "us-east-1", + "domain_name": "socialwork.compactconnect.org", + "ui_domain_name_override": "app.compactconnect.org", + "backup_enabled": true, + "notifications": { + "ses_operations_support_email": "justin@example.com", + "email": [ + "justin@example.com" + ], + "slack": [ + { + "channel_name": "ops-monitoring", + "channel_id": "C0123456789", + "workspace_id": "T01234567" + } + ] + }, + "backup_policies": { + "general_data": { + "schedule": { + "year": "*", + "month": "*", + "day": "*", + "hour": "5", + "minute": "0" + }, + "delete_after_days": 730, + "cold_storage_after_days": 30 + }, + "frequent_updates": { + "schedule": { + "year": "*", + "month": "*", + "day": "*", + "hour": "*", + "minute": "0" + }, + "delete_after_days": 730, + "cold_storage_after_days": 30 + } + } + } + }, + "backup_config": { + "backup_account_id": "111122223333", + "backup_region": "us-west-2", + "general_vault_name": "CompactConnectBackupVault", + "ssn_vault_name": "CompactConnectSSNBackupVault" + } + } +} diff --git a/backend/social-work-app/cdk.context.sandbox-example.json b/backend/social-work-app/cdk.context.sandbox-example.json new file mode 100644 index 0000000000..3f63f549aa --- /dev/null +++ b/backend/social-work-app/cdk.context.sandbox-example.json @@ -0,0 +1,28 @@ +{ + "sandbox": true, + "environment_name": "justin", + "sandbox_active_compact_member_jurisdictions": { + "socw": ["az"] + }, + "ssm_context": { + "github_repo_string": "csg-org/CompactConnect", + "app_name": "social-work-compact-connect", + "environments": { + "justin": { + "account_id": "111122223333", + "region": "us-east-1", + "domain_name": "justin.socialwork.compactconnect.org", + "ui_domain_name_override": "app.justin.compactconnect.org", + "backup_enabled": false, + "allow_local_ui": true, + "security_profile": "VULNERABLE", + "notifications": { + "ses_operations_support_email": "justin@example.com", + "email": [ + "justin@example.com" + ] + } + } + } + } +} diff --git a/backend/social-work-app/cdk.context.test-example.json b/backend/social-work-app/cdk.context.test-example.json new file mode 100644 index 0000000000..ca9edbf438 --- /dev/null +++ b/backend/social-work-app/cdk.context.test-example.json @@ -0,0 +1,64 @@ +{ + "ssm_context": { + "github_repo_string": "csg-org/CompactConnect", + "app_name": "social-work-compact-connect", + "environments": { + "pipeline": { + "account_id": "000000000000", + "region": "us-east-1", + "connection_arn": "arn:aws:codestar-connections:us-east-1:000000000000:connection/11111111-1111-1111-111111111111" + }, + "test": { + "account_id": "111122223333", + "region": "us-east-1", + "domain_name": "test.socialwork.compactconnect.org", + "ui_domain_name_override": "app.test.compactconnect.org", + "backup_enabled": true, + "allow_local_ui": true, + "notifications": { + "ses_operations_support_email": "justin@example.com", + "email": [ + "justin@example.com" + ], + "slack": [ + { + "channel_name": "preprod-ops-monitoring", + "channel_id": "C1234567890", + "workspace_id": "T01234567" + } + ] + }, + "backup_policies": { + "general_data": { + "schedule": { + "week_day": "5", + "year": "*", + "month": "*", + "hour": "5", + "minute": "0" + }, + "delete_after_days": 180, + "cold_storage_after_days": 30 + }, + "frequent_updates": { + "schedule": { + "week_day": "5", + "year": "*", + "month": "*", + "hour": "5", + "minute": "0" + }, + "delete_after_days": 180, + "cold_storage_after_days": 30 + } + } + } + }, + "backup_config": { + "backup_account_id": "111122223333", + "backup_region": "us-west-2", + "general_vault_name": "CompactConnectBackupVault", + "ssn_vault_name": "CompactConnectBackupVault-SSN" + } + } +} diff --git a/backend/social-work-app/cdk.json b/backend/social-work-app/cdk.json new file mode 100644 index 0000000000..eb9ca018b5 --- /dev/null +++ b/backend/social-work-app/cdk.json @@ -0,0 +1,108 @@ +{ + "app": "python3 app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "source.bat", + "**/__init__.py", + "**/__pycache__", + "tests" + ] + }, + "context": { + "tags": { + "project": "compact-connect", + "service": "socialwork" + }, + "jurisdictions": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "license_types": { + "socw": [ + {"name": "licensed clinical social worker", "abbreviation": "lcsw"}, + {"name": "licensed master social worker,", "abbreviation": "lmsw"}, + {"name": "licensed bachelor social worker", "abbreviation": "lbsw"} + ] + }, + "compacts": [ + "socw" + ], + "active_compact_member_jurisdictions": { + "socw": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-us-gov" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true + } +} diff --git a/backend/social-work-app/common_constructs/README.md b/backend/social-work-app/common_constructs/README.md new file mode 100644 index 0000000000..2e486b201b --- /dev/null +++ b/backend/social-work-app/common_constructs/README.md @@ -0,0 +1,8 @@ +# Common Constructs + +This is the application node of a [namespace package](https://docs.python.org/3/glossary.html#term-namespace-package), which +houses common CDK constructs that are used across different apps in the CompactConnect project. Modules in this package +will be merged in Python with similarly-named namespace packages in each app's specific folder. + +> **Note: Do not add an `__init__.py` file to any of the `common_constructs` packages, or they will break the +> import behavior. diff --git a/backend/social-work-app/common_constructs/cognito_user_backup.py b/backend/social-work-app/common_constructs/cognito_user_backup.py new file mode 100644 index 0000000000..053baa5cd3 --- /dev/null +++ b/backend/social-work-app/common_constructs/cognito_user_backup.py @@ -0,0 +1,240 @@ +""" +Common construct for backing up a single Cognito user pool. + +This construct creates all the necessary resources for exporting and backing up +a single Cognito user pool, including the Lambda function, S3 bucket, backup plan, +and EventBridge scheduling. +""" + +from __future__ import annotations + +import os + +from aws_cdk import Duration, RemovalPolicy +from aws_cdk.aws_backup import BackupResource +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import Rule, RuleTargetInput, Schedule +from aws_cdk.aws_events_targets import LambdaFunction +from aws_cdk.aws_iam import Effect, PolicyStatement +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_s3 import BucketEncryption, LifecycleRule +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.backup_plan import CCBackupPlan +from common_constructs.bucket import Bucket +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack +from constructs import Construct + + +class CognitoUserBackup(Construct): + """ + Common construct for backing up a single Cognito user pool. + + This construct creates: + - S3 bucket for storing exported user data + - Lambda function for exporting user data + - EventBridge rule for daily scheduling + - Backup plan for cross-account replication + - CloudWatch alarm for failure monitoring + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + user_pool_id: str, + access_logs_bucket: AccessLogsBucket, + encryption_key: IKey, + removal_policy: RemovalPolicy, + backup_infrastructure_stack: BackupInfrastructureStack, + alarm_topic: ITopic, + environment_context: dict, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + self.user_pool_id = user_pool_id + self.encryption_key = encryption_key + + # Create the backup bucket for this user pool + self.backup_bucket = self._create_backup_bucket( + access_logs_bucket, encryption_key, removal_policy, backup_infrastructure_stack, environment_context + ) + + # Create the export Lambda function + self.export_lambda = self._create_export_lambda() + + # Create the EventBridge rule for daily scheduling + self.backup_rule = self._create_backup_rule() + + # Create failure alarm + self.failure_alarm = self._create_failure_alarm(alarm_topic) + + def _create_backup_bucket( + self, + access_logs_bucket: AccessLogsBucket, + encryption_key: IKey, + removal_policy: RemovalPolicy, + backup_infrastructure_stack: BackupInfrastructureStack, + environment_context: dict, + ) -> Bucket: + """Create S3 bucket for storing exported user data.""" + self.bucket = Bucket( + self, + 'BackupBucket', + encryption=BucketEncryption.KMS, + encryption_key=encryption_key, + server_access_logs_bucket=access_logs_bucket, + removal_policy=removal_policy, + # Versioning is required for AWS Backup + # https://docs.aws.amazon.com/aws-backup/latest/devguide/s3-backups.html#s3-backup-prerequisites + versioned=True, + # Minimize versioning storage costs by deleting all non-current versions + # (AWS Backup will store previous versions in recovery points) + lifecycle_rules=[ + LifecycleRule( + id='MinimizeVersioningCosts', + enabled=True, + # Delete non-current versions after 1 day to minimize storage costs + noncurrent_version_expiration=Duration.days(1), + ) + ], + ) + + NagSuppressions.add_resource_suppressions( + self.bucket, + suppressions=[ + { + 'id': 'HIPAA.Security-S3BucketReplicationEnabled', + 'reason': 'This bucket stores Cognito user exports that are backed up to cross-account vault via ' + 'AWS Backup. Replication is handled by backup infrastructure rather than S3 replication.', + }, + ], + ) + + # Set up backup plan using the general_data backup category + self.backup_plan = CCBackupPlan( + self.bucket, + 'BackupPlan', + backup_plan_name_prefix=f'{self.bucket.bucket_name}-cognito-backup', + backup_resources=[BackupResource.from_arn(self.bucket.bucket_arn)], + backup_vault=backup_infrastructure_stack.local_backup_vault, + backup_service_role=backup_infrastructure_stack.backup_service_role, + cross_account_backup_vault=backup_infrastructure_stack.cross_account_backup_vault, + # We'll force a single backup policy for all Cognito user pools + # So that we can synchronize the backup timing with the export Lambda timing. + backup_policy={ + 'schedule': { + 'year': '*', + 'month': '*', + 'day': '*', + 'hour': '6', # One hour after the export Lambda runs + 'minute': '0', + }, + 'delete_after_days': environment_context['backup_policies']['general_data']['delete_after_days'], + 'cold_storage_after_days': environment_context['backup_policies']['general_data'][ + 'cold_storage_after_days' + ], + }, + ) + + return self.bucket + + def _create_export_lambda(self) -> PythonFunction: + """Create Lambda function for exporting user data.""" + # Get stack to access common environment variables + stack = Stack.of(self) + + lambda_function = PythonFunction( + self, + 'ExportLambda', + description='Export user pool data for backup purposes', + lambda_dir='cognito-backup', + index=os.path.join('handlers', 'cognito_backup.py'), + handler='backup_handler', + timeout=Duration.minutes(15), # Allow time for large user pools + memory_size=512, # Sufficient memory for processing and S3 uploads + ) + + # Grant the Lambda permissions to access Cognito and S3 + lambda_function.add_to_role_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'cognito-idp:ListUsers', + 'cognito-idp:DescribeUserPool', + ], + resources=[ + stack.format_arn( + partition=stack.partition, + service='cognito-idp', + region=stack.region, + account=stack.account, + resource='userpool', + resource_name=self.user_pool_id, + ), + ], + ) + ) + + # Grant S3 permissions + self.backup_bucket.grant_write(lambda_function) + self.encryption_key.grant_encrypt_decrypt(lambda_function) + + # Add CDK NAG suppressions for the Lambda IAM permissions + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{lambda_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'Lambda requires read access to specific Cognito user pool and write access to ' + 'the backup S3 bucket. Permissions are scoped to specific resources where possible.', + }, + ], + ) + + return lambda_function + + def _create_backup_rule(self) -> Rule: + """Create EventBridge rule for daily execution.""" + # Schedule at 5 AM UTC to avoid conflicts with other backup operations + # Pass the required parameters as part of the event + return Rule( + self, + 'DailyExportRule', + description='Daily schedule for user pool backup export', + schedule=Schedule.cron(week_day='*', year='*', month='*', hour='5', minute='0'), + targets=[ + LambdaFunction( + self.export_lambda, + event=RuleTargetInput.from_object( + { + 'user_pool_id': self.user_pool_id, + 'backup_bucket_name': self.backup_bucket.bucket_name, + } + ), + ) + ], + ) + + def _create_failure_alarm(self, alarm_topic: ITopic) -> Alarm: + """Create CloudWatch alarm for backup failures.""" + alarm = Alarm( + self, + 'BackupFailureAlarm', + metric=self.export_lambda.metric_errors(), + threshold=1, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description='User pool backup export Lambda has failed. User data backup may be incomplete.', + ) + alarm.add_alarm_action(SnsAction(alarm_topic)) + + return alarm diff --git a/backend/social-work-app/common_constructs/resource_scope_mixin.py b/backend/social-work-app/common_constructs/resource_scope_mixin.py new file mode 100644 index 0000000000..cd8f0dfdf3 --- /dev/null +++ b/backend/social-work-app/common_constructs/resource_scope_mixin.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from aws_cdk.aws_cognito import ResourceServerScope, UserPoolResourceServer + +from stacks import persistent_stack as ps + + +class ResourceScopeMixin: + """ + Mixin class that provides internal methods for generating API Resource Servers and Scopes + """ + + def _add_resource_servers(self, stack: ps.PersistentStack): + """Add scopes for all compact/jurisdictions""" + # {compact}/write, {compact}/admin, {compact}/readGeneral for every compact resource server + # {jurisdiction}/{compact}.write, {jurisdiction}/{compact}.admin, {jurisdiction}/{compact}.readGeneral + # for every jurisdiction and compact resource server. + # Note: the scopes defined here will control access to API endpoints via the Cognito + # authorizer, however there will be a secondary level of authorization within the runtime logic that ensures + # the caller has the correct privileges to perform the action against the requested compact/jurisdiction. + + # The following scopes are specifically for compact level access + self.compact_write_scope = ResourceServerScope( + scope_name='write', + scope_description='Write access for the compact', + ) + self.compact_admin_scope = ResourceServerScope( + scope_name='admin', + scope_description='Admin access for the compact', + ) + self.compact_read_scope = ResourceServerScope( + scope_name='readGeneral', + scope_description='Read access for generally available data (not private) in the compact', + ) + + active_compacts = stack.get_list_of_compact_abbreviations() + self.compact_resource_servers = {} + self.jurisdiction_resource_servers: dict[str, UserPoolResourceServer] = {} + _jurisdiction_compact_scope_mapping: dict[str, list] = {} + for compact in active_compacts: + self.compact_resource_servers[compact] = self.add_resource_server( + f'LicenseData-{compact}', + identifier=compact, + scopes=[ + self.compact_admin_scope, + self.compact_write_scope, + self.compact_read_scope, + ], + ) + # we define the jurisdiction level scopes, which will be used by every + # jurisdiction that is active for the compact/environment. + active_jurisdictions_for_compact = stack.get_list_of_active_jurisdictions_for_compact_environment( + compact=compact + ) + for jurisdiction in active_jurisdictions_for_compact: + if _jurisdiction_compact_scope_mapping.get(jurisdiction) is None: + _jurisdiction_compact_scope_mapping[jurisdiction] = ( + self._generate_resource_server_scopes_list_for_compact(compact) + ) + else: + _jurisdiction_compact_scope_mapping[jurisdiction].extend( + self._generate_resource_server_scopes_list_for_compact(compact) + ) + + # now create resources servers for every jurisdiction that was active within at least one compact for this + # environment + for jurisdiction, scopes in _jurisdiction_compact_scope_mapping.items(): + self.jurisdiction_resource_servers[jurisdiction] = self.add_resource_server( + f'LicenseData-{jurisdiction}', + identifier=jurisdiction, + scopes=scopes, + ) + + def _generate_resource_server_scopes_list_for_compact(self, compact: str): + return [ + ResourceServerScope( + scope_name=f'{compact}.admin', + scope_description=f'Admin access for the {compact} compact within the jurisdiction', + ), + ResourceServerScope( + scope_name=f'{compact}.write', + scope_description=f'Write access for the {compact} compact within the jurisdiction', + ), + ResourceServerScope( + scope_name=f'{compact}.readPrivate', + scope_description=f'Read access for SSNs in the {compact} compact within the jurisdiction', + ), + ] diff --git a/backend/social-work-app/disaster_recovery/FULL_TABLE_RECOVERY.md b/backend/social-work-app/disaster_recovery/FULL_TABLE_RECOVERY.md new file mode 100644 index 0000000000..3ab83a5b6d --- /dev/null +++ b/backend/social-work-app/disaster_recovery/FULL_TABLE_RECOVERY.md @@ -0,0 +1,230 @@ +## Overview + +The Full Table Disaster Recovery (DR) system provides automated recovery capabilities for critical DynamoDB tables in the CompactConnect system. This system allows administrators to perform Point-in-Time Recovery (PITR) operations when tables become corrupted or require rollback to a previous state. + +**⚠️ WARNING: This system performs a HARD RESET of the target table, permanently deleting all current data before restoring from the specified timestamp.** + +## When to Use + +This Disaster Recovery process should only be run in the event that the system experiences an event that causes +system-wide failures, such as the following scenarios: + +1. **Data Corruption**: When a table contains corrupted or invalid data that cannot be fixed through normal operations +2. **Accidental Data Loss**: When critical data has been accidentally deleted or modified +3. **Failed Deployments**: When a deployment has caused data integrity issues +4. **Security Incidents**: When unauthorized modifications require rolling back to a clean state +5. **System-wide Issues**: When multiple tables need to be restored to a consistent point in time + +## Architecture + +### Two-Phase Recovery Process +DynamoDB PITR cannot directly restore data into your production database. Instead, it creates a new table with data matching the exact values you had in your production database at the specified timestamp. You as the owner of the database must decide what to do with that data from that point in time. For the purposes of disaster recovery rollback, we have determined to get the data into the production table by performing a 'hard reset', meaning **all the current data in the production table is deleted**, then we copy over the data from the temporary table into the production table. This process includes the following step functions. + +1. **RestoreDynamoDbTable Step Function** (Parent) + - Creates a backup of the current table for post-incident analysis + - Restores a temporary table from the specified PITR timestamp + - Invokes the SyncTableData Step Function + +2. **SyncTableData Step Function** (Child) + - **Delete Phase**: Removes all records from the production table + - **Copy Phase**: Copies all records from the temporary table to the production table + +Once this process is complete, the data in the target table will be restored with the data from the specified point in time. + +### Per-Table Isolation + +Each DynamoDB table has its own dedicated pair of Step Functions: + +- `DRRestoreDynamoDbTable{TableName}StateMachine` +- `{TableName}DRSyncTableDataStateMachine` + +This design allows for: +- **Targeted Recovery**: Restore only the affected table(s) +- **Granular Permissions**: Each Step Function has minimal, table-specific permissions + +## Supported Tables + +The following tables are configured for disaster recovery: + +| Table Name | Step Function Prefix | Purpose | Recovery Notes | +|------------|---------------------|---------|----------------| +| TransactionHistoryTable | `TransactionHistoryTable` | transaction data from authorize.net | Can be rolled back independently. After DR rollback, run the Transaction History Processing Workflow Step Function for each compact for every day where data was lost to restore all transaction data from Authorize.net accounts. The Transaction History Processing Workflow step functions are idempotent. They can be run multiple times without producing duplicate transaction items in the table. | +| ProviderTable | `ProviderTable` | Provider information and GSIs | **Dependent on SSN table** - Can be rolled back without updating SSN table since SSN table does not have a dependency on the provider table. **⚠️ WARNING**: If SSN table needs rollback, the provider table will likely need to be restored to same point in time as SSN table. Otherwise new provider IDs may be generated for existing SSNs causing data inconsistency/orphaned providers that won't receive license updates. After DR rollback, consider that the transaction history table will have a list of all privileges purchased as recorded in Authorize.net, and can be used as a data source for repopulating any privilege records that may have been lost as a result of the rollback.| +| CompactConfigurationTable | `CompactConfigurationTable` | System configuration data | Can be rolled back independently of other tables. Contains configuration set by compact and state admins. Admins may need to reset configurations that were lost as a result of the rollback. | +| DataEventTable | `DataEventTable` | License data events | Used for downstream processing events triggered by Event Bridge event bus. In the event of recovery, many of these events can likely be restored by replaying events placed on the event bus. See https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-archive.html | +| UsersTable | `UsersTable` | Staff user permissions and account data | Can be rolled back independently. Contains staff user permissions and account information. Admins may need to re-invite new users or reset permissions that were lost as a result of the rollback. | + +> **Note**: The SSN table is excluded due to additional security requirements and will be handled in a future implementation. + +## Running the Disaster Recovery Workflow + +## Pre-Execution Checklist + +1. ✅ **Verify Impact**: Confirm which applications/users will be affected +2. ✅ **Communication**: Notify stakeholders of the planned recovery +3. ✅ **Timestamp Selection**: Determine the UTC timestamp to restore to (must be within 35 days) +4. ✅ **Access Verification**: Confirm you have necessary permissions (Currently only AWS account admins can trigger a DR) + +### Step 1: Start Recovery Mode + +Before executing the DR Step Function, you must throttle all Lambda functions to prevent other data operations from occurring while attempting to roll any databases back. There is a script provided to perform this action: + +```bash +# Navigate to the disaster_recovery directory +cd backend/compact-connect/disaster_recovery + +# Start recovery mode for the environment (replace "Prod" with your target environment) +python start_recovery_mode.py --environment Prod +``` + +This will put the system into recovery mode by: +- Setting reserved concurrency to 0 for all environment Lambda functions, so they can't be invoked +- Leaving Disaster Recovery functions operational +- **Important**: If any functions failed to throttle, you may rerun the script or manually check their reserved concurrency settings if needed. The script is idempotent and can be run multiple times. + +### Step 2: Execute Disaster Recovery Step Function For Specific Tables +#### Prerequisites +- Identify the exact table name from the DynamoDB console (needed for `tableNameRecoveryConfirmation`) +- Verify the PITR timestamp is correct +- Create a unique incident ID for tracking (see [Execution Request Parameter Details](#execution-request-parameter-details)) + +When you are ready to perform a rollback, find the step function for the specific table you need to rollback (`DRRestoreDynamoDbTable{TableName}StateMachine`) and start an execution with the following input (replace placeholders with your values) + +```json +{ + "incidentId": "", + "pitrBackupTime": "", + "tableNameRecoveryConfirmation": "" +} +``` + +#### Execution Request Parameter Details + +- **`incidentId`** (required) + - Purpose: Unique identifier for tracking this recovery operation + - Format: String (80 chars or less, allows alphanumeric and hyphens) + - Example: `"incident-2025-001"`, `"corruption-fix-20250115"` + - Used in: Backup names, restored table names, execution tracking + +- **`pitrBackupTime`** (required) + - Purpose: The timestamp to restore the table to + - Format: UTC datetime string + - Example: `"2030-01-15T12:39:46Z"` + - Constraints: Must be within the PITR retention window (35 days) + +- **`tableNameRecoveryConfirmation`** (required) + - Purpose: Security guard rail to prevent accidental execution + - Format: Exact table name being recovered (you can copy this from the DynamoDB console) + - Example: `"Prod-PersistentStack-DataEventTable00A96798-C6VX9JVDOYGN"` + - Validation: Must match the actual destination table name + +example: +```json +{ + "incidentId": "transaction-corruption-20250115", + "pitrBackupTime": "2025-01-15T09:00:00Z", + "tableNameRecoveryConfirmation": "Prod-PersistentStack-TransactionHistoryTable00A96798-C6VX9JVDOYGN" +} +``` + +#### Running Step Functions from AWS Console + +1. Navigate to Step Functions in the AWS Console +2. Find the appropriate Step Function(s) for the table(s) you need to recover (e.g., `DRRestoreDynamoDbTableTransactionHistoryTableStateMachine`) +3. For each step function you need to run, Click "Start Execution" +4. Enter the JSON payload in the input field +5. Click "Start Execution" and wait for completion (multiple Step functions can be run concurrently if you are restoring multiple tables) + +### Step 3: End Recovery Mode + +**⚠️CRITICAL**: Only proceed after ALL recovery Step Functions you have run have completed successfully. + +After the DR Step Function completes successfully for each table you need to restore, end the recovery mode to restore normal operations: + +```bash +# End recovery mode for the environment +python end_recovery_mode.py --environment Prod +``` + +This will: +- Remove reserved concurrency throttling from all Lambda functions +- Restore normal application operations +- Complete the disaster recovery process +- **Important**: If any functions failed to unthrottle, you may rerun the script or manually check their reserved concurrency settings if needed. The script is idempotent and can be run multiple times. + +### Post-Execution + +1. **Verify Recovery**: Confirm data integrity and completeness +2. **Application Testing**: Test critical application functions +3. **Documentation**: Update incident documentation with recovery details +4. **Cleanup Review**: Cleanup temporary resources after post-incident analysis. + +### Operational Constraints + +- **Data Loss**: All data newer than the PITR timestamp will be permanently lost. The backup snapshot may be restored post-recovery to determine which records can potentially be recovered. +- **Dependencies**: Related tables may need coordinated restoration for consistency. + +## Monitoring and Troubleshooting +### Common Issues and Solutions + +#### Invalid table name +- **Cause**: `tableNameRecoveryConfirmation` doesn't match actual table name (this parameter is used to prevent accidental recovery on a database) +- **Solution**: Copy exact table name from DynamoDB console + +#### Restore timestamp out of range +- **Cause**: PITR timestamp is outside the 35-day retention window +- **Solution**: Choose a more recent timestamp within the retention period + +## Complete Table Deletion Recovery (Manual Backup Restoration) + +**⚠️ CRITICAL**: This section applies ONLY when a DynamoDB table has been completely deleted and PITR is not available. This requires manual intervention and cannot use the automated Step Functions. + +### Recovery Steps +Depending on how the table was deleted, there may be a latest 'snapshot' backup in the DynamoDB console that you can recover from. If that snapshot is not available, the system performs daily backups of our tables and store them in the AWS Backup service that you can recover from. + +#### Step 1: Locate the Latest Backup + +##### Option A: DynamoDB Console +1. Navigate to DynamoDB Console → Backups +2. Find the most recent backup for the deleted table +3. Note the backup name and creation time + +##### Option B: AWS Backup Console +1. Navigate to AWS Backup Console → Backup Vaults +2. Find the most recent recovery point for the deleted table +3. **CRITICAL**: Note the "Original table name" from the recovery point details + +#### Step 2: Restore Table from Backup + +1. **From DynamoDB Console**: + - Go to DynamoDB → Backups + - Select the backup → "Restore" + - **CRITICAL Configuration**: + - **Table Name**: Must match EXACTLY the original deleted table name + - **Encryption**: Select "Customer managed key" + - **KMS Key**: Choose `-PersistentStack-shared-encryption-key` for non-ssn tables, `ssn-key` for the SSN table + - Example: `Prod-PersistentStack-shared-encryption-key` + - **Global Secondary Indexes (GSIs)**: Ensure ALL original GSIs are included in the restore by selecting 'Restore the entire table' + - Select 'Restore' + +2. **From AWS Backup Console**: + - Navigate to Recovery Points → Select the backup + - Click "Restore" + - **CRITICAL Configuration**: + - **New Table Name**: Use the EXACT "Original table name" from the recovery point + - **Encryption**: Choose an AWS KMS key -> `-PersistentStack-shared-encryption-key` for non-ssn tables, `ssn-key` for the SSN table + - **GSIs**: Verify all original GSIs are restored + - Select 'Restore Backup' + +#### Step 3: Verify Restoration + +1. **Table Configuration**: + - ✅ Table name matches exactly (including environment prefix and suffix) + - ✅ All Global Secondary Indexes are present + - ✅ Encryption is set to the correct KMS key + - ✅ Table status is "ACTIVE" + +2. **Data Verification**: + - Spot-check critical records + - Verify record counts are reasonable + - Verify application functionality with the restored table diff --git a/backend/social-work-app/disaster_recovery/LICENSE_UPLOAD_ROLLBACK.md b/backend/social-work-app/disaster_recovery/LICENSE_UPLOAD_ROLLBACK.md new file mode 100644 index 0000000000..ecd922035b --- /dev/null +++ b/backend/social-work-app/disaster_recovery/LICENSE_UPLOAD_ROLLBACK.md @@ -0,0 +1,192 @@ +# License Upload Rollback Guide + +## Overview + +The License Upload Rollback system allows AWS account administrators to automatically revert invalid or corrupted license data that was uploaded by a specific jurisdiction within a defined time window. + +The system will automatically determine which providers had their license records modified as a result of uploads during the time window, and confirm which license updates can be safely rolled back. A provider is eligible for automatic rollback if only license upload-related changes happened since the window. If any other updates have occurred since the start of the time window, the provider will be skipped and manual review will be required to determine which action should be taken for that individual. The rollback process will generate a full JSON report showing which providers had their licenses rolled back and which were skipped and require manual review. + +## Step-by-Step Execution Guide + +### Prerequisites + +Before starting the rollback: + +1. ✅ **Verify the Problem**: Confirm which jurisdiction uploaded bad data for which compact(s) +2. ✅ **Disable automated access for Jurisdiction**: If jurisdiction has API credentials for automated uploads, disable those credentials to prevent further data changes until system has been recovered. To do this, determine which Cognito app client(s) the jurisdiction is using for the compact(s) and delete the appropriate app client(s) from the State Auth Cognito user pool. +3. ✅ **Determine Time Window**: Identify the exact start and end times (UTC) of the problematic uploads +4. ✅ **Determine When Rollback Should be Performed**: Depending on the severity of the issue and scale of records that need to be rolled back, determine if the rollback needs to be performed as soon as possible or if it can be performed outside of peak traffic hours. When possible, it is recommended to perform rollbacks during periods of low traffic. While the risk is low, there is a narrow race condition (.2 second window based on load testing) where a license record may be modified by another part of the system after the rollback system checked for updates and the modification could be removed by the rollback. Running the rollback when traffic is low reduces this risk even further. +5. ✅ **Stakeholder Notification**: Coordinate with relevant state administrators and other stakeholders. Ensure jurisdiction is aware they should not attempt to upload any more license data until the rollback has been completed. + +### Step 1: Gather Required Information + +You'll need the following information for the execution: + +| Parameter | Description | Example | +|-----------|----------------------------------------------------------|---------| +| `compact` | The compact abbreviation (lowercase) | `"socw"` | +| `jurisdiction` | The state/jurisdiction code (lowercase) | `"oh"`, `"ky"`, `"ne"` | +| `startDateTime` | UTC timestamp when problematic uploads began (inclusive) | `"2020-01-15T08:00:00Z"` | +| `endDateTime` | UTC timestamp when problematic uploads ended (inclusive) | `"2020-01-15T17:59:59Z"` | +| `rollbackReason` | Description for audit trail | `"Invalid license data uploaded by OH staff"` | + +**Important Notes:** +- All timestamps must be in UTC +- Time window cannot exceed 7 days (604,800 seconds) + +### Step 2: Locate the Step Function + +1. Navigate to the AWS Console → Step Functions +2. Find the Step Function with the name prefix: **`LicenseUploadRollbackLicenseUploadRollbackStateMachine`** + +### Step 3: Execute the Step Function + +1. Click **"Start Execution"** +2. Enter a descriptive execution name (this will be used for the S3 results folder): + ``` + rollback-socw-oh-2020-01-15 + ``` + +3. Paste the following JSON input (replace values with your specific parameters): + +```json +{ + "compact": "socw", + "jurisdiction": "oh", + "startDateTime": "2020-01-15T08:00:00Z", + "endDateTime": "2020-01-15T17:59:59Z", + "rollbackReason": "Invalid license data uploaded - incorrect expiration dates" +} +``` + +4. Click **"Start Execution"** + +### Step 4: Monitor Execution Progress + +The Step Function will process providers in batches. Monitor the step function execution until it completes and verify the execution was successful. + +### Step 5: Review Results + +Once the execution completes, comprehensive results are stored in S3. The S3 key is returned as output from the lambda step of the step function. Check the Step Function execution output/logs to get the S3 key. + +#### Accessing the Results File + +1. Navigate to S3 in the AWS Console +2. Find the bucket with `disasterrecoveryrollbackresults` in the name. +3. Navigate to the folder matching your execution name: `rollback-socw-oh-2025-01-15/` +4. Download the file: `results.json` + +#### Understanding the Results Structure + +The results file contains three main sections: + +##### 1. Reverted Provider Summaries + +Providers that were successfully rolled back (example): + +```json +{ + "revertedProviderSummaries": [ + { + "providerId": "01234567-89ab-cdef-0123-456789abcdef", + "licensesReverted": [ + { + "jurisdiction": "oh", + "licenseType": "SocialWork", + "revisionId": "98765432-10ab-cdef-0123-456789abcdef", + "action": "REVERT" + } + ], + "privilegesReverted": [ + { + "jurisdiction": "ky", + "licenseType": "SocialWork", + "revisionId": "11111111-2222-3333-4444-555555555555", + "action": "REACTIVATED" + } + ], + "updatesDeleted": [ + + ] + } + ] +} +``` + +**Actions Explained:** +- `"REVERT"`: License data was restored to its pre-upload state +- `"DELETE"`: License was newly created during the upload and has been removed +- `"REACTIVATED"`: Privilege was deactivated due to the upload and has been reactivated + +##### 2. Skipped Provider Details + +Providers that require manual review (example): + +```json +{ + "skippedProviderDetails": [ + { + "providerId": "12345678-90ab-cdef-0123-456789abcdef", + "reason": "Provider has updates that are either unrelated to license upload or occurred after rollback end time. Manual review required.", + "ineligibleUpdates": [ + { + "recordType": "licenseUpdate", + "typeOfUpdate": "encumbrance", + "updateTime": "2025-01-16T10:30:00Z", + "licenseType": "SocialWork", + "reason": "License was updated with a change unrelated to license upload or the update occurred after rollback end time. Manual review required." + } + ] + } + ] +} +``` + +##### 3. Failed Provider Details + +Providers that encountered errors: + +```json +{ + "failedProviderDetails": [ + { + "providerId": "23456789-01ab-cdef-0123-456789abcdef", + "error": "Failed to rollback updates for provider. Manual review required: ConditionalCheckFailedException" + } + ] +} +``` + +These require technical investigation to determine the cause. + +#### Options for Skipped or Failed Providers + +For providers requiring manual review, you have three options: + +1. **Do Nothing**: If the subsequent updates are valid, the provider's current state is correct +2. **Manual Database Edit**: For complex cases, coordinate with stakeholders to manually adjust records and document manual edits made. +3. **Re-upload Data**: Have the state re-upload correct data for these specific providers through the normal upload process (often the simplest option) + +## Technical Details + +### How the System Identifies Affected Providers + +The system uses the `licenseUploadDateGSI` Global Secondary Index to efficiently query for all license records uploaded during the specified time window. This index is structured as: + +- **Partition Key**: `C#{compact}#J#{jurisdiction}#D#{year-month}` +- **Sort Key**: `TIME#{epoch}#LT#{license_type}#PID#{provider_id}` + +The system queries each month in the time range and collects unique provider IDs. + +### Event Publishing + +For each successfully reverted provider, the system publishes events to the EventBridge event bus: + +- `license.reverted` events for each reverted license + +These events include: +- The rollback reason +- Time window information +- Revision IDs for tracking + +These events purely for auditing purposes. They are not currently referenced by any downstream processes. diff --git a/backend/social-work-app/disaster_recovery/README.md b/backend/social-work-app/disaster_recovery/README.md new file mode 100644 index 0000000000..e09dfbea0d --- /dev/null +++ b/backend/social-work-app/disaster_recovery/README.md @@ -0,0 +1,16 @@ +# DynamoDB Disaster Recovery System + +## 🚨 IMPORTANT: Choose the Right Recovery Tool + +This repository contains TWO DIFFERENT recovery systems for different scenarios: + +### 1. **License Upload Rollback** +Use when you need to revert **specific license uploads** from **one jurisdiction** within a **time window**. + +See: [LICENSE_UPLOAD_ROLLBACK.md](./LICENSE_UPLOAD_ROLLBACK.md) + +### 2. **Full System Disaster Recovery** +Use when you need to recover **entire DynamoDB tables** affecting **ALL compacts and jurisdictions**. + +See: [FULL_TABLE_RECOVERY.md](./FULL_TABLE_RECOVERY.md) + diff --git a/backend/social-work-app/disaster_recovery/end_recovery_mode.py b/backend/social-work-app/disaster_recovery/end_recovery_mode.py new file mode 100755 index 0000000000..250f6b0f29 --- /dev/null +++ b/backend/social-work-app/disaster_recovery/end_recovery_mode.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201 we use print statements for local scripts +""" +Disaster Recovery - End Recovery Mode Script + +This script removes reserved concurrency throttling from all Lambda functions in the account +to restore normal operations after disaster recovery mode. + +Usage: + python end_recovery_mode.py --environment + +Example: + python end_recovery_mode.py --environment Test + python end_recovery_mode.py --environment Beta + python end_recovery_mode.py --environment Prod + +Requirements: + - AWS CLI configured with appropriate credentials + - boto3 installed + - Lambda permissions: ListFunctions, GetReservedConcurrencyConfiguration, DeleteReservedConcurrencyConfiguration +""" + +import argparse +import sys + +import boto3 +from botocore.exceptions import ClientError + + +def validate_environment(environment_name: str) -> str: + """ + Validate and normalize the environment name. + param: environment_name: Environment name to validate + + return: Normalized environment name in title case + raise ValueError: If environment name is invalid + """ + if not environment_name: + raise ValueError('Environment name cannot be empty') + + # Normalize to title case for validation and consistency + normalized = environment_name.strip().title() + valid_environments = ['Test', 'Beta', 'Prod', 'Sandbox'] + + if normalized not in valid_environments: + raise ValueError(f"Invalid environment '{environment_name}'. Valid options: {valid_environments}") + + return normalized + + +def unthrottle_lambda_functions(environment_name: str) -> dict: + """ + Remove reserved concurrency throttling from Lambda functions for the specified environment. + + param: environment_name: Environment to unthrottle functions for + + return: Dict containing results of the operation + """ + lambda_client = boto3.client('lambda') + + # Environment prefix for filtering functions (e.g., "Test-", "Beta-", "Prod-") + environment_prefix = f'{environment_name}-' + + print(f'Ending recovery mode for environment: {environment_name}') + print(f'Function prefix filter: {environment_prefix}') + + unthrottled_functions = [] + skipped_functions = [] + errors = [] + failed_function_names = [] + + try: + # Use paginator to handle accounts with many Lambda functions + paginator = lambda_client.get_paginator('list_functions') + total_functions_checked = 0 + + for page in paginator.paginate(): + for function in page['Functions']: + function_name = function['FunctionName'] + total_functions_checked += 1 + + # Skip functions that don't match the environment prefix + if not function_name.startswith(environment_prefix): + print(f'Skipping {function_name} - does not match environment prefix {environment_prefix}') + skipped_functions.append(function_name) + continue + + # Skip Disaster Recovery functions as they weren't throttled + if 'DisasterRecovery' in function_name: + print(f'Skipping DR function: {function_name}') + skipped_functions.append(function_name) + continue + + try: + # Remove the reserved concurrency configuration + lambda_client.delete_function_concurrency(FunctionName=function_name) + + print(f'Successfully unthrottled function: {function_name}') + unthrottled_functions.append(function_name) + + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + error_msg = f'Error unthrottling {function_name}: {error_code} - {error_message}' + print(error_msg) + errors.append(error_msg) + failed_function_names.append(function_name) + + except Exception as e: # noqa: BLE001 + error_msg = f'Unexpected error unthrottling {function_name}: {str(e)}' + print(error_msg) + errors.append(error_msg) + failed_function_names.append(function_name) + + return { + 'unthrottled_functions': unthrottled_functions, + 'skipped_functions': skipped_functions, + 'failed_functions': failed_function_names, + 'errors': errors, + 'total_functions_checked': total_functions_checked, + } + + except ClientError as e: + error_msg = f'Failed to list Lambda functions: {str(e)}' + print(error_msg) + return { + 'unthrottled_functions': unthrottled_functions, + 'skipped_functions': skipped_functions, + 'errors': errors + [error_msg], + } + + except Exception as e: # noqa: BLE001 + error_msg = f'Unexpected error during recovery mode deactivation: {str(e)}' + print(error_msg) + return { + 'unthrottled_functions': unthrottled_functions, + 'skipped_functions': skipped_functions, + 'errors': errors + [error_msg], + } + + +def main(): + """Main script execution.""" + parser = argparse.ArgumentParser( + description='End recovery mode by unthrottling Lambda functions', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" + Examples: + python end_recovery_mode.py --environment Test + python end_recovery_mode.py -e Beta + """, + ) + + parser.add_argument('-e', '--environment', required=True, help='Environment name (Test, Beta, Prod, Sandbox)') + + args = parser.parse_args() + + try: + environment_name = validate_environment(args.environment) + except ValueError as e: + print(f'Environment validation failed: {e}') + return + + # Confirmation prompt + print(f'\n🔓 This will restore normal Lambda function operations for the {args.environment} environment.') + print('All reserved concurrency throttling will be removed.') + print('Application functionality will be restored.') + + response = input(f'\nAre you sure you want to end recovery mode for {args.environment}? (yes/no): ') + + if response.lower() not in ['yes', 'y']: + print('Operation cancelled.') + sys.exit(0) + + # Execute the unthrottling operation + result = unthrottle_lambda_functions(environment_name) + + print(f' Functions unthrottled: {len(result["unthrottled_functions"])}') + print(f' Functions skipped: {len(result["skipped_functions"])}') + + if result.get('failed_functions') or result.get('errors'): + print(f' Functions that failed: {result.get("failed_functions", "unknown")}') + print(f' Errors: {result.get("errors", "unknown")}') + else: + print('\n✅ Recovery mode deactivation completed') + print(f"\n🔓 Environment '{args.environment}' recovery mode has been ended.") + print('Normal application operations have been restored!') + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/disaster_recovery/start_recovery_mode.py b/backend/social-work-app/disaster_recovery/start_recovery_mode.py new file mode 100755 index 0000000000..8f3cd1fc9c --- /dev/null +++ b/backend/social-work-app/disaster_recovery/start_recovery_mode.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +# ruff: noqa: T201 we use print statements for local scripts +""" +Disaster Recovery - Start Recovery Mode Script + +This script throttles all Lambda functions in the account except Disaster Recovery functions +by setting their reserved concurrency to 0. This effectively puts the system into +recovery mode during disaster recovery operations. + +Usage: + python start_recovery_mode.py --environment + +Example: + python start_recovery_mode.py --environment Test + python start_recovery_mode.py --environment Beta + python start_recovery_mode.py --environment Prod + +Requirements: + - AWS CLI configured with appropriate credentials + - boto3 installed +""" + +import argparse +import sys + +import boto3 +from botocore.exceptions import ClientError + + +def validate_environment(environment_name: str) -> str: + """ + Validate and normalize the environment name. + param: environment_name: Environment name to validate + + return: Normalized environment name in title case + raise ValueError: If environment name is invalid + """ + if not environment_name: + raise ValueError('Environment name cannot be empty') + + # Normalize to title case for validation and consistency + normalized = environment_name.strip().title() + valid_environments = ['Test', 'Beta', 'Prod', 'Sandbox'] + + if normalized not in valid_environments: + raise ValueError(f"Invalid environment '{environment_name}'. Valid options: {valid_environments}") + + return normalized + + +def throttle_lambda_functions(environment_name: str, dry_run: bool = False) -> dict: + """ + Throttle all Lambda functions for the specified environment except DR functions. + + param: environment_name: Environment to throttle functions for + param: dry_run: If True, only simulate the actions without making changes + + return: Dict containing results of the operation + """ + lambda_client = boto3.client('lambda') + + # Environment prefix for filtering functions (e.g., "Test-", "Beta-", "Prod-") + environment_prefix = f'{environment_name}-' + + print(f'{"[DRY RUN] " if dry_run else ""}Starting recovery mode for environment: {environment_name}') + print(f'Function prefix filter: {environment_prefix}') + + throttled_functions = [] + skipped_functions = [] + errors = [] + failed_functions = [] + + try: + # Use paginator to handle accounts with many Lambda functions + paginator = lambda_client.get_paginator('list_functions') + total_functions_checked = 0 + + for page in paginator.paginate(): + for function in page['Functions']: + function_name = function['FunctionName'] + total_functions_checked += 1 + + # Skip functions that don't match the environment prefix + if not function_name.startswith(environment_prefix): + print(f'Skipping {function_name} - does not match environment prefix {environment_prefix}') + skipped_functions.append( + {'function_name': function_name, 'reason': 'Does Not Match environment prefix'} + ) + continue + + # Skip Disaster Recovery functions to keep them operational + if 'DisasterRecovery' in function_name: + print(f'Skipping DR function: {function_name}') + skipped_functions.append({'function_name': function_name, 'reason': 'Disaster Recovery function'}) + continue + + if dry_run: + print(f'[DRY RUN] Would throttle function: {function_name}') + throttled_functions.append(function_name) + continue + + try: + # Set reserved concurrency to 0 to effectively throttle the function + lambda_client.put_function_concurrency(FunctionName=function_name, ReservedConcurrentExecutions=0) + + print(f'Successfully throttled function: {function_name}') + throttled_functions.append(function_name) + + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + + error_msg = f'Error throttling {function_name}: {error_code} - {error_message}' + print(error_msg) + errors.append(error_msg) + failed_functions.append(function_name) + + except Exception as e: # noqa: BLE001 + error_msg = f'Unexpected error throttling {function_name}: {str(e)}' + print(error_msg) + errors.append(error_msg) + failed_functions.append(function_name) + + if errors: + print(f'Encountered {len(errors)} errors during throttling process') + + return { + 'throttled_functions': throttled_functions, + 'skipped_functions': skipped_functions, + 'failed_functions': failed_functions, + 'errors': errors, + 'total_functions_checked': total_functions_checked, + } + + except ClientError as e: + error_msg = f'Failed to list Lambda functions: {str(e)}' + print(error_msg) + return { + 'error': error_msg, + 'throttled_functions': throttled_functions, + 'skipped_functions': skipped_functions, + 'errors': errors + [error_msg], + } + + except Exception as e: # noqa: BLE001 + error_msg = f'Unexpected error during recovery mode activation: {str(e)}' + print(error_msg) + return { + 'error': error_msg, + 'throttled_functions': throttled_functions, + 'skipped_functions': skipped_functions, + 'errors': errors + [error_msg], + } + + +def main(): + """Main script execution.""" + parser = argparse.ArgumentParser( + description='Start recovery mode by throttling Lambda functions', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" + Examples: + python start_recovery_mode.py --environment Test + python start_recovery_mode.py --environment Prod --dry-run + python start_recovery_mode.py -e Beta + """, + ) + + parser.add_argument('-e', '--environment', required=True, help='Environment name (Test, Beta, Prod, Sandbox)') + + parser.add_argument('--dry-run', action='store_true', help='Show what would be done without making changes') + + args = parser.parse_args() + + try: + environment_name = validate_environment(args.environment) + except ValueError as e: + print(f'Environment validation failed: {e}') + return + + # Confirmation prompt unless --dry-run is used + if not args.dry_run: + print(f'\n⚠️ WARNING: This will throttle ALL Lambda functions in the {args.environment} environment!') + print('This action will effectively stop all application functionality.') + print('Only Disaster Recovery functions will remain operational.') + print('\nThis action should only be performed during disaster recovery operations.') + + response = input(f'\nAre you sure you want to start recovery mode for {args.environment}? (yes/no): ') + + if response.lower() not in ['yes', 'y']: + print('Operation cancelled.') + sys.exit(0) + + # Execute the throttling operation + result = throttle_lambda_functions(environment_name, dry_run=args.dry_run) + + action = 'would be throttled' if args.dry_run else 'throttled' + print(f' Functions {action}: {len(result["throttled_functions"])}') + print(f' Functions skipped: {len(result["skipped_functions"])}') + + # Show failed functions if any + if result.get('failed_functions') or result.get('errors'): + print(f'\n❌ Recovery mode {"dry run" if args.dry_run else "activation"} failed') + print(f' Functions that failed: {result.get("failed_functions", "unknown")}') + print(f' Errors: {result.get("errors", "unknown")}') + else: + print(f'\n✅ Recovery mode {"dry run" if args.dry_run else "activation"} completed') + if not args.dry_run: + print(f"\n🔒 Environment '{args.environment}' is now in recovery mode.") + print("Remember to run 'end_recovery_mode.py' when disaster recovery is complete!") + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/docs/README.md b/backend/social-work-app/docs/README.md new file mode 100644 index 0000000000..8297fbea93 --- /dev/null +++ b/backend/social-work-app/docs/README.md @@ -0,0 +1,132 @@ +# CompactConnect - technical user guide + +This documentation is intended for technical IT staff that plan to integrate with this data system. It will likely grow +as the features of this system grow. For technical documentation of the internal design of the CompactConnect backend, +look [here](./design/README.md). + +## Introduction + +TheSocial Workcompact commission is building a system to share professional licensure data between state licensing boards to facilitate participation in the occupational licensure compact. + +## Table of Contents +- **[How to use the API bulk-upload feature](#how-to-use-the-api-bulk-upload-feature)** +- **[Frequently Asked Questions](#frequently-asked-questions)** +- **[Open API Specification](#open-api-specification)** + +## How to use the API bulk-upload feature +[Back to top](#compact-connect---technical-user-guide) + +### Generating a CSV export of your license data + +Export your license data to a CSV file, formatted as follows: + - The file must be a utf-8 encoded text format + - The first line must be a header with column names exactly matching the field names listed in + [the table below](#field-descriptions). + - All subsequent lines must be individual licenses. + - At least all required fields must be present as a column and required fields cannot be empty in any row. + - Any optional fields may also be included. Optional fields can be left empty in some rows. + - Order of columns does not matter. + - String lengths are enforced - exceeding them will cause validation errors + - Some fields have a set list of allowed values. For those fields, make sure to enter the value exactly, including + spacing and capitalization + - SSNs must be unique within a single CSV upload file. Do not include multiple rows with the same `ssn` in one file. If duplicate SSNs are sent within the same file, the first row will be processed, but all other duplicate rows will be rejected. + +#### Field Descriptions + +The following table describes all available fields for the license CSV file. Required fields are marked with an asterisk +(*). Note that our API does not accept `NULL` values - if some of your licenses have no value for an optional field, +leave the field entirely empty. If some of your licenses are missing a required field, those licenses will be rejected. + +| Field Name | Description | Format | Example | +|------------|-------------|---------|---------| +| dateOfBirth* | Provider's date of birth | YYYY-MM-DD | 1980-01-31 | +| dateOfExpiration* | License expiration date | YYYY-MM-DD | 2025-12-31 | +| dateOfIssuance* | Date when license was originally issued | YYYY-MM-DD | 2020-01-01 | +| dateOfRenewal | Most recent license renewal date | YYYY-MM-DD | 2023-01-01 | +| familyName* | Provider's family/last name | String (max 100 chars) | Smith | +| givenName* | Provider's given/first name | String (max 100 chars) | John | +| homeAddressCity* | City of provider's home address | String (max 100 chars) | Springfield | +| homeAddressPostalCode* | Postal/ZIP code of provider's home address | String (5-7 chars) | 12345 | +| homeAddressState* | State/province of provider's home address | String (max 100 chars) | IL | +| homeAddressStreet1* | First line of provider's street address | String (max 100 chars) | 123 Main St | +| licenseNumber* | License number | String (max 100 chars) | OT12345 | +| licenseType* | Type of professional license. Types you provide must be associated with the compact you are uploading for. | One of: `cosmetologist`, `esthetician` | cosmetologist | +| ssn* | Social Security Number | Format: XXX-XX-XXXX | 123-45-6789 | +| licenseStatus* | Current status of the license. "active" means they are allowed to practice their profession. *Note: licenses will automatically be displayed as `inactive` after their date of expiration, even if the last upload still showed them as `active`.* | One of: `active`, `inactive` | active | +| licenseStatusName | An optional more descriptive name of the license status. | String (max 100 chars) | SUSPENDED | +| compactEligibility* | Whether this license makes the licensee eligible to participate in the compact based on the compact's requirements. Cannot be `eligible` if licenseStatus is `inactive`. *Note: licenses will automatically be displayed as `ineligible` after their date of expiration, even if the last upload still showed them as `eligible`.* | One of: `eligible`, `ineligible` | eligible | +| emailAddress | Provider's email address (optional) | Email (max 100 chars) | john.smith@example.com | +| homeAddressStreet2 | Second line of provider's street address (optional) | String (max 100 chars) | Suite 100 | +| middleName | Provider's middle name (optional) | String (max 100 chars) | Robert | +| phoneNumber | Provider's phone number (optional) | [ITU-T E.164 format](https://www.itu.int/rec/T-REC-E.164-201011-I/en) (must include country code, no spaces or dashes) | +12025550123 | +| suffix | Provider's name suffix (optional) | String (max 100 chars) | Jr. | +#### Example CSV +```csv +dateOfIssuance,licenseNumber,dateOfBirth,licenseType,familyName,homeAddressCity,middleName,licenseStatus,licenseStatusName,compactEligibility,ssn,homeAddressStreet1,homeAddressStreet2,dateOfExpiration,homeAddressState,homeAddressPostalCode,givenName,dateOfRenewal +2024-06-30,A0608337260,2024-06-30,cosmetologist,Guðmundsdóttir,Birmingham,Gunnar,active,ACTIVE,eligible,529-31-5408,123 A St.,Apt 321,2024-06-30,oh,35004,Björk,2024-06-30 +2024-06-30,B0608337260,2024-06-30,esthetician,Scott,Huntsville,Patricia,active,ACTIVE,eligible,529-31-5409,321 B St.,,2024-06-30,oh,35005,Elizabeth,2024-06-30 +2024-06-30,C0608337260,2024-06-30,cosmetologist,毛,Hoover,泽,active,ACTIVE,eligible,529-31-5410,10101 Binary Ave.,,2024-06-30,oh,35006,覃,2024-06-30 +2024-06-30,D0608337260,2024-06-30,cosmetologist,Adams,Tuscaloosa,Michael,inactive,EXPIRED,ineligible,529-31-5411,1AB3 Hex Blvd.,,2024-06-30,oh,35007,John,2024-06-30 +2024-06-30,E0608337260,2024-06-30,cosmetologist,Carreño Quiñones,Montgomery,José,active,ACTIVE_IN_RENEWAL,eligible,529-31-5412,10 Main St.,,2024-06-30,oh,35008,María,2024-06-30 +``` + +### Manual Uploads + +1) Request a staff user with permissions you need. +2) Log into CompactConnect with your new user. +3) Navigate to the bulk-upload page to upload your exported CSV. It may take about five minutes for uploaded licenses to + be fully ingested and appear in the system. + +Note that CSV uploads are an asynchronous process, meaning that **the data you upload may still have errors and will not show up in CompactConnect even if the file is uploaded successfully.** CompactConnect will process all the valid records in the CSV file, and will report on any licenses in the file that could not be processed due to validation error. In order to receive data validation error notifications from CompactConnect, your state administrator must configure your email address as a point of contact for operation support. See [System Configuration section of the Staff User Documentation](../../../staff-user-documentation/README.md#system-configuration) + +### Machine-to-machine automated uploads + +The data system API supports uploading of a large CSV file for asynchronous data ingest, as well as JSON license records +for synchronous data ingest. See the [CompactConnect Automated License Data Upload Instructions](./it_staff_onboarding_instructions.md) for more information. + +## Frequently Asked Questions +[Back to top](#compact-connect---technical-user-guide) + +### What if we don't have data for an optional field? + +If data is not available for an optional field, it must be left empty in the case of CSV data (or omit the CSV column entirely if none of the license records contain that field). In the case of JSON uploads, the key must be excluded from the object. Please do not include NULL or similar N/A type values for missing data. + +**CSV Example with missing optional fields:** +```csv +dateOfIssuance,licenseNumber,dateOfBirth,licenseType,familyName,homeAddressCity,middleName,licenseStatus,licenseStatusName,compactEligibility,ssn,homeAddressStreet1,homeAddressStreet2,dateOfExpiration,homeAddressState,homeAddressPostalCode,givenName,dateOfRenewal +2024-06-30,COS12345,2024-06-30,cosmetologist,Guðmundsdóttir,Birmingham,,active,,eligible,529-31-5408,123 A St.,,2024-06-30,oh,35004,Björk, +``` + +### What if we don't have data for a required field? + +If data is not available for a required field, that particular license record cannot be uploaded into CompactConnect. Required fields must be included or the record will not be accepted. Please do not include NULL or empty values for required fields. + +### Can we upload the same licenses multiple times? What if their information changes? + +Yes. CompactConnect is designed to automatically detect and track changes to license records over time. When you upload a license record, CompactConnect will determine if the record currently exists in the CompactConnect database using the provided SSN to match with any existing licensee in the system, and create the record if not found. If the license record already exists, CompactConnect will check the differences between the existing record in the system and changes uploaded by the state, and apply the changes accordingly. + +### Which of these license values will be publicly visible? + +The following license fields are publicly visible through CompactConnect's public lookup endpoints: + +- Licensee name (given, middle, family, suffix). +- The jurisdiction and compact their license is associated with. +- License status and compact eligibility. + +**Fields that are NOT publicly visible include:** +- Social Security Numbers +- Full addresses (street, city, state, postal code) +- Email addresses +- Phone numbers +- Date of birth + +## Open API Specification +[Back to top](#compact-connect---technical-user-guide) + +We will maintain the latest api specification here, in [latest-oas30.json](api-specification/latest-oas30.json). You can +use [Swagger.io](https://editor.swagger.io/) to render the json directly or, if you happen to use an IDE that supports +the feature, you can open a Swagger UI view of it by opening up the accompanying +[swagger.html](api-specification/swagger.html) in your browser. + +Note that HTTP request headers such as `Content-Type` and `User-Agent` are important to the API and should be +transmitted with HTTP requests. Most of the API only accepts a `Content-Type` of `application/json`. diff --git a/backend/social-work-app/docs/api-specification/latest-oas30.json b/backend/social-work-app/docs/api-specification/latest-oas30.json new file mode 100644 index 0000000000..8412e41d52 --- /dev/null +++ b/backend/social-work-app/docs/api-specification/latest-oas30.json @@ -0,0 +1,367 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "StateApi", + "version": "2026-02-16T16:53:09Z" + }, + "servers": [ + { + "url": "https://state-api.beta.compactconnect.org", + "x-amazon-apigateway-endpoint-configuration": { + "disableExecuteApiEndpoint": true + } + } + ], + "paths": { + "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses": { + "post": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSStateGpk4PxFv8Eew" + } + } + }, + "required": true + }, + "responses": { + "400": { + "description": "400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSStatevE5TiToQrRwN" + } + } + } + }, + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSState2d1wqb3JR6OX" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestStateAPIStackStateApiStateAuthAuthorizerBD69FBB7": [ + "socw/write", + "al/socw.write", + "az/socw.write", + "co/socw.write", + "ks/socw.write", + "ky/socw.write", + "md/socw.write", + "oh/socw.write", + "tn/socw.write", + "va/socw.write", + "wa/socw.write" + ] + } + ] + } + }, + "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses/bulk-upload": { + "get": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSState4LF6rLqTTc6G" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestStateAPIStackStateApiStateAuthAuthorizerBD69FBB7": [ + "socw/write", + "al/socw.write", + "az/socw.write", + "co/socw.write", + "ks/socw.write", + "ky/socw.write", + "md/socw.write", + "oh/socw.write", + "tn/socw.write", + "va/socw.write", + "wa/socw.write" + ] + } + ] + } + } + }, + "components": { + "schemas": { + "TestSState4LF6rLqTTc6G": { + "required": [ + "upload" + ], + "type": "object", + "properties": { + "upload": { + "required": [ + "fields", + "url" + ], + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + } + } + }, + "TestSState2d1wqb3JR6OX": { + "required": [ + "message" + ], + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "A message about the request" + } + } + }, + "TestSStateGpk4PxFv8Eew": { + "maxItems": 100, + "type": "array", + "items": { + "required": [ + "compactEligibility", + "dateOfBirth", + "dateOfExpiration", + "dateOfIssuance", + "familyName", + "givenName", + "homeAddressCity", + "homeAddressPostalCode", + "homeAddressState", + "homeAddressStreet1", + "licenseNumber", + "licenseStatus", + "licenseType", + "ssn" + ], + "type": "object", + "properties": { + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string", + "description": "The provider's social security number" + }, + "licenseType": { + "type": "string", + "enum": [ + "cosmetologist", + "esthetician" + ] + }, + "emailAddress": { + "maxLength": 100, + "minLength": 5, + "type": "string", + "format": "email" + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + }, + "TestSStatevE5TiToQrRwN": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message indicating success or failure" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "array", + "description": "List of error messages for a field", + "items": { + "type": "string" + } + }, + "description": "Errors for a specific record" + }, + "description": "Validation errors by record index" + } + } + } + }, + "securitySchemes": { + "TestBackendCosmetologyTestStateAPIStackStateApiStateAuthAuthorizerBD69FBB7": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "cognito_user_pools" + } + } + }, + "x-amazon-apigateway-security-policy": "TLS_1_0" +} diff --git a/backend/social-work-app/docs/api-specification/swagger.html b/backend/social-work-app/docs/api-specification/swagger.html new file mode 100644 index 0000000000..44396776c4 --- /dev/null +++ b/backend/social-work-app/docs/api-specification/swagger.html @@ -0,0 +1,22 @@ + + + + + + + SwaggerUI + + + +
+ + + + diff --git a/backend/social-work-app/docs/design/README.md b/backend/social-work-app/docs/design/README.md new file mode 100644 index 0000000000..9545f864ea --- /dev/null +++ b/backend/social-work-app/docs/design/README.md @@ -0,0 +1,548 @@ +# Backend design + +Look here for continued documentation of the back-end design, as it progresses. + +## Table of Contents +- **[Compacts and Jurisdictions](#compacts-and-jurisdictions)** +- **[License Ingest](#license-ingest)** +- **[User Architecture](#user-architecture)** +- **[Data Model](#data-model)** +- **[Multi-State License Model / Privilege Generation](#multi-state-license-model--privilege-generation)** +- **[Advanced Data Search](#advanced-data-search)** +- **[CI/CD Pipelines](#cicd-pipelines)** +- **[Audit Logging](#audit-logging)** + +## Compacts and Jurisdictions + +The CompactConnect system supports multiple licensure compacts and, within each compact, multiple jurisdictions. The +jurisdictions it supports within each compact is all 50 states, Washington D.C., Puerto Rico, and the Virgin Islands. + +### Adding a compact to CompactConnect + +When a new compact joins CompactConnect, some configuration has to be done to add them to the system. First, a new +entry has to be added to the list of supported compacts, found in [cdk.json](../../cdk.json). Each compact is +represented there with an abbreviation, which determines how the compact will be represented in the API as well as +in its corresponding Oauth2 access scopes. Because of the way that the scopes are represented, the compact abbreviation +must not overlap with any jurisdiction abbreviations (which correspond to the jurisdictions' USPS postal abbreviations). +**Since postal abbreviations are all two letters, make a point to choose a compact abbreviation that is at least four +letters for clarity and to avoid naming conflicts.** Note that the compact abbreviations in this system do no +necessarily need to match the ones used publicly by those compacts. It only affects how the compact is represented +in the REST API and its access token scopes. + +Once the supported compacts have been updated and the configuration change deployed, a CompactConnect admin can create +a user for the compact's executive director, who then will be allowed to start creating users for the boards of each +jurisdiction within the compact. + +## License Ingest +[Back to top](#backend-design) + +To facilitate sharing of license data across states, compact member jurisdictions will periodically upload data for +eligible licensees to CompactConnect. See [license-ingest-digram.pdf](./license-ingest-diagram.pdf) for an illustration +of the ingest chain architecture. Board admins and/or information systems have two primary methods of upload: +- A direct HTTP POST method, where they can synchronously validate up to 100 licenses per call. +- A bulk-upload mechanism that allows submitting of a CSV file with a much larger number of licenses for asynchronous + validation and ingest. + +### SSN Access Controls +The system implements strict controls for SSN access: + +1. **Dedicated SSN Table**: All SSN data is stored in a dedicated DynamoDB table with strict access controls and + customer-managed KMS encryption. +2. **Limited API Access**: Only specific API endpoints can query SSN data for staff users with the proper `readSSN` + scope. +3. **Comprehensive Audit Logging**: + - All SSN data access through the application is logged with user identity, timestamp, and access context + - Direct database access is independently tracked through our secure audit logging system (see + [Audit Logging](#audit-logging)) +4. **Restricted Operations**: The SSN table policy explicitly denies batch operations to prevent mass data extraction. + +#### SSN Role-Based Access +Three specialized IAM roles control access to SSN data: + - `license_upload_role`: Used by upload handlers to encrypt SSN data for the preprocessing queue. + - `ingest_role`: Used by the license preprocessor to create and update SSN records in the SSN table. + - `api_query_role`: Used by the Get SSN API endpoint to allow staff users to read the SSN for an individual provider + per request (staff user must have the readSSN permission). + +### Ingest Flow + +The ingest process begins when license data enters the system through one of the following two methods: + +#### HTTP POST + Clients can directly post an array of up to 100 licenses to the license data API. If they do this, the API will +validate each license synchronously and return any validation errors to the client. If the licenses are valid, +the API will send the validated licenses to the preprocessing queue. + +#### Bulk Upload + To upload a bulk license file, clients use an authenticated GET endpoint to receive a +[presigned url](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html) that will allow the +client to directly upload their file to s3. Once the file is uploaded to s3, an s3 event triggers a lambda to read and +validate each license in the data file, then fire either a success or failure event to the license data event bus. + +Both of these upload methods will place license records containing full SSNs in an SQS queue which is encrypted with the +same KMS key as the SSN table to invoke the license preprocessor Lambda function. + +#### **License Preprocessing**: + - A Lambda function processes messages from the encrypted queue + - For each license, it: + - Extracts the full SSN from the license data and creates/updates a record in the SSN table, which becomes + associated with a provider ID. This provider id is unique to the CompactConnect system and is used to generate + provider records within the system. + - After creating the SSN record, the lambda Publishes an event to the data event bus with the license data + (minus the full SSN) + + The event bus then triggers the license data processing Lambda function. + +#### **License Data Processing**: + - The data event bus receives the sanitized license events + - Downstream processors create provider and license records in the provider table, using only the last four digits + of the SSN + +This architecture ensures that SSN data is protected throughout the ingest process while still allowing the system to +associate licenses with the correct providers across jurisdictions. + +### Asynchronous validation feedback +Asynchronous validation feedback for boards to review is not yet implemented. + +## User Architecture +[Back to top](#backend-design) + +Authentication with the CompactConnect backend will be controlled through Oauth2 via +[AWS Cognito User Pools](https://github.com/csg-org/CompactConnect). Clients will be divided into two groups, each +represented by an independent User Pool: [Staff Users](#staff-users) and [Licensee Users](#licensee-users). See +the accompanying [architecture diagram](./users-arch-diagram.pdf) for an illustration. + +### Staff Users + +Staff users will be granted a variety of different permissions, depending on their role. Read permissions are granted +to a user for an entire compact or not at all. Data writing and user administration permissions can each be granted to +a user per compact/jurisdiction combination. All of a compact user's permissions are stored in a DynamoDB record that is +associated with their own Cognito user id. That record will be used to generate scopes in the Oauth2 token issued to +them on login. See [Implementation of scopes](#implementation-of-scopes) for a detailed explanation of the design for +exactly how permissions will be represented by scopes in an access token. See +[Implementation of permissions](#implementation-of-permissions) for a detailed explanation of the design for exactly +how permissions are stored and translated into scopes. + +#### Common Staff User Types +The system permissions are designed around several common types of staff users. It is important to note that these user +types are an abstraction which do not correlate directly to specific roles or access within the system. All access is +controlled by the specific permissions associated with a user. Still, these abstractions are useful for understanding +the system's design. + +##### Compact Executive Directors and Staff + +Compact ED level staff will typically be granted the following permissions at the compact level: + +- `admin` - grants access to administrative functions for the compact, such as creating and managing users and their + permissions. +- `readPrivate` - grants access to view all data for any licensee within the compact. + +With the `admin` permission, they can grant other users the ability to write data for a particular +jurisdiction and to create more users associated with a particular jurisdiction. They can also delete any user within +their compact, so long as that user does not have permissions associated with a different compact, in which case the +permissions from the other compact would have to be removed first. + +Users granted any of these permissions will also be implicitly granted the `readGeneral` scope for the associated +compact, which allows them to read any licensee data within that compact that is not considered private. + +##### Board Executive Directors and Staff + +Board ED level staff may be granted the following permissions at a jurisdiction level: + +- `admin` - grants access to administrative functions for the jurisdiction, such as creating and managing users and +their permissions. +- `write` - grants access to write data for their particular jurisdiction (ie uploading license information). +- `readPrivate` - grants access to view all information for any licensee that has either a license or privilege within + their jurisdiction (except the full SSN, see `readSSN` permission below. This permission allows viewing the last 4 + digits of the SSN). +- `readSSN` - grants access to view the full SSN for any licensee that has either a license or privilege within their + jurisdiction. + +#### Implementation of Scopes + +AWS Cognito integrates with API Gateway to provide +[authorizers](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html) +on an API that can verify the tokens issued by a given User Pool and to protect access based on scopes belonging to +[Resource Servers](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-define-resource-servers.html) +associated with that User Pool. + +The Staff Users pool implements authorization using resource servers configured for each jurisdiction and compact. This +design allows for efficient management of permissions while staying within AWS Cognito's limits (100 scopes per +resource server, 300 resource servers per user pool). + +Each jurisdiction has its own resource server with scopes that control access to that jurisdiction's data across +different compacts. For example, the Kentucky (KY) resource server would have scopes like: + +``` +ky/socw.admin +ky/socw.write +ky/socw.readPrivate +ky/socw.readSSN +ky/octp.admin +ky/octp.write +ky/octp.readPrivate +ky/octp.readSSN +``` + +If a user has the `ky/aslp.admin` scope, for example, they will be able to perform any admin action within the Kentucky +jurisdiction within the ASLP compact. + +Each compact also has its own resource server with compact-wide scopes, which are used to control access to data across +all jurisdictions within a compact: + +``` +socw/admin +socw/readGeneral +socw/readPrivate +socw/readSSN +``` + +If a user has the `aslp/admin` scope, for example, they will be able to perform any admin action for any jurisdiction +within the compact. + +Staff users in a compact will also be implicitly granted the `readGeneral` scope for the associated compact, +which allows them to read any licensee data that is not considered private. + +In addition to the `readGeneral` scope, there is a `readPrivate` scope, which can be granted at both compact and +jurisdiction levels. This permission indicates the user can read all of a compact's provider data (licenses and +privileges), so long as the provider has at least one license or privilege within their jurisdiction or the user has +compact-wide permissions. + +#### Implementation of Permissions + +Staff user permissions are stored in a dedicated DynamoDB table, which has a single record for each user +and includes a data structure that details that user's particular permissions. Cognito allows for a lambda to be +[invoked just before it issues a token](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html). +We use that feature to retrieve the database record for each user, parse the permissions data and translate those +into scopes, which will be added to the Cognito token. The lambda generates scopes based on both compact-level and +jurisdiction-level permissions, ensuring consistent access control at token issuance. + +#### Machine-to-machine app clients + +See README under the [app_clients](../../app_clients/README.md) directory for more information about how +machine-to-machine app clients are configured and used in the system. + + +## Data Model +[Back to top](#backend-design) + +CompactConnect uses a single noSQL (DynamoDB) table design for storing provider (practitioner) data, following the +[single table design](https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/) +pattern. This approach optimizes for: + +1) **Compact-Level Partitioning**: Data is always partitioned by compact, ensuring that queries never return records + from multiple compacts. This enforces a deliberate separation of data across all layers - UI, API, and database - + where users must explicitly specify which compact's data they're accessing. + +2) **Query Efficiency**: Access patterns are optimized so most data needs can be satisfied in a single query, leveraging + DynamoDB's single-digit-millisecond latency at any scale. + +### Record Types in Detail + +The data model comprises the following stored record types (note: Privileges are not stored in the DB; they are generated at API +runtime from licenses, adverse actions, and investigations, see [Multi-State License Model / Privilege Generation](#multi-state-license-model--privilege-generation)). + +1. **Provider Record** (`provider`): The core record containing a provider's foundational information: + - Personal details (name, DOB, contact information) + - Home address + - License jurisdiction of record + - SSN last four digits (full SSN is stored separately for security) + - National Provider Identifier (NPI) + - Set of privilege jurisdictions + - Status (calculated based on expiration date and jurisdiction status) + +2. **License Record** (`license`): Represents professional licenses held by the provider: + - License number and type + - Issuing jurisdiction + - Issuance, renewal, and expiration dates + - License status (active/inactive, calculated at load time, based on current time, expiry, and other factors) + - Provider's name and contact details at time of issuance + +3. **License Update Record** (`licenseUpdate`): Tracks historical changes to licenses: + - Update type (renewal, deactivation, or other) + - Previous values before the update + - Updated values that changed + - List of values that were removed + - Timestamp of the update + - Change hash for uniqueness + +4. **Provider Update Record** (`providerUpdate`): Tracks historical changes to the provider record (e.g. demographics, + home address). + +5. **Adverse Action Record** (`adverseAction`): Encumbrances and related actions against a license or privilege + (jurisdiction + license type). Used when generating privilege status at runtime. + +6. **Investigation Record** (`investigation`): Open or closed investigations against a license or privilege. Used when + generating privilege status at runtime. + +A single query for a provider's partition with a sort key starting with `{compact}#PROVIDER` retrieves all stored +records needed to construct a complete view of the provider. Privileges are then derived at read time from licenses, +adverse actions, and investigations. + +### Historical Tracking + +CompactConnect maintains a comprehensive historical record of each provider from their first addition to the system. +Any change to a provider's status, dates, or demographic information creates a supporting record that tracks the change. + +For license changes, records use sort keys like `socw#PROVIDER#license/oh#UPDATE#1735232821/1a812bc8f`. This key +contains: +- The jurisdiction (e.g., "oh") +- "UPDATE" indicator +- POSIX timestamp of the change +- A hash of the previous and updated values for uniqueness + +This historical tracking allows authorized users to determine a provider's practice eligibility status in any member +state for any point in time since they entered the system. + +### Global Secondary Indexes (GSIs) + +The provider table includes several GSIs to support different access patterns: + +1. **Provider Name Index** (`providerFamGivMid`): + - Enables searching providers by name + - Uses a composite sort key of quoted and lowercase family name, given name, and middle name + +2. **Provider Update Time Index** (`providerDateOfUpdate`): + - Allows retrieving providers by the date they were last updated + - Useful for getting recently modified provider records + +3. **License GSI** (`licenseGSI`): + - Facilitates finding licenses by jurisdiction and provider name + - Supports compact and jurisdiction-specific queries + +### Security and Status Calculation + +The model incorporates several security and operational features: + +1. **SSN Protection**: Only the last four digits of SSNs are stored in the provider table. Full SSNs are stored in a + separate, highly secured table with strict access controls. + +2. **Status Calculation**: Rather than storing a simple status flag, the system calculates status at read time based on: + - The jurisdiction's reported status (active/inactive) + - The current date compared to the expiration date + - This ensures accurate representation of a provider's current status without requiring updates + +3. **Historical Tracking**: All changes are preserved as separate records, allowing point-in-time deduction of a + provider's status for any historical date. + +This comprehensive data model enables efficient queries while maintaining complete historical data, supporting both +operational needs and audit requirements for healthcare provider licensing across jurisdictions. + +## Multi-State License Model / Privilege Generation +[Back to top](#backend-design) + +Privileges are authorizations that allow licensed providers to practice their profession in jurisdictions other than their home state. TheSocial WorkCompact follows a multi-state licensure model, where privileges are automatically granted to a licensee as a result of having a multi-state license in their home state. The list of jurisdictions where privileges are granted is determined by which states have onboarded into the CompactConnect system for theSocial WorkCompact. + +Because the list of privilege records for practitioners is dynamically determined by the number of states that have onboarded into the system, theSocial Workbackend does **not** store privilege records in the database; privileges are **generated at API runtime** by referencing other stored values in the database such as license records, adverse actions, and investigations. + +### Privilege Runtime Generation for Multi-State Licenses + +When a practitioner has licenses uploaded by multiple states, the system must choose a **home state license** per license type. Unlike the JCC model, where practitioners register under a specific home state, thisSocial Worksystem does not currently allow the user to specify which state is their current home state. It was determined that the home state license would be automatically selected based on which license was issued or renewed most recently. Privileges are then generated from that home license: one privilege per compact member jurisdiction (other than the home jurisdiction) for that license type. + +This means that if a practitioner has two licenses from two different states, if one is eligible for privileges and the other is not, the system will only generate privileges for that practitioner if the most recently issued/renewed license is eligible for privileges. + +The following flow describes how the home state license is assigned. + +([Social Work Practitioner License Assignment Flow](./practitioner-home-state-license-assignment.pdf)) + + +Licenses are **grouped by license type**. For each type, the system picks the **most recently renewed license** as the effective “home” license for that type. If date of renewal cannot be determined for either license, it falls back to use the most recent date of issuance. + +For each such home license (per type), the system generates **one privilege per live compact jurisdiction** except the home jurisdiction. Each privilege’s status (active/inactive, under investigation) is derived from adverse actions and investigations for that jurisdiction and license type. + +### Overview of Privilege System + +The privilege system is built around several core concepts: + +1. **Home Jurisdiction (per license type)**: The state of the license that is chosen as the “home” license for that type (see flow above). +2. **Privilege Jurisdictions**: Other compact member states where the provider can practice under the compact; one generated privilege per (jurisdiction, license type). +3. **License-Based Eligibility**: Privileges are derived from stored licenses and are only generated when the chosen home license for that type is valid and compact-eligible. Status is then modified by adverse actions and investigations. + +#### Adverse Actions and Encumbrance +The system supports encumbering privileges via **stored adverse action records**: +- **Privilege-specific encumbrance**: An adverse action specifies a jurisdiction and license type; the generated privilege for that jurisdiction/type shows as encumbered until the action is lifted. +- **License-based encumbrance**: If the home-state license is encumbered, it is ineligible for privileges so none are generated at runtime. + +## Advanced Data Search +[Back to top](#backend-design) + +To support advanced search capabilities for provider and privilege records, this project leverages +[AWS OpenSearch Service](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html). +Provider data from the provider DynamoDB table is indexed into an OpenSearch Domain (Cluster), enabling staff users to perform complex searches through the Search API (search.compactconnect.org). + +The OpenSearch resources are deployed within a Virtual Private Cloud (VPC) to provide network-level security and restrict outside access. Unlike DynamoDB, which is a fully managed and serverless AWS service that does not require (and does not support) VPC deployment, OpenSearch domains have data nodes that must be managed. Placing the OpenSearch domain in a VPC allows us to tightly control which resources and users can access it, reducing exposure to external threats. + +### Architecture Overview +![Advanced Search Diagram](./advanced-provider-search.pdf) + +The search infrastructure consists of several key components: + +1. **OpenSearch Domain**: A managed OpenSearch cluster deployed within a VPC +2. **Index Manager**: A CloudFormation custom resource that creates and manages domain indices +3. **Search API**: API Gateway endpoints backed by Lambda functions for querying the domain +4. **Populate Handler**: A Lambda function for bulk indexing all provider data from DynamoDB +5. **Provider Update Ingest Handler**: A Lambda function for updating documents in OpenSearch whenever provider records are updated in DynamoDB. + +### Document model (Social Work vs. JCC) + +Unlike the JCC CompactConnect model, which indexes **one OpenSearch document per provider** (with that provider’s licenses nested in a single document),Social Work indexes **one document per license**. Each document repeats the same top-level provider fields you would see on a provider detail response, while the `licenses` array contains **only the license represented by that document** (effectively one license entry per document). + +Social Work needs to support searching and listing **rows of license records** by license number in the search UI. OpenSearch pagination (`from`/`size`, `search_after`, etc.) applies to **documents**, not to entries inside a nested array. Splitting each license into its own document lets the UI paginate natively at license granularity. It also keeps the search API response model consistent across the compacts. + +Most practitioners only have one multi-state license, so this model does not significantly increase the size of storage used by the OpenSearch domain. + +### Index Structure + +Documents are stored in compact-specific indices with the naming convention: `compact_{compact} +_providers_{version}` +(e.g., `compact_socw_providers_v1`). We use index aliases to provide a stable reference to the current version of each index, allowing read and write operations to be transparently redirected during planned index migrations or upgrades. This enables seamless index schema changes without requiring app code changes, as applications and APIs can continue to reference the alias rather than a specific index name. See [OpenSearch index alias documentation](https://docs.opensearch.org/latest/im-plugin/index-alias/) for more information. + +#### Index Management + +The `IndexManagerCustomResource` is a CloudFormation custom resource that creates compact-specific indices when the +domain is first created. It ensures the indices/aliases exist with the correct mapping before any indexing operations begin. + +#### Index Mapping + +Each indexed document corresponds to **one license** and uses the same overall shape as the provider detail API with `readGeneral` permission. See the [application code](../../lambdas/python/search/handlers/manage_opensearch_indices.py) for the current mapping definition. Document construction (one sanitized document per license, including composite `documentId`) is implemented in [search/utils.py](../../lambdas/python/search/utils.py). + +The index uses a custom ASCII-folding analyzer for name fields, which allows searching for names with international +characters using their ASCII equivalents (e.g., searching "Jose" matches "José"). + +### Search API Endpoints + +The Search API provides two endpoints for querying the OpenSearch domain: + +#### Provider Search +``` +POST /v1/compacts/{compact}/providers/search +``` + +Returns one result row per indexed document (one per license). Each hit is a full provider-shaped document for that license row (including the single license in `licenses` and generated privileges as applicable). + +### Document Indexing + +#### Initial Population / Re-indexing + +The `populate_provider_documents` Lambda function handles bulk indexing of provider data from DynamoDB into +OpenSearch. This function is invoked manually through the AWS Console for: +- Initial data population when the search infrastructure is first deployed +- Full re-indexing if data becomes out of sync + +The function: +1. Scans the provider table using the `providerDateOfUpdate` GSI +2. Retrieves complete provider records for each provider +3. Expands each provider into **one OpenSearch document per license** (sanitized via `ProviderOpenSearchDocumentSchema`) +4. Bulk indexes documents + +**Resumable Processing**: If the function approaches the 15-minute Lambda timeout, it returns pagination information in the +`resumeFrom` field that can be passed as lambda input to continue processing: + +```json +{ + "startingCompact": "socw", + "startingLastKey": {"pk": "...", "sk": "..."} +} +``` + +**Race Condition Consideration**: A potential race condition can occur when running this function while provider data is being actively updated: + +1. The `populate_provider_documents` Lambda function queries the current data from DynamoDB for a provider +2. A change is made in DynamoDB for that same provider +3. The DynamoDB stream handler queries the data and indexes the change into OpenSearch after the ~30 second delay of sitting in SQS +4. The `populate_provider_documents` Lambda function finally indexes the stale data into OpenSearch, overwriting the change indexed by the DynamoDB stream handler + +For this reason, it is recommended that this process be run during a period of low traffic. Given that it is a one-time process to initially populate the table, the risk is low and if needed, the Lambda function can be run again to synchronize all indexed documents. + +#### Updates via DynamoDB Streams + +To keep the OpenSearch index synchronized with changes in the provider DynamoDB table, the system uses DynamoDB Streams to capture all modifications made to provider records (see [AWS documentation](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html)). This ensures that the corresponding license documents in OpenSearch are updated automatically whenever records are created, modified, or deleted in the provider table. + +**Architecture Flow:** + +1. **DynamoDB Stream**: The provider table has a DynamoDB stream enabled with `NEW_AND_OLD_IMAGES` view type, which captures both the before and after state of any record modification. + +2. **EventBridge Pipe**: An EventBridge Pipe reads events from the DynamoDB stream and forwards them to an SQS queue. + +3. **Provider Update Ingest Lambda**: The Lambda function processes SQS message batches, determines the providers that were modified, and upserts their latest information into the appropriate OpenSearch index. + +### Monitoring and Alarms + +The search infrastructure includes CloudWatch alarms for capacity monitoring. If these alarms get triggered, review +usage metrics to determine if the Domain needs to be scaled up: + +- **CPU Utilization**: Alerts when CPU exceeds threshold +- **Memory Pressure**: Monitors JVM memory pressure +- **Storage Space**: Alerts on low disk space +- **Cluster Health**: Monitors yellow/red cluster status + +## CI/CD Pipelines + +This project leverages AWS CodePipeline to deploy the backend and frontend infrastructure. See the +[pipeline architecture docs](./pipeline-architecture.md) for detailed discussion. + +## Audit Logging +[Back to top](#backend-design) + +### Overview + +CompactConnect implements a comprehensive audit logging system using AWS CloudTrail to track access to sensitive data, +particularly DynamoDB tables containing SSNs. This system provides accountability, supports audit requirements, and +enables incident investigation when needed. + +### Multi-Account Architecture + +The audit logging infrastructure is deployed across two AWS accounts for enhanced security: + +1. **Logs Account**: Contains secured S3 buckets for storing: + - CloudTrail logs from sensitive table operations + - Access logs from all buckets for comprehensive tracking + +2. **Management Account**: Hosts the CloudTrail organization trail and the KMS encryption key + +This separation follows security best practices and ensures that those who can access sensitive data can't modify the +logs of their actions, and vice versa. + +### Understanding CloudTrail Organization Trail + +AWS CloudTrail is a service that records API calls made within an AWS account. Our implementation uses an organization +trail, which provides several important capabilities: + +- **Cross-Account Visibility**: A single trail that captures activities across all AWS accounts in our organization +- **Centralized Logging**: All logs are automatically sent to a central, secured location in the logs account +- **Data Event Focus**: The trail is configured to capture specific "data events" - detailed records of when someone + reads data from sensitive tables +- **Consistent Policy**: The same logging standards are automatically applied to all accounts in the organization + +This approach ensures that all interactions with sensitive data are captured, regardless of which account the user is +operating from. + +### Logging Strategy + +The system balances comprehensive coverage with cost efficiency: + +- **Selective Logging**: We focus on tables containing the most sensitive data, particularly SSNs +- **Read Operations**: We track primarily read operations as these represent potential data exposure +- **Opt-In Design**: Tables are explicitly marked for logging with a special suffix (-DataEventsLog) + +### Key Security Features + +Several important controls protect the integrity of the audit logs: + +- **Immutable Storage**: S3 buckets with versioning and support for object locks, to prevent log deletion or + modification +- **Encryption**: KMS encryption with restricted access protects log content +- **Break-Glass Access**: A security model where even administrators need special authorization to access logs +- **Organization-wide Visibility**: The CloudTrail is configured as an organization trail, capturing events across all + accounts + +### Business Benefits + +This audit logging architecture delivers several advantages: + +- **Security Governance**: Supports audit requirements and security best practices +- **Forensic Capability**: Enables detailed investigation of any potential data misuse +- **Accountability**: Creates clear audit trails of who accessed what data and when +- **Cost Optimization**: Intelligent storage tiering and selective logging minimize expenses + +The system operates automatically in the background, requiring minimal day-to-day management while providing essential +security and governance capabilities. diff --git a/backend/social-work-app/docs/design/advanced-provider-search.pdf b/backend/social-work-app/docs/design/advanced-provider-search.pdf new file mode 100644 index 0000000000..9c0841b2fe Binary files /dev/null and b/backend/social-work-app/docs/design/advanced-provider-search.pdf differ diff --git a/backend/social-work-app/docs/design/cognito-user-states.pdf b/backend/social-work-app/docs/design/cognito-user-states.pdf new file mode 100644 index 0000000000..76333c3aff Binary files /dev/null and b/backend/social-work-app/docs/design/cognito-user-states.pdf differ diff --git a/backend/social-work-app/docs/design/high-level-arch-diagram.pdf b/backend/social-work-app/docs/design/high-level-arch-diagram.pdf new file mode 100644 index 0000000000..eda1cf59ca Binary files /dev/null and b/backend/social-work-app/docs/design/high-level-arch-diagram.pdf differ diff --git a/backend/social-work-app/docs/design/license-events-diagram.pdf b/backend/social-work-app/docs/design/license-events-diagram.pdf new file mode 100644 index 0000000000..d7c928e70e Binary files /dev/null and b/backend/social-work-app/docs/design/license-events-diagram.pdf differ diff --git a/backend/social-work-app/docs/design/license-ingest-diagram.pdf b/backend/social-work-app/docs/design/license-ingest-diagram.pdf new file mode 100644 index 0000000000..48d3830110 Binary files /dev/null and b/backend/social-work-app/docs/design/license-ingest-diagram.pdf differ diff --git a/backend/social-work-app/docs/design/multi-account-arch-diagram.pdf b/backend/social-work-app/docs/design/multi-account-arch-diagram.pdf new file mode 100644 index 0000000000..73027c3ece Binary files /dev/null and b/backend/social-work-app/docs/design/multi-account-arch-diagram.pdf differ diff --git a/backend/social-work-app/docs/design/pipeline-architecture.md b/backend/social-work-app/docs/design/pipeline-architecture.md new file mode 100644 index 0000000000..e4bf4b7fe3 --- /dev/null +++ b/backend/social-work-app/docs/design/pipeline-architecture.md @@ -0,0 +1,116 @@ +# CDK Pipeline Architecture Design + +[View Pipeline Architecture (PDF)](./pipeline-architecture.pdf) + +## Overview + +The CompactConnect CI/CD pipeline architecture implements an optimized deployment strategy built around AWS CDK +Pipelines (see https://docs.aws.amazon.com/cdk/v2/guide/cdk_pipeline.html). It follows a multi-pipeline approach with +separate backend and frontend pipelines to improve deployment speed, reliability, and security. + +## Key Components + +### Backend Pipelines + +There are different backend pipelines for each environment, defined as part of this CDK app. Those pipelines deploy +infrastructure resources and backend components to environment-specific application AWS accounts. + +### Frontend Pipelines + +There are also different frontend pipelines for each environment. These pipelines are defined as part of the separate +[CompactConnect UI App](../../../compact-connect-ui-app/README.md). The frontend pipelines deploy application hosting +infrastructure to the environment-specific application AWS accounts, based on backend configuration values provided +by the backend deploy process. + +### Deployment Resources Stack + +- **Deployment Resources Stack**: Shared resources used by pipeline stacks across all environments +- **Environments**: Test, Beta, and Production environments + +## Pipeline Flow + +Commits are pushed to the `main` branch, but no deployments are triggered by commits. Each pipeline has an associated +git tag pattern, which will trigger the corresponding backend/frontend pipeline to the corresponding environment. The +patterns are as follows: +- CompactConnect backend pipeline: `cc--*` +- CompactConnect frontend pipeline: `ui--*` + +## Self-Mutation Feature and Optimization + +### Understanding CDK Pipeline Self-Mutation + +AWS CDK Pipelines include a powerful "self-mutation" feature that allows the pipeline to update itself. When code +changes affecting the pipeline's structure are pushed, the pipeline: + +1. Executes with its current configuration +2. Synthesizes CloudFormation templates for all stacks in the app +3. Deploys a "self-mutation" step that updates the pipeline's own definition +4. Continues deployment with the updated pipeline definition + +While powerful, this feature presents challenges: + +1. **Performance Impact**: By default, CDK synthesizes all stacks in the application even when only one pipeline needs + to be updated. This can be extremely slow, especially for complex applications. + +2. **Unnecessary Processing**: Every pipeline synthesis includes bundling operations (like frontend builds) even when + those components aren't changing. + +### The SynthSubstituteStage and SynthSubstituteStack Solution + +To address these challenges, we've implemented the `SynthSubstituteStage` and `SynthSubstituteStack` classes that act +as lightweight placeholders during the synthesis process: + +#### How It Works + +1. During pipeline synthesis, we pass in cdk context variables to determine which specific pipeline is being + synthesized. + +2. For any stage that isn't part of the current pipeline being synthesized, we replace it with a `SynthSubstituteStage` + containing a minimal `SynthSubstituteStack`. + +3. The substitute stack synths a single SSM parameter resource, dramatically reducing synthesis time compared to full application stacks. + +## Implementation Details + +The substitution mechanism relies on CDK context values which we pass in during the CDK synth step of the pipeline definition (see the [BackendPipeline](../backend_pipeline.py) and [FrontendPipeline](../frontend_pipeline.py) class constructors, specifically the `synth.commands` property): + +```python +commands=[ + ... other commands + # Only synthesize the specific pipeline stack needed + f'cdk synth --context pipelineStack={pipeline_stack_name} --context action=pipelineSynth', +], +``` +The following context values are used to determine which pipeline to fully synthesize: + +- `action`: Specifies the current action (e.g., `pipelineSynth`, `bootstrapDeploy`) +- `pipelineStack`: The specific pipeline stack being synthesized + +In the pipeline stack classes, the `_determine_backend_stage` and `_determine_frontend_stage` methods handle the stage substitution logic: + +```python +def _determine_backend_stage(self, construct_id, app_name, environment_name, environment_context): + # Check if we're in pipeline synthesis mode and if we're synthesizing this specific pipeline + action = self.node.try_get_context('action') + pipeline_stack_name = self.node.try_get_context('pipelineStack') + + # Use substitute stage if not synthesizing this specific pipeline or during bootstrap + if (action == PIPELINE_SYNTH_ACTION and pipeline_stack_name != self.stack_name) or action == BOOTSTRAP_DEPLOY_ACTION: + return SynthSubstituteStage( + self, + 'SubstituteBackendStage', + environment_context=environment_context, + ) + + # Otherwise, use the real stage + return BackendStage( + self, + construct_id, + app_name=app_name, + environment_name=environment_name, + environment_context=environment_context, + ) +``` + +# Bootstrapping the piplines +See this [README.md](../../README.md) for details on performing a bootstrap deployment of the pipelines. diff --git a/backend/social-work-app/docs/design/pipeline-architecture.pdf b/backend/social-work-app/docs/design/pipeline-architecture.pdf new file mode 100644 index 0000000000..c5146c2e28 Binary files /dev/null and b/backend/social-work-app/docs/design/pipeline-architecture.pdf differ diff --git a/backend/social-work-app/docs/design/practitioner-home-state-license-assignment.pdf b/backend/social-work-app/docs/design/practitioner-home-state-license-assignment.pdf new file mode 100644 index 0000000000..3c7622ea84 Binary files /dev/null and b/backend/social-work-app/docs/design/practitioner-home-state-license-assignment.pdf differ diff --git a/backend/social-work-app/docs/design/users-arch-diagram.pdf b/backend/social-work-app/docs/design/users-arch-diagram.pdf new file mode 100644 index 0000000000..63d88a2173 Binary files /dev/null and b/backend/social-work-app/docs/design/users-arch-diagram.pdf differ diff --git a/backend/social-work-app/docs/devops/README.md b/backend/social-work-app/docs/devops/README.md new file mode 100644 index 0000000000..d855dadf68 --- /dev/null +++ b/backend/social-work-app/docs/devops/README.md @@ -0,0 +1,4 @@ +# DevOps Documentation + +This directory contains internal operations and support procedures for the CompactConnect development and support teams. +This documentation is **NOT** intended for external IT staff. diff --git a/backend/social-work-app/docs/devops/STAFF_USER_MFA_RECOVERY.md b/backend/social-work-app/docs/devops/STAFF_USER_MFA_RECOVERY.md new file mode 100644 index 0000000000..539cbef37b --- /dev/null +++ b/backend/social-work-app/docs/devops/STAFF_USER_MFA_RECOVERY.md @@ -0,0 +1,67 @@ +# Staff User MFA Recovery Procedure + +## Overview + +When a staff user loses access to their Multi-Factor Authentication (MFA) device, they cannot log into the CompactConnect system. + +A staff user account consists of two parts: a Cognito user to track login information, and a DynamoDB record in the staff +users DynamoDB table to track permissions and other account data about the user. + +Unfortunately, AWS Cognito does not provide a way to reset or recover a user’s MFA configuration. If a staff user loses access to their MFA device, the Cognito user account must be deleted and recreated in order to enroll a new MFA device. In CompactConnect, the DynamoDB staff-user record is keyed by the Cognito user ID (sub), and that identifier is referenced by system audit events (for example, when a staff user deactivates or encumbers a privilege). + +Recreating a Cognito user always generates a new sub. Reusing or mutating the existing DynamoDB staff-user record would retroactively change the meaning of historical audit events. To preserve audit integrity and traceability, the DynamoDB record with the original sub must therefore be archived, and a new staff-user record must be created for the newly recreated Cognito user. In the future, staff-user records should be decoupled from Cognito sub values (for example, by referencing email). Until that migration is completed and the process is automated, support staff must assist with deleting Cognito accounts and archiving staff-user records when a staff user loses access to their MFA device. + +This document provides step-by-step instructions to recover the user's access while preserving their historical record for audit and traceability purposes. + +## Prerequisites + +Before beginning this procedure, ensure you have: + +1. **AWS Console Access**: Administrative access to the AWS account for the target environment (test, beta, or production) +2. **User Information**: You will need the user's email address to find their Cognito account + +## Procedure + +### Step 1: Retrieve the Current User Record + +1. **Find the User in Cognito** + - Navigate to AWS Console → Cognito → User Pools + - Locate the staff user pool for your environment (prefixed with `StaffUsers`) + - In the Cognito User Pool, search for the user by email address + - Click on the user to view their details + - Copy the `sub` (Subject) value - this is the user ID you'll need + +2. **Retrieve the DynamoDB Record** + - Navigate to the Staff Users table in the AWS Console → DynamoDB → Tables → `{environment}-PersistentStack-StaffUsers...` + - Click on "Explore table items" + - Search for an item where: + - `pk` = `USER#{user_sub}` (where `user_sub` is the sub value from Cognito) + - `sk` = `COMPACT#{compact}` (where `compact` is the user's compact, e.g., "socw") + - Click on the item to view its full contents + +### Step 2: Archive the DynamoDB Record + +In the DynamoDB console, if you change the partition key (pk) value of a existing DynamoDB item, it automatically deletes the old record with the old pk and creates a new one with the new pk, effectively archiving the user record. Perform the following steps: + +1. **Create the Archived Record** + - In the DynamoDB table, find the existing staff user record and select it to open the 'Edit item' view. + - Update the item pk with the following structure: + - `pk` = `ARCHIVED_USER#{staff user email}` + - **Add a new field**: `archivedDate` = current date (yyyy-mm-dd format) + - **Add a new field**: `archivedReason` = "MFA recovery - user lost access to MFA device" + - A box should appear at the bottom that states the item will be deleted and recreated, click the box. + - Click the 'Recreate item' button to create the archived record, this will delete the old record and create a new archived record + - Verify the archived record was created successfully + - Note the permissions of the archived user for when the user is re-invited into the system. + +### Step 3: Delete the Cognito User Account + +1. Navigate back to the user account in the staff Cognito User Pool +2. Deactivate the Cognito user +3. Delete the Cognito user + +### Step 4: Staff user recreates user account +At this point, a staff admin with the needed permissions can recreate the staff user in CompactConnect using the UI. Notify management to +create the account for the user as they did before with the appropriate permissions. The user should receive an email +with a new temporary password. When they log into the system and set their new password, they will also be prompted to +configure their new MFA using the authenticator app of their choice. diff --git a/backend/social-work-app/docs/internal/api-specification/latest-oas30.json b/backend/social-work-app/docs/internal/api-specification/latest-oas30.json new file mode 100644 index 0000000000..25ddc582a4 --- /dev/null +++ b/backend/social-work-app/docs/internal/api-specification/latest-oas30.json @@ -0,0 +1,4799 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "LicenseApi", + "version": "2026-05-11T21:56:12Z" + }, + "servers": [ + { + "url": "https://api.beta.compactconnect.org", + "x-amazon-apigateway-endpoint-configuration": { + "disableExecuteApiEndpoint": true + } + } + ], + "paths": { + "/v1/compacts/{compact}": { + "get": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicen8DOCd2kFNKVr" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/readGeneral" + ] + } + ] + }, + "put": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenVIb0TY3sud8L" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/jurisdictions": { + "get": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicen5TOdmxJJNjOc" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/readGeneral" + ] + } + ] + } + }, + "/v1/compacts/{compact}/jurisdictions/{jurisdiction}": { + "get": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenieqYQgLPF3Oc" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/readGeneral" + ] + } + ] + }, + "put": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenqjE3z9YbfyHD" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses/bulk-upload": { + "get": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenvVMLbSK5KTv8" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/write", + "al/socw.write", + "az/socw.write", + "co/socw.write", + "ks/socw.write", + "ky/socw.write", + "md/socw.write", + "oh/socw.write", + "tn/socw.write", + "va/socw.write", + "wa/socw.write" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/query": { + "post": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenvEBZtPTBPy5K" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenfgNnVzLTxob0" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/readGeneral" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}": { + "get": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicen0ZuYY62aEHDO" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/readGeneral" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/licenses/jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance": { + "post": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenSwxDyLtD7O9E" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/licenses/jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance/{encumbranceId}": { + "patch": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "encumbranceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicen87UzEO8FByN3" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/licenses/jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation": { + "post": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenABDlpIbmGvpt" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/licenses/jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation/{investigationId}": { + "patch": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "investigationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenDiO5FBTVQqnN" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/privileges/jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance": { + "post": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicena35BrLR78TH0" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/privileges/jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance/{encumbranceId}": { + "patch": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "encumbranceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenT5k0UMd0IQFY" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/privileges/jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation": { + "post": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenwsC6m1RPaBTa" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/providers/{providerId}/privileges/jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation/{investigationId}": { + "patch": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "jurisdiction", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "licenseType", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "investigationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenrTInrGJ5VcWA" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/staff-users": { + "get": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenGuFBFkHjgyWP" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + }, + "post": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenTM26Tw0Xfojy" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALiceneDjMMCLsTfYg" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/staff-users/{userId}": { + "get": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "404": { + "description": "404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + }, + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALiceneDjMMCLsTfYg" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + }, + "delete": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "404": { + "description": "404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + }, + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + }, + "patch": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicen7VPWeACUXNxW" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + }, + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALiceneDjMMCLsTfYg" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/compacts/{compact}/staff-users/{userId}/reinvite": { + "post": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "404": { + "description": "404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + }, + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "socw/admin", + "al/socw.admin", + "az/socw.admin", + "co/socw.admin", + "ks/socw.admin", + "ky/socw.admin", + "md/socw.admin", + "oh/socw.admin", + "tn/socw.admin", + "va/socw.admin", + "wa/socw.admin" + ] + } + ] + } + }, + "/v1/flags/{flagId}/check": { + "post": { + "parameters": [ + { + "name": "flagId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenrTKXWeGlJdSm" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenBPNgHHOEfrMN" + } + } + } + } + } + } + }, + "/v1/public/compacts/{compact}/jurisdictions": { + "get": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicen5TOdmxJJNjOc" + } + } + } + } + } + } + }, + "/v1/public/compacts/{compact}/providers/query": { + "post": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenvEBZtPTBPy5K" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenecMeKurmg8eG" + } + } + } + } + } + } + }, + "/v1/public/compacts/{compact}/providers/{providerId}": { + "get": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "providerId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenf1LpawbsV02z" + } + } + } + } + } + } + }, + "/v1/public/jurisdictions/live": { + "get": { + "parameters": [ + { + "name": "compact", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenxSsVtZvGSYkW" + } + } + } + } + } + } + }, + "/v1/staff-users/me": { + "get": { + "responses": { + "404": { + "description": "404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + }, + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALiceneDjMMCLsTfYg" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "profile" + ] + } + ] + }, + "patch": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenDec5g8dycU8V" + } + } + }, + "required": true + }, + "responses": { + "404": { + "description": "404 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALicenkb3YpptFJlFD" + } + } + } + }, + "200": { + "description": "200 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestALiceneDjMMCLsTfYg" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": [ + "profile" + ] + } + ] + } + } + }, + "components": { + "schemas": { + "TestALicenVIb0TY3sud8L": { + "required": [ + "compactAdverseActionsNotificationEmails", + "compactOperationsTeamEmails", + "configuredStates", + "licenseeRegistrationEnabled" + ], + "type": "object", + "properties": { + "configuredStates": { + "type": "array", + "description": "List of states that have submitted configurations and their live status", + "items": { + "required": [ + "isLive", + "postalAbbreviation" + ], + "type": "object", + "properties": { + "postalAbbreviation": { + "type": "string", + "description": "The postal abbreviation of the jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "isLive": { + "type": "boolean", + "description": "Whether the state is live and available for registrations." + } + }, + "additionalProperties": false + } + }, + "compactAdverseActionsNotificationEmails": { + "maxItems": 10, + "minItems": 1, + "uniqueItems": true, + "type": "array", + "description": "List of email addresses for adverse actions notifications", + "items": { + "type": "string", + "format": "email" + } + }, + "licenseeRegistrationEnabled": { + "type": "boolean", + "description": "Denotes whether licensee registration is enabled" + }, + "compactOperationsTeamEmails": { + "maxItems": 10, + "minItems": 1, + "uniqueItems": true, + "type": "array", + "description": "List of email addresses for operations team notifications", + "items": { + "type": "string", + "format": "email" + } + } + }, + "additionalProperties": false + }, + "TestALicenvEBZtPTBPy5K": { + "required": [ + "query" + ], + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + }, + "additionalProperties": false + }, + "query": { + "type": "object", + "properties": { + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string", + "description": "Internal UUID for the provider" + }, + "jurisdiction": { + "type": "string", + "description": "Filter for providers with license in a jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "givenName": { + "maxLength": 100, + "type": "string", + "description": "Filter for providers with a given name (familyName is required if givenName is provided)" + }, + "familyName": { + "maxLength": 100, + "type": "string", + "description": "Filter for providers with a family name" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string", + "description": "Filter for licenses with a specific license number" + } + }, + "additionalProperties": false, + "description": "The query parameters" + }, + "sorting": { + "required": [ + "key" + ], + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to sort results by", + "enum": [ + "dateOfUpdate", + "familyName" + ] + }, + "direction": { + "type": "string", + "description": "Direction to sort results by", + "enum": [ + "ascending", + "descending" + ] + } + }, + "description": "How to sort results" + } + }, + "additionalProperties": false + }, + "TestALicenxSsVtZvGSYkW": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + } + } + }, + "TestALicenf1LpawbsV02z": { + "required": [ + "compact", + "dateOfUpdate", + "familyName", + "givenName", + "licenseJurisdiction", + "providerId", + "type" + ], + "type": "object", + "properties": { + "privileges": { + "type": "array", + "items": { + "required": [ + "administratorSetStatus", + "compact", + "dateOfExpiration", + "jurisdiction", + "licenseJurisdiction", + "licenseType", + "providerId", + "status", + "type" + ], + "type": "object", + "properties": { + "licenseType": { + "type": "string", + "enum": [ + "cosmetologist", + "esthetician" + ] + }, + "administratorSetStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "type": { + "type": "string", + "enum": [ + "privilege" + ] + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + } + }, + "licenses": { + "type": "array", + "description": "Sanitized home-state license rows (LicensePublicResponseSchema)", + "items": { + "required": [ + "compact", + "compactEligibility", + "dateOfExpiration", + "jurisdiction", + "licenseNumber", + "licenseStatus", + "licenseType", + "type" + ], + "type": "object", + "properties": { + "licenseType": { + "type": "string", + "enum": [ + "cosmetologist", + "esthetician" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "license" + ] + } + } + } + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "provider" + ] + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + }, + "TestALicenTM26Tw0Xfojy": { + "required": [ + "attributes", + "permissions" + ], + "type": "object", + "properties": { + "permissions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + } + }, + "jurisdictions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "write": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + } + } + }, + "additionalProperties": false + } + }, + "attributes": { + "required": [ + "email", + "familyName", + "givenName" + ], + "type": "object", + "properties": { + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "TestALicenvVMLbSK5KTv8": { + "required": [ + "upload" + ], + "type": "object", + "properties": { + "upload": { + "required": [ + "fields", + "url" + ], + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + } + } + }, + "TestALicenrTInrGJ5VcWA": { + "required": [ + "action" + ], + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "close" + ] + }, + "encumbrance": { + "required": [ + "clinicalPrivilegeActionCategories", + "encumbranceEffectiveDate", + "encumbranceType" + ], + "type": "object", + "properties": { + "encumbranceEffectiveDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "description": "The effective date of the encumbrance", + "format": "date" + }, + "clinicalPrivilegeActionCategories": { + "type": "array", + "description": "The categories of clinical privilege action", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "encumbranceType": { + "type": "string", + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ] + } + }, + "additionalProperties": false, + "description": "Encumbrance data to create" + } + } + }, + "TestALicenkb3YpptFJlFD": { + "required": [ + "message" + ], + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "A message about the request" + } + } + }, + "TestALicenqjE3z9YbfyHD": { + "required": [ + "jurisdictionAdverseActionsNotificationEmails", + "jurisdictionOperationsTeamEmails", + "licenseeRegistrationEnabled" + ], + "type": "object", + "properties": { + "jurisdictionAdverseActionsNotificationEmails": { + "maxItems": 10, + "minItems": 1, + "uniqueItems": true, + "type": "array", + "description": "List of email addresses for adverse actions notifications", + "items": { + "type": "string", + "format": "email" + } + }, + "jurisdictionOperationsTeamEmails": { + "maxItems": 10, + "minItems": 1, + "uniqueItems": true, + "type": "array", + "description": "List of email addresses for operations team notifications", + "items": { + "type": "string", + "format": "email" + } + }, + "licenseeRegistrationEnabled": { + "type": "boolean", + "description": "Denotes whether licensee registration is enabled" + } + }, + "additionalProperties": false + }, + "TestALicen5TOdmxJJNjOc": { + "type": "array", + "items": { + "required": [ + "compact", + "jurisdictionName", + "postalAbbreviation" + ], + "type": "object", + "properties": { + "postalAbbreviation": { + "type": "string", + "description": "The postal abbreviation of the jurisdiction" + }, + "compact": { + "type": "string" + }, + "jurisdictionName": { + "type": "string", + "description": "The name of the jurisdiction" + } + } + } + }, + "TestALicen0ZuYY62aEHDO": { + "required": [ + "adverseActions", + "birthMonthDay", + "compact", + "dateOfExpiration", + "dateOfUpdate", + "familyName", + "givenName", + "licenseJurisdiction", + "licenses", + "privileges", + "providerId", + "type" + ], + "type": "object", + "properties": { + "privileges": { + "type": "array", + "items": { + "required": [ + "administratorSetStatus", + "compact", + "compactTransactionId", + "dateOfExpiration", + "history", + "jurisdiction", + "licenseJurisdiction", + "licenseType", + "providerId", + "status", + "type" + ], + "type": "object", + "properties": { + "investigationStatus": { + "type": "string", + "description": "Status indicating if the privilege is under investigation", + "enum": [ + "underInvestigation" + ] + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "investigations": { + "type": "array", + "items": { + "required": [ + "compact", + "creationDate", + "dateOfUpdate", + "investigationId", + "jurisdiction", + "licenseType", + "providerId", + "submittingUser", + "type" + ], + "type": "object", + "properties": { + "licenseType": { + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "submittingUser": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "investigation" + ] + }, + "creationDate": { + "type": "string", + "format": "date-time" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "history": { + "type": "array", + "items": { + "required": [ + "compact", + "dateOfUpdate", + "jurisdiction", + "previous", + "type", + "updateType" + ], + "type": "object", + "properties": { + "removedValues": { + "type": "array", + "description": "List of field names that were present in the previous record but removed in the update", + "items": { + "type": "string" + } + }, + "licenseType": { + "type": "string", + "enum": [ + "cosmetologist", + "esthetician" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "previous": { + "required": [ + "administratorSetStatus", + "compactTransactionId", + "dateOfExpiration", + "licenseJurisdiction" + ], + "type": "object", + "properties": { + "administratorSetStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "type": { + "type": "string", + "enum": [ + "privilege" + ] + }, + "compactTransactionId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "updatedValues": { + "type": "object", + "properties": { + "administratorSetStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "type": { + "type": "string", + "enum": [ + "privilege" + ] + }, + "compactTransactionId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + }, + "type": { + "type": "string", + "enum": [ + "privilegeUpdate" + ] + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + }, + "updateType": { + "type": "string", + "enum": [ + "deactivation", + "expiration", + "issuance", + "other", + "renewal", + "encumbrance", + "lifting_encumbrance", + "licenseDeactivation" + ] + } + } + } + }, + "type": { + "type": "string", + "enum": [ + "privilege" + ] + }, + "compactTransactionId": { + "type": "string" + }, + "licenseType": { + "type": "string", + "enum": [ + "cosmetologist", + "esthetician" + ] + }, + "administratorSetStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "adverseActions": { + "type": "array", + "items": { + "required": [ + "actionAgainst", + "adverseActionId", + "compact", + "creationDate", + "dateOfUpdate", + "effectiveStartDate", + "encumbranceType", + "jurisdiction", + "licenseType", + "licenseTypeAbbreviation", + "providerId", + "type" + ], + "type": "object", + "properties": { + "clinicalPrivilegeActionCategories": { + "type": "array", + "description": "The categories of clinical privilege action", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "adverseAction" + ] + }, + "creationDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "actionAgainst": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "effectiveStartDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "encumbranceType": { + "type": "string" + }, + "liftingUser": { + "type": "string" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + } + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "jurisdictionUploadedCompactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "jurisdictionUploadedLicenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "type": { + "type": "string", + "enum": [ + "provider" + ] + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenses": { + "type": "array", + "items": { + "required": [ + "compact", + "compactEligibility", + "dateOfExpiration", + "dateOfIssuance", + "dateOfRenewal", + "dateOfUpdate", + "familyName", + "givenName", + "history", + "homeAddressCity", + "homeAddressPostalCode", + "homeAddressState", + "homeAddressStreet1", + "jurisdiction", + "jurisdictionUploadedCompactEligibility", + "jurisdictionUploadedLicenseStatus", + "licenseStatus", + "licenseType", + "middleName", + "providerId", + "type" + ], + "type": "object", + "properties": { + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "investigations": { + "type": "array", + "items": { + "required": [ + "compact", + "creationDate", + "dateOfUpdate", + "investigationId", + "jurisdiction", + "licenseType", + "providerId", + "submittingUser", + "type" + ], + "type": "object", + "properties": { + "licenseType": { + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "submittingUser": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "investigation" + ] + }, + "creationDate": { + "type": "string", + "format": "date-time" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "type": { + "type": "string", + "enum": [ + "license-home" + ] + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "licenseType": { + "type": "string", + "enum": [ + "cosmetologist", + "esthetician" + ] + }, + "emailAddress": { + "maxLength": 100, + "minLength": 5, + "type": "string", + "format": "email" + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "investigationStatus": { + "type": "string", + "description": "Status indicating if the license is under investigation", + "enum": [ + "underInvestigation" + ] + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "jurisdictionUploadedLicenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "history": { + "type": "array", + "items": { + "required": [ + "compact", + "dateOfUpdate", + "jurisdiction", + "previous", + "type", + "updateType" + ], + "type": "object", + "properties": { + "removedValues": { + "type": "array", + "description": "List of field names that were present in the previous record but removed in the update", + "items": { + "type": "string" + } + }, + "licenseType": { + "type": "string", + "enum": [ + "cosmetologist", + "esthetician" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "previous": { + "required": [ + "dateOfExpiration", + "dateOfIssuance", + "dateOfRenewal", + "familyName", + "givenName", + "homeAddressCity", + "homeAddressPostalCode", + "homeAddressState", + "homeAddressStreet1", + "jurisdictionUploadedCompactEligibility", + "jurisdictionUploadedLicenseStatus", + "middleName" + ], + "type": "object", + "properties": { + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "jurisdictionUploadedCompactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "jurisdictionUploadedLicenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "emailAddress": { + "maxLength": 100, + "minLength": 5, + "type": "string", + "format": "email" + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + } + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "updatedValues": { + "type": "object", + "properties": { + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "jurisdictionUploadedCompactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "jurisdictionUploadedLicenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "emailAddress": { + "maxLength": 100, + "minLength": 5, + "type": "string", + "format": "email" + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + } + }, + "type": { + "type": "string", + "enum": [ + "licenseUpdate" + ] + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + }, + "updateType": { + "type": "string", + "enum": [ + "deactivation", + "expiration", + "issuance", + "other", + "renewal", + "encumbrance", + "lifting_encumbrance", + "licenseDeactivation" + ] + } + } + } + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "adverseActions": { + "type": "array", + "items": { + "required": [ + "actionAgainst", + "adverseActionId", + "compact", + "creationDate", + "dateOfUpdate", + "effectiveStartDate", + "encumbranceType", + "jurisdiction", + "licenseType", + "licenseTypeAbbreviation", + "providerId", + "type" + ], + "type": "object", + "properties": { + "clinicalPrivilegeActionCategories": { + "type": "array", + "description": "The categories of clinical privilege action", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "adverseAction" + ] + }, + "creationDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "actionAgainst": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "effectiveStartDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "encumbranceType": { + "type": "string" + }, + "liftingUser": { + "type": "string" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "birthMonthDay": { + "pattern": "^[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "adverseActions": { + "type": "array", + "items": { + "required": [ + "actionAgainst", + "adverseActionId", + "compact", + "creationDate", + "dateOfUpdate", + "effectiveStartDate", + "encumbranceType", + "jurisdiction", + "licenseType", + "licenseTypeAbbreviation", + "providerId", + "type" + ], + "type": "object", + "properties": { + "clinicalPrivilegeActionCategories": { + "type": "array", + "description": "The categories of clinical privilege action", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "adverseAction" + ] + }, + "creationDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "actionAgainst": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "effectiveStartDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "encumbranceType": { + "type": "string" + }, + "liftingUser": { + "type": "string" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + }, + "TestALicen87UzEO8FByN3": { + "required": [ + "effectiveLiftDate" + ], + "type": "object", + "properties": { + "effectiveLiftDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "description": "The effective date when the encumbrance will be lifted", + "format": "date" + } + }, + "additionalProperties": false + }, + "TestALicen8DOCd2kFNKVr": { + "required": [ + "compactAbbr", + "compactAdverseActionsNotificationEmails", + "compactName", + "compactOperationsTeamEmails", + "configuredStates", + "licenseeRegistrationEnabled" + ], + "type": "object", + "properties": { + "configuredStates": { + "type": "array", + "description": "List of states that have submitted configurations and their live status", + "items": { + "required": [ + "isLive", + "postalAbbreviation" + ], + "type": "object", + "properties": { + "postalAbbreviation": { + "type": "string", + "description": "The postal abbreviation of the jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "isLive": { + "type": "boolean", + "description": "Whether the state is live and available for registrations." + } + } + } + }, + "compactAdverseActionsNotificationEmails": { + "type": "array", + "description": "List of email addresses for adverse actions notifications", + "items": { + "type": "string", + "format": "email" + } + }, + "licenseeRegistrationEnabled": { + "type": "boolean", + "description": "Denotes whether licensee registration is enabled" + }, + "compactAbbr": { + "type": "string", + "description": "The abbreviation of the compact" + }, + "compactName": { + "type": "string", + "description": "The full name of the compact" + }, + "compactOperationsTeamEmails": { + "type": "array", + "description": "List of email addresses for operations team notifications", + "items": { + "type": "string", + "format": "email" + } + } + } + }, + "TestALicenieqYQgLPF3Oc": { + "required": [ + "compact", + "jurisdictionAdverseActionsNotificationEmails", + "jurisdictionName", + "jurisdictionOperationsTeamEmails", + "licenseeRegistrationEnabled", + "postalAbbreviation" + ], + "type": "object", + "properties": { + "postalAbbreviation": { + "type": "string", + "description": "The postal abbreviation of the jurisdiction" + }, + "jurisdictionAdverseActionsNotificationEmails": { + "type": "array", + "description": "List of email addresses for adverse actions notifications", + "items": { + "type": "string", + "format": "email" + } + }, + "jurisdictionOperationsTeamEmails": { + "type": "array", + "description": "List of email addresses for operations team notifications", + "items": { + "type": "string", + "format": "email" + } + }, + "compact": { + "type": "string", + "description": "The compact this jurisdiction configuration belongs to", + "enum": [ + "socw" + ] + }, + "licenseeRegistrationEnabled": { + "type": "boolean", + "description": "Denotes whether licensee registration is enabled" + }, + "jurisdictionName": { + "type": "string", + "description": "The name of the jurisdiction" + } + } + }, + "TestALicenDiO5FBTVQqnN": { + "required": [ + "action" + ], + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "close" + ] + }, + "encumbrance": { + "required": [ + "clinicalPrivilegeActionCategories", + "encumbranceEffectiveDate", + "encumbranceType" + ], + "type": "object", + "properties": { + "encumbranceEffectiveDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "description": "The effective date of the encumbrance", + "format": "date" + }, + "clinicalPrivilegeActionCategories": { + "type": "array", + "description": "The categories of clinical privilege action", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "encumbranceType": { + "type": "string", + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ] + } + }, + "additionalProperties": false, + "description": "Encumbrance data to create" + } + } + }, + "TestALicenrTKXWeGlJdSm": { + "type": "object", + "properties": { + "context": { + "type": "object", + "properties": { + "userId": { + "maxLength": 100, + "minLength": 1, + "type": "string", + "description": "Optional user ID for feature flag evaluation" + }, + "customAttributes": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Optional custom attributes for feature flag evaluation" + } + }, + "additionalProperties": false, + "description": "Optional context for feature flag evaluation" + } + }, + "additionalProperties": false + }, + "TestALicen7VPWeACUXNxW": { + "type": "object", + "properties": { + "permissions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + } + }, + "jurisdictions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "write": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "TestALicenBPNgHHOEfrMN": { + "required": [ + "enabled" + ], + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the feature flag is enabled" + } + } + }, + "TestALicenDec5g8dycU8V": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "properties": { + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "TestALicenT5k0UMd0IQFY": { + "required": [ + "effectiveLiftDate" + ], + "type": "object", + "properties": { + "effectiveLiftDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "description": "The effective date when the encumbrance will be lifted", + "format": "date" + } + }, + "additionalProperties": false + }, + "TestALicenABDlpIbmGvpt": { + "type": "object", + "properties": {} + }, + "TestALiceneDjMMCLsTfYg": { + "required": [ + "attributes", + "permissions", + "status", + "userId" + ], + "type": "object", + "properties": { + "permissions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + } + }, + "jurisdictions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "write": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + } + } + }, + "additionalProperties": false + } + }, + "attributes": { + "required": [ + "email", + "familyName", + "givenName" + ], + "type": "object", + "properties": { + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + } + }, + "additionalProperties": false + }, + "userId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + }, + "additionalProperties": false + }, + "TestALicenecMeKurmg8eG": { + "required": [ + "pagination", + "providers" + ], + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "prevLastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "object" + }, + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "object" + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + } + }, + "query": { + "type": "object", + "properties": { + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string", + "description": "Internal UUID for the provider" + }, + "jurisdiction": { + "type": "string", + "description": "Filter for providers with privilege/license in a jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "givenName": { + "maxLength": 100, + "type": "string", + "description": "Filter for providers with a given name" + }, + "familyName": { + "maxLength": 100, + "type": "string", + "description": "Filter for providers with a family name" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string", + "description": "Filter for licenses with a specific license number" + } + } + }, + "sorting": { + "required": [ + "key" + ], + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to sort results by", + "enum": [ + "dateOfUpdate", + "familyName" + ] + }, + "direction": { + "type": "string", + "description": "Direction to sort results by", + "enum": [ + "ascending", + "descending" + ] + } + }, + "description": "How to sort results" + }, + "providers": { + "maxLength": 100, + "type": "array", + "items": { + "required": [ + "compact", + "familyName", + "givenName", + "licenseEligibility", + "licenseJurisdiction", + "licenseNumber", + "licenseType", + "providerId" + ], + "type": "object", + "properties": { + "licenseType": { + "type": "string", + "description": "License type or profession designation for this license row" + }, + "licenseEligibility": { + "type": "string", + "description": "Whether the license is eligible for compact participation in public search results", + "enum": [ + "eligible", + "ineligible" + ] + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + } + } + } + } + }, + "TestALicenGuFBFkHjgyWP": { + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "prevLastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "object" + }, + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "object" + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + } + }, + "users": { + "type": "array", + "items": { + "required": [ + "attributes", + "permissions", + "status", + "userId" + ], + "type": "object", + "properties": { + "permissions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + } + }, + "jurisdictions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "write": { + "type": "boolean" + } + }, + "additionalProperties": false + } + } + } + } + }, + "additionalProperties": false + } + }, + "attributes": { + "required": [ + "email", + "familyName", + "givenName" + ], + "type": "object", + "properties": { + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + } + }, + "additionalProperties": false + }, + "userId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "TestALicenfgNnVzLTxob0": { + "required": [ + "pagination", + "providers" + ], + "type": "object", + "properties": { + "pagination": { + "type": "object", + "properties": { + "prevLastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "object" + }, + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "object" + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + } + }, + "sorting": { + "required": [ + "key" + ], + "type": "object", + "properties": { + "key": { + "type": "string", + "description": "The key to sort results by", + "enum": [ + "dateOfUpdate", + "familyName" + ] + }, + "direction": { + "type": "string", + "description": "Direction to sort results by", + "enum": [ + "ascending", + "descending" + ] + } + }, + "description": "How to sort results" + }, + "providers": { + "maxLength": 100, + "type": "array", + "items": { + "required": [ + "birthMonthDay", + "compact", + "compactEligibility", + "dateOfExpiration", + "dateOfUpdate", + "familyName", + "givenName", + "jurisdictionUploadedCompactEligibility", + "jurisdictionUploadedLicenseStatus", + "licenseJurisdiction", + "licenseStatus", + "providerId", + "type" + ], + "type": "object", + "properties": { + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "jurisdictionUploadedCompactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "jurisdictionUploadedLicenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "type": { + "type": "string", + "enum": [ + "provider" + ] + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "birthMonthDay": { + "pattern": "^[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "TestALicenSwxDyLtD7O9E": { + "required": [ + "clinicalPrivilegeActionCategories", + "encumbranceEffectiveDate", + "encumbranceType" + ], + "type": "object", + "properties": { + "encumbranceEffectiveDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "description": "The effective date of the encumbrance", + "format": "date" + }, + "clinicalPrivilegeActionCategories": { + "type": "array", + "description": "The categories of clinical privilege action", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "encumbranceType": { + "type": "string", + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ] + } + }, + "additionalProperties": false, + "description": "Encumbrance data to create" + }, + "TestALicena35BrLR78TH0": { + "required": [ + "clinicalPrivilegeActionCategories", + "encumbranceEffectiveDate", + "encumbranceType" + ], + "type": "object", + "properties": { + "encumbranceEffectiveDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "description": "The effective date of the encumbrance", + "format": "date" + }, + "clinicalPrivilegeActionCategories": { + "type": "array", + "description": "The categories of clinical privilege action", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "encumbranceType": { + "type": "string", + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ] + } + }, + "additionalProperties": false, + "description": "Encumbrance data to create" + }, + "TestALicenwsC6m1RPaBTa": { + "type": "object", + "properties": {} + } + }, + "securitySchemes": { + "TestBackendCosmetologyTestAPIStackLicenseApiStaffUsersPoolAuthorizerF1C101C2": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "cognito_user_pools" + } + } + }, + "x-amazon-apigateway-security-policy": "TLS_1_0" +} diff --git a/backend/social-work-app/docs/internal/api-specification/swagger.html b/backend/social-work-app/docs/internal/api-specification/swagger.html new file mode 100644 index 0000000000..44396776c4 --- /dev/null +++ b/backend/social-work-app/docs/internal/api-specification/swagger.html @@ -0,0 +1,22 @@ + + + + + + + SwaggerUI + + + +
+ + + + diff --git a/backend/social-work-app/docs/internal/postman/postman-collection.json b/backend/social-work-app/docs/internal/postman/postman-collection.json new file mode 100644 index 0000000000..8c9b76ac6d --- /dev/null +++ b/backend/social-work-app/docs/internal/postman/postman-collection.json @@ -0,0 +1,4672 @@ +{ + "auth": { + "bearer": [ + { + "key": "token", + "type": "string", + "value": "{{accessToken}}" + } + ], + "type": "bearer" + }, + "info": { + "_postman_id": "56bf1d02-7d0b-4b0b-bd1d-d438c768b790", + "description": { + "content": "", + "type": "text/plain" + }, + "name": "CompactConnect API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "item": [ + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "client-credentials-grant", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "client_credentials" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "client_secret", + "value": "{{clientSecret}}" + }, + { + "key": "scope", + "value": "{{jurisdiction}}/{{compact}}.write" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=client_credentials&client_id={{clientId}}&client_secret={{clientSecret}}&scope={{jurisdiction}}/{{compact}}.write" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});", + "", + "pm.test('Identity token returned', () => {", + " var id_token = pm.response.json().id_token;", + " pm.expect(id_token).not.to.be.empty;", + " pm.environment.set(\"idToken\", id_token);", + " console.log('id token: ' + id_token);", + "});", + "", + "pm.test('Refresh token returned', () => {", + " var refresh_token = pm.response.json().refresh_token;", + " pm.expect(refresh_token).not.to.be.empty;", + " pm.environment.set(\"refreshToken\", refresh_token);", + " console.log('refresh token: ' + refresh_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-grant-token", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "authorization_code" + }, + { + "key": "code", + "value": "f23723c3-1d21-40e1-89ec-64807d2d658d" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "scope", + "value": "openid" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-authorize", + "protocolProfileBehavior": { + "followRedirects": false + }, + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "disabled": true, + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "disabled": true, + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "authorize" + ], + "query": [ + { + "key": "response_type", + "value": "code" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + }, + { + "key": "scope", + "value": "openid" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-login", + "protocolProfileBehavior": { + "followRedirects": false + }, + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "authorize" + ], + "query": [ + { + "key": "scope", + "value": "openid" + }, + { + "key": "response_type", + "value": "code" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + }, + { + "key": "identity_provider", + "value": "COGNITO" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});", + "", + "pm.test('Identity token returned', () => {", + " var id_token = pm.response.json().id_token;", + " pm.environment.set(\"idToken\", id_token);", + " console.log('id token: ' + id_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "refresh-token", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "refresh_token" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "refresh_token", + "value": "{{refreshToken}}" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=refresh_token&client_id={{clientId}}&refresh_token={{refreshToken}}" + } + }, + "response": [] + } + ], + "name": "Staff-Auth" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "68cdf48f-7cd3-4dcc-af6a-4e32f78aeaac", + "name": "/v1/compacts/:compact", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"compactAbbr\": \"\",\n \"compactAdverseActionsNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"compactName\": \"\",\n \"compactOperationsTeamEmails\": [\n \"\",\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"ky\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"oh\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "0c6f4fa0-a5fd-4e4c-8786-a3ac43d77468", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "event": [], + "id": "a5e31b50-829b-4066-8ff4-ed11e2bc4294", + "name": "/v1/compacts/:compact", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"compactAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"compactOperationsTeamEmails\": [\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"co\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"tn\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PUT", + "name": "/v1/compacts/:compact", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "cedfee38-f89a-4ccc-a9c7-d93b9a090e23", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"compactAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"compactOperationsTeamEmails\": [\n \"\"\n ],\n \"configuredStates\": [\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"co\"\n },\n {\n \"isLive\": \"\",\n \"postalAbbreviation\": \"tn\"\n }\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PUT", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "f03e30cb-9ff8-49fa-8bd7-518c9e29272e", + "name": "/v1/compacts/:compact/jurisdictions", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact/jurisdictions", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "[\n {\n \"compact\": \"\",\n \"jurisdictionName\": \"\",\n \"postalAbbreviation\": \"\"\n },\n {\n \"compact\": \"\",\n \"jurisdictionName\": \"\",\n \"postalAbbreviation\": \"\"\n }\n]", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "85c75263-dbc7-4f45-8a6b-04e1fa28fb09", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "df1929c4-50ae-4e9a-8c4d-6c32b332333a", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"compact\": \"cosm\",\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\",\n \"\"\n ],\n \"jurisdictionName\": \"\",\n \"jurisdictionOperationsTeamEmails\": [\n \"\",\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\",\n \"postalAbbreviation\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "06c70a4c-1955-4596-b366-96343a6ad510", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "event": [], + "id": "f2ff9f0c-b166-463c-8848-6acc6ebd633c", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"jurisdictionOperationsTeamEmails\": [\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PUT", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "4a2c1911-5ae3-42de-93cb-ae6093acc10e", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"jurisdictionAdverseActionsNotificationEmails\": [\n \"\"\n ],\n \"jurisdictionOperationsTeamEmails\": [\n \"\"\n ],\n \"licenseeRegistrationEnabled\": \"\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PUT", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('URL returned', () => {", + " var url = pm.response.json().upload.url;", + " pm.expect(url).not.to.be.empty;", + " pm.environment.set(\"docUrl\", url);", + " console.log('Upload url: ' + url);", + "});", + "", + "pm.test('Fields returned', () => {", + " var fields = pm.response.json().upload.fields;", + " pm.expect(fields).not.to.be.empty;", + " for (const [key, value] of Object.entries(fields)) {", + " pm.environment.set(`docField-${key}`, value);", + " console.log(`Doc field \"${key}\": ${value}`);", + " }", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "id": "faf04e77-94e1-49b9-ac88-9b4155648393", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction/licenses/bulk-upload", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction/licenses/bulk-upload", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction", + "licenses", + "bulk-upload" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"upload\": {\n \"fields\": {\n \"est_2\": \"\"\n },\n \"url\": \"\"\n }\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "2609a867-310e-4063-b759-f069fddcd787", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction", + "licenses", + "bulk-upload" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "bulk-upload" + } + ], + "name": "licenses" + } + ], + "name": "{jurisdiction}" + } + ], + "name": "jurisdictions" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "eab0da7b-6d1a-452c-ba80-62afa885cc5d", + "name": "/v1/compacts/:compact/providers/query", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"query\": {\n \"providerId\": \"4d3ac645-b43a-4b5b-8d04-6848afa184dd\",\n \"jurisdiction\": \"az\",\n \"givenName\": \"\",\n \"familyName\": \"\",\n \"licenseNumber\": \"\"\n },\n \"pagination\": {\n \"lastKey\": \"\",\n \"pageSize\": \"\"\n },\n \"sorting\": {\n \"key\": \"dateOfUpdate\",\n \"direction\": \"descending\"\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/providers/query", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + "query" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"pagination\": {\n \"prevLastKey\": {},\n \"lastKey\": {},\n \"pageSize\": \"\"\n },\n \"providers\": [\n {\n \"birthMonthDay\": \"17-33\",\n \"compact\": \"cosm\",\n \"compactEligibility\": \"eligible\",\n \"dateOfExpiration\": \"1069-12-09\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"licenseJurisdiction\": \"va\",\n \"licenseStatus\": \"active\",\n \"providerId\": \"e3f87b9e-3722-4ad6-b5bd-e9b1b781a439\",\n \"type\": \"provider\",\n \"dateOfBirth\": \"2002-07-13\",\n \"suffix\": \"\",\n \"ssnLastFour\": \"6991\",\n \"middleName\": \"\"\n },\n {\n \"birthMonthDay\": \"00-05\",\n \"compact\": \"cosm\",\n \"compactEligibility\": \"ineligible\",\n \"dateOfExpiration\": \"1484-09-07\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"licenseJurisdiction\": \"oh\",\n \"licenseStatus\": \"inactive\",\n \"providerId\": \"46b57d10-344d-465b-b614-ced78affa0aa\",\n \"type\": \"provider\",\n \"dateOfBirth\": \"2999-12-15\",\n \"suffix\": \"\",\n \"ssnLastFour\": \"1060\",\n \"middleName\": \"\"\n }\n ],\n \"sorting\": {\n \"key\": \"dateOfUpdate\",\n \"direction\": \"ascending\"\n }\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "eecb4a66-7473-4b31-8036-914db268d739", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"query\": {\n \"providerId\": \"4d3ac645-b43a-4b5b-8d04-6848afa184dd\",\n \"jurisdiction\": \"az\",\n \"givenName\": \"\",\n \"familyName\": \"\",\n \"licenseNumber\": \"\"\n },\n \"pagination\": {\n \"lastKey\": \"\",\n \"pageSize\": \"\"\n },\n \"sorting\": {\n \"key\": \"dateOfUpdate\",\n \"direction\": \"descending\"\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + "query" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "query" + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "dfcc7cf8-c5da-4de4-81e2-72be3d503ed6", + "name": "/v1/compacts/:compact/providers/:providerId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact/providers/:providerId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"adverseActions\": [\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1704-11-24\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1022-10-03\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"ks\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"10ed510d-dc0c-4786-982a-8f1455fdbc7c\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"2902-08-12\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2282-03-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1278-12-31\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"738f6916-a2d4-4cc6-a958-3149b367d73e\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"other\",\n \"consumer harm\"\n ],\n \"effectiveLiftDate\": \"2034-11-11\",\n \"liftingUser\": \"\"\n }\n ],\n \"birthMonthDay\": \"02-17\",\n \"compact\": \"cosm\",\n \"dateOfExpiration\": \"1319-11-30\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"licenseJurisdiction\": \"az\",\n \"licenses\": [\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"ineligible\",\n \"dateOfExpiration\": \"2896-10-30\",\n \"dateOfIssuance\": \"1132-05-08\",\n \"dateOfRenewal\": \"2793-09-12\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"history\": [\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"al\",\n \"previous\": {\n \"dateOfExpiration\": \"1636-12-14\",\n \"dateOfIssuance\": \"2792-11-01\",\n \"dateOfRenewal\": \"2253-10-25\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"jurisdictionUploadedLicenseStatus\": \"inactive\",\n \"middleName\": \"\",\n \"homeAddressStreet2\": \"\",\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2462-11-31\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+945365259726997\",\n \"licenseStatus\": \"inactive\",\n \"licenseNumber\": \"\",\n \"licenseStatusName\": \"\"\n },\n \"type\": \"licenseUpdate\",\n \"updateType\": \"issuance\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"esthetician\",\n \"updatedValues\": {\n \"homeAddressStreet2\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"givenName\": \"\",\n \"homeAddressStreet1\": \"\",\n \"compactEligibility\": \"ineligible\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"dateOfBirth\": \"1425-05-05\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"suffix\": \"\",\n \"dateOfIssuance\": \"2974-12-05\",\n \"emailAddress\": \"\",\n \"dateOfExpiration\": \"1104-11-05\",\n \"phoneNumber\": \"+5382331736425\",\n \"homeAddressState\": \"\",\n \"dateOfRenewal\": \"2102-02-31\",\n \"licenseStatus\": \"active\",\n \"familyName\": \"\",\n \"homeAddressCity\": \"\",\n \"licenseNumber\": \"\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n }\n },\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"az\",\n \"previous\": {\n \"dateOfExpiration\": \"2330-11-21\",\n \"dateOfIssuance\": \"2331-05-05\",\n \"dateOfRenewal\": \"2354-05-24\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"middleName\": \"\",\n \"homeAddressStreet2\": \"\",\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"1165-11-19\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+18967348\",\n \"licenseStatus\": \"active\",\n \"licenseNumber\": \"\",\n \"licenseStatusName\": \"\"\n },\n \"type\": \"licenseUpdate\",\n \"updateType\": \"licenseDeactivation\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"cosmetologist\",\n \"updatedValues\": {\n \"homeAddressStreet2\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"givenName\": \"\",\n \"homeAddressStreet1\": \"\",\n \"compactEligibility\": \"ineligible\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"dateOfBirth\": \"2551-10-31\",\n \"jurisdictionUploadedLicenseStatus\": \"inactive\",\n \"suffix\": \"\",\n \"dateOfIssuance\": \"2064-08-08\",\n \"emailAddress\": \"\",\n \"dateOfExpiration\": \"2303-01-08\",\n \"phoneNumber\": \"+129540280865927\",\n \"homeAddressState\": \"\",\n \"dateOfRenewal\": \"2440-11-11\",\n \"licenseStatus\": \"inactive\",\n \"familyName\": \"\",\n \"homeAddressCity\": \"\",\n \"licenseNumber\": \"\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n }\n }\n ],\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdiction\": \"va\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"inactive\",\n \"licenseStatus\": \"inactive\",\n \"licenseType\": \"esthetician\",\n \"middleName\": \"\",\n \"providerId\": \"ae61e900-0b46-493a-aa01-d4f2c2c52108\",\n \"type\": \"license-home\",\n \"homeAddressStreet2\": \"\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"providerId\": \"dafe7762-702d-4546-bded-1c17a39c6798\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ks\",\n \"licenseType\": \"\",\n \"providerId\": \"540737f1-e520-4ba3-a990-7437f1d85bad\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"licenseNumber\": \"\",\n \"investigationStatus\": \"underInvestigation\",\n \"dateOfBirth\": \"1643-12-24\",\n \"ssnLastFour\": \"9281\",\n \"phoneNumber\": \"+84009236542\",\n \"licenseStatusName\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2164-09-31\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2797-12-06\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"ky\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"d9d8499a-1f5c-4237-852a-4ca7dc9770ad\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"other\",\n \"consumer harm\"\n ],\n \"effectiveLiftDate\": \"1975-12-28\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2205-04-11\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2283-02-01\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"va\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"7c767061-1812-459f-a808-0e2a2dd06c47\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"other\",\n \"consumer harm\"\n ],\n \"effectiveLiftDate\": \"2577-10-31\",\n \"liftingUser\": \"\"\n }\n ]\n },\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"ineligible\",\n \"dateOfExpiration\": \"1500-05-30\",\n \"dateOfIssuance\": \"1440-11-10\",\n \"dateOfRenewal\": \"1482-11-30\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"history\": [\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"md\",\n \"previous\": {\n \"dateOfExpiration\": \"2810-10-28\",\n \"dateOfIssuance\": \"2039-09-05\",\n \"dateOfRenewal\": \"1259-08-09\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"inactive\",\n \"middleName\": \"\",\n \"homeAddressStreet2\": \"\",\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"1227-02-30\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+64672574598\",\n \"licenseStatus\": \"active\",\n \"licenseNumber\": \"\",\n \"licenseStatusName\": \"\"\n },\n \"type\": \"licenseUpdate\",\n \"updateType\": \"licenseDeactivation\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"cosmetologist\",\n \"updatedValues\": {\n \"homeAddressStreet2\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"givenName\": \"\",\n \"homeAddressStreet1\": \"\",\n \"compactEligibility\": \"ineligible\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2975-01-30\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"suffix\": \"\",\n \"dateOfIssuance\": \"2793-08-06\",\n \"emailAddress\": \"\",\n \"dateOfExpiration\": \"2855-10-13\",\n \"phoneNumber\": \"+963925673992567\",\n \"homeAddressState\": \"\",\n \"dateOfRenewal\": \"2755-08-31\",\n \"licenseStatus\": \"inactive\",\n \"familyName\": \"\",\n \"homeAddressCity\": \"\",\n \"licenseNumber\": \"\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n }\n },\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"az\",\n \"previous\": {\n \"dateOfExpiration\": \"2065-05-27\",\n \"dateOfIssuance\": \"2833-11-09\",\n \"dateOfRenewal\": \"2722-09-12\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"middleName\": \"\",\n \"homeAddressStreet2\": \"\",\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"1933-10-05\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+134240923517135\",\n \"licenseStatus\": \"active\",\n \"licenseNumber\": \"\",\n \"licenseStatusName\": \"\"\n },\n \"type\": \"licenseUpdate\",\n \"updateType\": \"lifting_encumbrance\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"cosmetologist\",\n \"updatedValues\": {\n \"homeAddressStreet2\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"givenName\": \"\",\n \"homeAddressStreet1\": \"\",\n \"compactEligibility\": \"eligible\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"dateOfBirth\": \"2959-11-24\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"suffix\": \"\",\n \"dateOfIssuance\": \"2337-07-08\",\n \"emailAddress\": \"\",\n \"dateOfExpiration\": \"2784-06-03\",\n \"phoneNumber\": \"+419715888481085\",\n \"homeAddressState\": \"\",\n \"dateOfRenewal\": \"1072-11-26\",\n \"licenseStatus\": \"inactive\",\n \"familyName\": \"\",\n \"homeAddressCity\": \"\",\n \"licenseNumber\": \"\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n }\n }\n ],\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdiction\": \"va\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"cosmetologist\",\n \"middleName\": \"\",\n \"providerId\": \"3efdca4f-ce9e-4131-aaa5-3c297b20b883\",\n \"type\": \"license-home\",\n \"homeAddressStreet2\": \"\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"providerId\": \"c161a9b0-decf-4f81-9afe-870c8ea33188\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"al\",\n \"licenseType\": \"\",\n \"providerId\": \"8e3092fd-3c68-43d6-b45d-33ae966a9237\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"licenseNumber\": \"\",\n \"investigationStatus\": \"underInvestigation\",\n \"dateOfBirth\": \"2074-12-03\",\n \"ssnLastFour\": \"3747\",\n \"phoneNumber\": \"+61351667777\",\n \"licenseStatusName\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1932-11-08\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2639-03-01\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"oh\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"8e048e34-5113-4a95-b879-45e74e92f405\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"other\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1774-08-26\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1380-09-31\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1397-04-30\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"al\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"f48aff8a-e316-43b7-b2b0-b6781c5ccd03\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"other\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1079-10-12\",\n \"liftingUser\": \"\"\n }\n ]\n }\n ],\n \"privileges\": [\n {\n \"administratorSetStatus\": \"active\",\n \"compact\": \"cosm\",\n \"compactTransactionId\": \"\",\n \"dateOfExpiration\": \"2132-01-05\",\n \"history\": [\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"al\",\n \"previous\": {\n \"administratorSetStatus\": \"inactive\",\n \"compactTransactionId\": \"\",\n \"dateOfExpiration\": \"1007-03-30\",\n \"licenseJurisdiction\": \"al\",\n \"compact\": \"cosm\",\n \"providerId\": \"717a0322-8ff9-4c4f-a991-be019c784fa7\",\n \"jurisdiction\": \"wa\",\n \"type\": \"privilege\",\n \"status\": \"active\"\n },\n \"type\": \"privilegeUpdate\",\n \"updateType\": \"licenseDeactivation\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"esthetician\",\n \"updatedValues\": {\n \"administratorSetStatus\": \"inactive\",\n \"dateOfExpiration\": \"1245-10-30\",\n \"licenseJurisdiction\": \"az\",\n \"compact\": \"cosm\",\n \"providerId\": \"d8b49e12-c262-4cb7-889e-42ab6e01c5af\",\n \"jurisdiction\": \"al\",\n \"type\": \"privilege\",\n \"compactTransactionId\": \"\",\n \"status\": \"active\"\n }\n },\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"az\",\n \"previous\": {\n \"administratorSetStatus\": \"active\",\n \"compactTransactionId\": \"\",\n \"dateOfExpiration\": \"2796-07-13\",\n \"licenseJurisdiction\": \"wa\",\n \"compact\": \"cosm\",\n \"providerId\": \"92071240-f158-4ee4-86ce-d4037f664eb6\",\n \"jurisdiction\": \"ky\",\n \"type\": \"privilege\",\n \"status\": \"active\"\n },\n \"type\": \"privilegeUpdate\",\n \"updateType\": \"deactivation\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"cosmetologist\",\n \"updatedValues\": {\n \"administratorSetStatus\": \"inactive\",\n \"dateOfExpiration\": \"1066-12-08\",\n \"licenseJurisdiction\": \"ks\",\n \"compact\": \"cosm\",\n \"providerId\": \"9b120a0f-3c7d-4953-882c-502c123e3756\",\n \"jurisdiction\": \"ks\",\n \"type\": \"privilege\",\n \"compactTransactionId\": \"\",\n \"status\": \"active\"\n }\n }\n ],\n \"jurisdiction\": \"co\",\n \"licenseJurisdiction\": \"ks\",\n \"licenseType\": \"cosmetologist\",\n \"providerId\": \"28817ef4-78db-49d5-85bc-956d06d83b39\",\n \"status\": \"active\",\n \"type\": \"privilege\",\n \"investigationStatus\": \"underInvestigation\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ky\",\n \"licenseType\": \"\",\n \"providerId\": \"928a25f0-13e3-4e92-863f-9ee2d4720636\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"providerId\": \"f7ebef49-0486-4534-8d77-8a44f1092383\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"adverseActions\": [\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1376-11-31\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1331-11-31\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"az\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"b3ba6720-ee2b-4b24-8999-2314926229f1\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"other\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"2544-10-04\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2762-06-02\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2516-11-27\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"az\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"e3f3a4c9-0e49-4e3e-9229-fea602ec79f8\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1096-07-28\",\n \"liftingUser\": \"\"\n }\n ]\n },\n {\n \"administratorSetStatus\": \"active\",\n \"compact\": \"cosm\",\n \"compactTransactionId\": \"\",\n \"dateOfExpiration\": \"2641-12-14\",\n \"history\": [\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"co\",\n \"previous\": {\n \"administratorSetStatus\": \"active\",\n \"compactTransactionId\": \"\",\n \"dateOfExpiration\": \"1548-11-17\",\n \"licenseJurisdiction\": \"oh\",\n \"compact\": \"cosm\",\n \"providerId\": \"464113a8-78b3-4149-9f8e-628cc5355a75\",\n \"jurisdiction\": \"oh\",\n \"type\": \"privilege\",\n \"status\": \"active\"\n },\n \"type\": \"privilegeUpdate\",\n \"updateType\": \"other\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"esthetician\",\n \"updatedValues\": {\n \"administratorSetStatus\": \"inactive\",\n \"dateOfExpiration\": \"1798-05-30\",\n \"licenseJurisdiction\": \"al\",\n \"compact\": \"cosm\",\n \"providerId\": \"2b63e61d-f8fd-4598-9065-9c266af243d9\",\n \"jurisdiction\": \"oh\",\n \"type\": \"privilege\",\n \"compactTransactionId\": \"\",\n \"status\": \"inactive\"\n }\n },\n {\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"jurisdiction\": \"wa\",\n \"previous\": {\n \"administratorSetStatus\": \"inactive\",\n \"compactTransactionId\": \"\",\n \"dateOfExpiration\": \"1267-01-31\",\n \"licenseJurisdiction\": \"md\",\n \"compact\": \"cosm\",\n \"providerId\": \"d9d723c8-ab7b-4133-9eab-7488cc0c3364\",\n \"jurisdiction\": \"md\",\n \"type\": \"privilege\",\n \"status\": \"active\"\n },\n \"type\": \"privilegeUpdate\",\n \"updateType\": \"encumbrance\",\n \"removedValues\": [\n \"\",\n \"\"\n ],\n \"licenseType\": \"cosmetologist\",\n \"updatedValues\": {\n \"administratorSetStatus\": \"inactive\",\n \"dateOfExpiration\": \"2726-07-13\",\n \"licenseJurisdiction\": \"ky\",\n \"compact\": \"cosm\",\n \"providerId\": \"3f0c93d6-fbf7-4c7c-aacd-3ff53e9f3b55\",\n \"jurisdiction\": \"ky\",\n \"type\": \"privilege\",\n \"compactTransactionId\": \"\",\n \"status\": \"active\"\n }\n }\n ],\n \"jurisdiction\": \"al\",\n \"licenseJurisdiction\": \"wa\",\n \"licenseType\": \"esthetician\",\n \"providerId\": \"78e40573-28b0-4e8c-80c0-43369c131a14\",\n \"status\": \"active\",\n \"type\": \"privilege\",\n \"investigationStatus\": \"underInvestigation\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ks\",\n \"licenseType\": \"\",\n \"providerId\": \"7370c1ad-02c8-4c3b-b9f1-9cce95d1fba2\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"al\",\n \"licenseType\": \"\",\n \"providerId\": \"04cf1a5d-fcc5-42c4-98e8-d65393cf4a4e\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"adverseActions\": [\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1097-10-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1180-12-31\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"al\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"da629e25-a820-416d-a54c-f579dc9b0557\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"fraud\"\n ],\n \"effectiveLiftDate\": \"1706-12-30\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2383-06-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2098-04-15\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"d48a1e3c-4efb-476b-8572-929c5ad666e6\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1137-12-20\",\n \"liftingUser\": \"\"\n }\n ]\n }\n ],\n \"providerId\": \"f9be7248-5ace-485f-b46e-635d1c052d80\",\n \"type\": \"provider\",\n \"compactEligibility\": \"ineligible\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"dateOfBirth\": \"1703-04-11\",\n \"jurisdictionUploadedLicenseStatus\": \"inactive\",\n \"suffix\": \"\",\n \"ssnLastFour\": \"2985\",\n \"licenseStatus\": \"active\",\n \"middleName\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "999d5cc3-d268-40c2-90e5-d0bac445fbb8", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "ca7b080a-2bce-4ca7-b22e-7ddaebf2f080", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"fraud\"\n ],\n \"encumbranceEffectiveDate\": \"1968-01-31\",\n \"encumbranceType\": \"surrender of license\"\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "edb48f26-4d3d-46cc-87dd-58a2d2ad8fda", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"fraud\"\n ],\n \"encumbranceEffectiveDate\": \"1968-01-31\",\n \"encumbranceType\": \"surrender of license\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "4fe6d742-e13c-4209-ad59-d2160e5414db", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance/:encumbranceId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"effectiveLiftDate\": \"2122-07-31\"\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PATCH", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance/:encumbranceId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance", + ":encumbranceId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "encumbranceId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "dcd53473-fa87-4ede-91a4-078a78316542", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"effectiveLiftDate\": \"2122-07-31\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance", + ":encumbranceId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "{encumbranceId}" + } + ], + "name": "encumbrance" + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "0d16004b-e025-44fc-8e1f-405dff9f8ef7", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "49192cb7-ce70-416c-b3d5-d44dad6d13a5", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "99c7dfae-c084-400c-8bc9-65301e1fb884", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation/:investigationId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"action\": \"close\",\n \"encumbrance\": {\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"consumer harm\"\n ],\n \"encumbranceEffectiveDate\": \"2044-11-30\",\n \"encumbranceType\": \"suspension\"\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PATCH", + "name": "/v1/compacts/:compact/providers/:providerId/licenses/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation/:investigationId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation", + ":investigationId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "investigationId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "a44d3b21-3a43-4aa7-81cd-5e4fb6cfcc0e", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"action\": \"close\",\n \"encumbrance\": {\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"consumer harm\"\n ],\n \"encumbranceEffectiveDate\": \"2044-11-30\",\n \"encumbranceType\": \"suspension\"\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "licenses", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation", + ":investigationId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "{investigationId}" + } + ], + "name": "investigation" + } + ], + "name": "{licenseType}" + } + ], + "name": "licenseType" + } + ], + "name": "{jurisdiction}" + } + ], + "name": "jurisdiction" + } + ], + "name": "licenses" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "3ffbd841-54b4-48a6-b492-641fd2b0469d", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"fraud\"\n ],\n \"encumbranceEffectiveDate\": \"1968-01-31\",\n \"encumbranceType\": \"surrender of license\"\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "dcb4c1ba-f090-4a87-8757-635c16610763", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"fraud\"\n ],\n \"encumbranceEffectiveDate\": \"1968-01-31\",\n \"encumbranceType\": \"surrender of license\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "4d611100-ee45-4867-8256-91edf2fc918c", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance/:encumbranceId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"effectiveLiftDate\": \"2122-07-31\"\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PATCH", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/encumbrance/:encumbranceId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance", + ":encumbranceId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "encumbranceId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "a7cb906d-70d7-481f-99cc-e6fe2c02eba7", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"effectiveLiftDate\": \"2122-07-31\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "encumbrance", + ":encumbranceId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "{encumbranceId}" + } + ], + "name": "encumbrance" + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "267f553f-e2de-4533-8d28-4fa66717b4ae", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "1dc68a74-87db-46de-b302-0fd21f55f20f", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "b26d34d6-168d-4319-8435-d4574eadb833", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation/:investigationId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"action\": \"close\",\n \"encumbrance\": {\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"consumer harm\"\n ],\n \"encumbranceEffectiveDate\": \"2044-11-30\",\n \"encumbranceType\": \"suspension\"\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PATCH", + "name": "/v1/compacts/:compact/providers/:providerId/privileges/jurisdiction/:jurisdiction/licenseType/:licenseType/investigation/:investigationId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation", + ":investigationId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "licenseType", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "investigationId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "74f3b10b-973e-4577-8d98-b8c30256ad49", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"action\": \"close\",\n \"encumbrance\": {\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"consumer harm\"\n ],\n \"encumbranceEffectiveDate\": \"2044-11-30\",\n \"encumbranceType\": \"suspension\"\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + ":providerId", + "privileges", + "jurisdiction", + ":jurisdiction", + "licenseType", + ":licenseType", + "investigation", + ":investigationId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "{investigationId}" + } + ], + "name": "investigation" + } + ], + "name": "{licenseType}" + } + ], + "name": "licenseType" + } + ], + "name": "{jurisdiction}" + } + ], + "name": "jurisdiction" + } + ], + "name": "privileges" + } + ], + "name": "{providerId}" + } + ], + "name": "providers" + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "a0f18bc4-8880-4824-a337-03c8e5e9cc0c", + "name": "/v1/compacts/:compact/staff-users", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact/staff-users", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"pagination\": {\n \"prevLastKey\": {},\n \"lastKey\": {},\n \"pageSize\": \"\"\n },\n \"users\": [\n {\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"auteb\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"qui_21\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"incididunt434\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"dolor58\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"idb17\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"in74\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"in62\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"quis_17\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"in_cfa\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n },\n \"status\": \"active\",\n \"userId\": \"\"\n },\n {\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"et_cad\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"voluptate_ea3\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"in_e8\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolore2f\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"ipsum_9\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"voluptate_c61\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n },\n \"status\": \"inactive\",\n \"userId\": \"\"\n }\n ]\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "Access-Control-Allow-Origin", + "value": "" + } + ], + "id": "df3a2238-9981-471a-89bd-02afe5da2f7b", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "event": [], + "id": "81473fa2-b810-48c1-9456-992dd3db3409", + "name": "/v1/compacts/:compact/staff-users", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"aute__\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"dolore980\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"ad2\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"nostrud3\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"aliquip91\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolored1\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"est_00\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/staff-users", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"exa7a\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"dolore_bb7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"ad2\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"officiad\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"elit391\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"magna_b7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"dolore671\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"aliqua__5\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolorc\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"sede00\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"consectetur_ea\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n },\n \"status\": \"active\",\n \"userId\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "Access-Control-Allow-Origin", + "value": "" + } + ], + "id": "3636f965-1f4d-46b8-9147-8fd2f734e1e6", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"aute__\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"dolore980\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"ad2\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"nostrud3\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"aliquip91\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolored1\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"est_00\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "b292449c-e000-43cc-9a90-30f4b7c181ca", + "name": "/v1/compacts/:compact/staff-users/:userId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact/staff-users/:userId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "userId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"exa7a\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"dolore_bb7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"ad2\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"officiad\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"elit391\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"magna_b7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"dolore671\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"aliqua__5\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolorc\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"sede00\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"consectetur_ea\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n },\n \"status\": \"active\",\n \"userId\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "Access-Control-Allow-Origin", + "value": "" + } + ], + "id": "ec465621-d8b8-4807-b8cb-24aa2e2352b7", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + }, + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 404, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "daddd514-5918-4d44-bfab-57c2fe2792fd", + "name": "404 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [] + } + }, + "status": "Not Found" + } + ] + }, + { + "event": [], + "id": "adbb1078-e800-439c-9b7d-79d047e2dbed", + "name": "/v1/compacts/:compact/staff-users/:userId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "DELETE", + "name": "/v1/compacts/:compact/staff-users/:userId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "userId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "d6dc4851-2005-4c34-aee3-7ab77090300d", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "DELETE", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + }, + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 404, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "29272061-367b-4319-8ca1-40ff0432ab8a", + "name": "404 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "DELETE", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [] + } + }, + "status": "Not Found" + } + ] + }, + { + "event": [], + "id": "130f1ebd-5fc2-4299-a34b-6ccfb6037caf", + "name": "/v1/compacts/:compact/staff-users/:userId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"permissions\": {\n \"mollit02f\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"eu_c\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"fugiat9f\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"eu_5c\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"sint_007\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PATCH", + "name": "/v1/compacts/:compact/staff-users/:userId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "userId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"exa7a\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"dolore_bb7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"ad2\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"officiad\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"elit391\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"magna_b7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"dolore671\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"aliqua__5\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolorc\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"sede00\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"consectetur_ea\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n },\n \"status\": \"active\",\n \"userId\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "Access-Control-Allow-Origin", + "value": "" + } + ], + "id": "aa23c905-1c9a-4b76-a807-8650022433be", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"permissions\": {\n \"mollit02f\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"eu_c\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"fugiat9f\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"eu_5c\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"sint_007\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + }, + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 404, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "bdb7f3fa-8f36-4cda-b2a2-12c0b1816e28", + "name": "404 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"permissions\": {\n \"mollit02f\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"eu_c\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"fugiat9f\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"eu_5c\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"sint_007\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId" + ], + "query": [], + "variable": [] + } + }, + "status": "Not Found" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "1a6f77b0-b795-4c2e-a980-e55303909905", + "name": "/v1/compacts/:compact/staff-users/:userId/reinvite", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/staff-users/:userId/reinvite", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId", + "reinvite" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "userId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "0dd9aa1c-4f72-483b-af70-d387710a4653", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId", + "reinvite" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + }, + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 404, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "e4bc3418-7a1e-4426-b33c-daca588d6100", + "name": "404 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "staff-users", + ":userId", + "reinvite" + ], + "query": [], + "variable": [] + } + }, + "status": "Not Found" + } + ] + } + ], + "name": "reinvite" + } + ], + "name": "{userId}" + } + ], + "name": "staff-users" + } + ], + "name": "{compact}" + } + ], + "name": "compacts" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "fda98b88-10b5-45cb-bc90-f9f31c5499bd", + "name": "/v1/flags/:flagId/check", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"context\": {\n \"userId\": \"\",\n \"customAttributes\": {\n \"sed_5c3\": \"\",\n \"adipisicing_0\": \"\",\n \"animd3\": \"\",\n \"dolore5\": \"\",\n \"nostrud_7\": \"\"\n }\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/flags/:flagId/check", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "flags", + ":flagId", + "check" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "flagId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"enabled\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "749ea113-1dbe-4291-abcb-e98163071bc5", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"context\": {\n \"userId\": \"\",\n \"customAttributes\": {\n \"sed_5c3\": \"\",\n \"adipisicing_0\": \"\",\n \"animd3\": \"\",\n \"dolore5\": \"\",\n \"nostrud_7\": \"\"\n }\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "flags", + ":flagId", + "check" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "check" + } + ], + "name": "{flagId}" + } + ], + "name": "flags" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "db05bb1d-1621-429b-ac5f-6714c3afd221", + "name": "/v1/public/compacts/:compact/jurisdictions", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/public/compacts/:compact/jurisdictions", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "compacts", + ":compact", + "jurisdictions" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "[\n {\n \"compact\": \"\",\n \"jurisdictionName\": \"\",\n \"postalAbbreviation\": \"\"\n },\n {\n \"compact\": \"\",\n \"jurisdictionName\": \"\",\n \"postalAbbreviation\": \"\"\n }\n]", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "af03627e-4ae8-4120-b1df-ce4560e79d2d", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "compacts", + ":compact", + "jurisdictions" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "jurisdictions" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "aa1dfd2c-2c5b-4126-aca6-b0b8e98052ff", + "name": "/v1/public/compacts/:compact/providers/query", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"query\": {\n \"providerId\": \"4d3ac645-b43a-4b5b-8d04-6848afa184dd\",\n \"jurisdiction\": \"az\",\n \"givenName\": \"\",\n \"familyName\": \"\",\n \"licenseNumber\": \"\"\n },\n \"pagination\": {\n \"lastKey\": \"\",\n \"pageSize\": \"\"\n },\n \"sorting\": {\n \"key\": \"dateOfUpdate\",\n \"direction\": \"descending\"\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/public/compacts/:compact/providers/query", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "compacts", + ":compact", + "providers", + "query" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"pagination\": {\n \"prevLastKey\": {},\n \"lastKey\": {},\n \"pageSize\": \"\"\n },\n \"providers\": [\n {\n \"compact\": \"cosm\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"licenseEligibility\": \"eligible\",\n \"licenseJurisdiction\": \"wa\",\n \"licenseNumber\": \"\",\n \"licenseType\": \"\",\n \"providerId\": \"69c540cf-1b63-4ed2-962a-240ee3a02271\"\n },\n {\n \"compact\": \"cosm\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"licenseEligibility\": \"eligible\",\n \"licenseJurisdiction\": \"al\",\n \"licenseNumber\": \"\",\n \"licenseType\": \"\",\n \"providerId\": \"7fc1ff84-7ae1-4a38-bb6f-37a7a66d779a\"\n }\n ],\n \"query\": {\n \"providerId\": \"41f57d7c-dd36-4be8-82f2-429348a9ee6b\",\n \"jurisdiction\": \"va\",\n \"givenName\": \"\",\n \"familyName\": \"\",\n \"licenseNumber\": \"\"\n },\n \"sorting\": {\n \"key\": \"familyName\",\n \"direction\": \"ascending\"\n }\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "b8459001-1f3d-4b6b-b8f4-e175eda9df36", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"query\": {\n \"providerId\": \"4d3ac645-b43a-4b5b-8d04-6848afa184dd\",\n \"jurisdiction\": \"az\",\n \"givenName\": \"\",\n \"familyName\": \"\",\n \"licenseNumber\": \"\"\n },\n \"pagination\": {\n \"lastKey\": \"\",\n \"pageSize\": \"\"\n },\n \"sorting\": {\n \"key\": \"dateOfUpdate\",\n \"direction\": \"descending\"\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "compacts", + ":compact", + "providers", + "query" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "query" + }, + { + "description": "", + "item": [ + { + "event": [], + "id": "119f5aae-3585-4272-955a-9d8213f1467b", + "name": "/v1/public/compacts/:compact/providers/:providerId", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/public/compacts/:compact/providers/:providerId", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "compacts", + ":compact", + "providers", + ":providerId" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "providerId", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"compact\": \"cosm\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"licenseJurisdiction\": \"al\",\n \"providerId\": \"538c4dde-65b9-47e3-be4f-1f785335fbc2\",\n \"type\": \"provider\",\n \"privileges\": [\n {\n \"administratorSetStatus\": \"inactive\",\n \"compact\": \"cosm\",\n \"dateOfExpiration\": \"2149-12-09\",\n \"jurisdiction\": \"md\",\n \"licenseJurisdiction\": \"tn\",\n \"licenseType\": \"esthetician\",\n \"providerId\": \"37d67147-12c8-4ace-b1f9-dcda9ad436fd\",\n \"status\": \"active\",\n \"type\": \"privilege\"\n },\n {\n \"administratorSetStatus\": \"active\",\n \"compact\": \"cosm\",\n \"dateOfExpiration\": \"1651-11-15\",\n \"jurisdiction\": \"co\",\n \"licenseJurisdiction\": \"tn\",\n \"licenseType\": \"cosmetologist\",\n \"providerId\": \"e3d31465-7766-46a8-af20-82a398cfab02\",\n \"status\": \"inactive\",\n \"type\": \"privilege\"\n }\n ],\n \"licenses\": [\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"eligible\",\n \"dateOfExpiration\": \"1944-11-30\",\n \"jurisdiction\": \"md\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"esthetician\",\n \"type\": \"license\"\n },\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"eligible\",\n \"dateOfExpiration\": \"2388-09-27\",\n \"jurisdiction\": \"al\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"cosmetologist\",\n \"type\": \"license\"\n }\n ],\n \"middleName\": \"\",\n \"suffix\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "3a4dc02a-3913-4516-b1e8-c03b443589bf", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "compacts", + ":compact", + "providers", + ":providerId" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "{providerId}" + } + ], + "name": "providers" + } + ], + "name": "{compact}" + } + ], + "name": "compacts" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "55d7b4b5-c5f2-46f3-837d-c85dfa75d29b", + "name": "/v1/public/jurisdictions/live", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/public/jurisdictions/live", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "jurisdictions", + "live" + ], + "query": [ + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "value": "" + } + ], + "variable": [] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"sed3e\": [\n \"md\",\n \"wa\"\n ],\n \"est_8__\": [\n \"ks\",\n \"ky\"\n ],\n \"sunt8bb\": [\n \"az\",\n \"oh\"\n ]\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "8fa34e72-2336-4ddc-8e0c-61da5d26473e", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "public", + "jurisdictions", + "live" + ], + "query": [ + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "value": "" + } + ], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "live" + } + ], + "name": "jurisdictions" + } + ], + "name": "public" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "ee6b9034-05f4-4d93-8e6d-200505d733e3", + "name": "/v1/staff-users/me", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/staff-users/me", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "staff-users", + "me" + ], + "query": [], + "variable": [] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"exa7a\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"dolore_bb7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"ad2\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"officiad\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"elit391\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"magna_b7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"dolore671\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"aliqua__5\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolorc\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"sede00\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"consectetur_ea\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n },\n \"status\": \"active\",\n \"userId\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "Access-Control-Allow-Origin", + "value": "" + } + ], + "id": "77c410b0-8dfe-4a7e-9f3c-230239f8b9a8", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "staff-users", + "me" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + }, + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 404, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "6b33fac8-04ff-4d58-a141-2e49b5229e77", + "name": "404 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "staff-users", + "me" + ], + "query": [], + "variable": [] + } + }, + "status": "Not Found" + } + ] + }, + { + "event": [], + "id": "81f67ebf-e4cc-4099-a7d9-dbd9e4e1bf33", + "name": "/v1/staff-users/me", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"attributes\": {\n \"givenName\": \"\",\n \"familyName\": \"\"\n }\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "PATCH", + "name": "/v1/staff-users/me", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "staff-users", + "me" + ], + "query": [], + "variable": [] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"attributes\": {\n \"email\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\"\n },\n \"permissions\": {\n \"exa7a\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"dolore_bb7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"ad2\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"officiad\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"elit391\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"magna_b7\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"dolore671\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"aliqua__5\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n },\n \"dolorc\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n },\n \"sede00\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\"\n },\n \"jurisdictions\": {\n \"consectetur_ea\": {\n \"actions\": {\n \"readPrivate\": \"\",\n \"admin\": \"\",\n \"write\": \"\"\n }\n }\n }\n }\n },\n \"status\": \"active\",\n \"userId\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "description": { + "content": "", + "type": "text/plain" + }, + "disabled": false, + "key": "Access-Control-Allow-Origin", + "value": "" + } + ], + "id": "34fddfbc-4d3b-4d43-9927-9556500c959f", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"attributes\": {\n \"givenName\": \"\",\n \"familyName\": \"\"\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "staff-users", + "me" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + }, + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 404, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "07da24a7-cb4a-4b43-993e-0e321a15a849", + "name": "404 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"attributes\": {\n \"givenName\": \"\",\n \"familyName\": \"\"\n }\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "PATCH", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "staff-users", + "me" + ], + "query": [], + "variable": [] + } + }, + "status": "Not Found" + } + ] + } + ], + "name": "me" + } + ], + "name": "staff-users" + } + ], + "name": "v1" + }, + { + "name": "Upload Document", + "request": { + "auth": { + "type": "noauth" + }, + "body": { + "formdata": [ + { + "key": "key", + "type": "text", + "value": "{{docField-key}}" + }, + { + "key": "x-amz-algorithm", + "type": "text", + "value": "{{docField-x-amz-algorithm}}" + }, + { + "key": "x-amz-credential", + "type": "text", + "value": "{{docField-x-amz-credential}}" + }, + { + "key": "x-amz-date", + "type": "text", + "value": "{{docField-x-amz-date}}" + }, + { + "key": "x-amz-signature", + "type": "text", + "value": "{{docField-x-amz-signature}}" + }, + { + "key": "x-amz-security-token", + "type": "text", + "value": "{{docField-x-amz-security-token}}" + }, + { + "key": "policy", + "type": "text", + "value": "{{docField-policy}}" + }, + { + "key": "file", + "src": "AmVhGkArk/octp-nc-mock-data.csv", + "type": "file" + } + ], + "mode": "formdata" + }, + "header": [], + "method": "POST", + "url": { + "host": [ + "{{docUrl}}" + ], + "raw": "{{docUrl}}" + } + }, + "response": [] + } + ] +} diff --git a/backend/social-work-app/docs/internal/postman/postman-environment.json b/backend/social-work-app/docs/internal/postman/postman-environment.json new file mode 100644 index 0000000000..dbb3b5d272 --- /dev/null +++ b/backend/social-work-app/docs/internal/postman/postman-environment.json @@ -0,0 +1,45 @@ +{ + "id": "65234e00-5ac9-4819-8620-d1b9076dcbce", + "name": "CompactConnect - beta", + "values": [ + { + "key": "boardUserPoolUrl", + "value": "https://staff-auth.test.compactconnect.org", + "type": "default", + "enabled": true + }, + { + "key": "baseUrl", + "value": "https://api.test.compactconnect.org", + "type": "default", + "enabled": true + }, + { + "key": "uiUrl", + "value": "https://app.test.compactconnect.org", + "type": "default", + "enabled": true + }, + { + "key": "m2mClientId", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "m2mClientSecret", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "m2mScopes", + "value": "ky/aslp.write ky/aslp.readPrivate aslp/readGeneral", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2024-08-02T19:23:16.240Z", + "_postman_exported_using": "Postman/11.6.2-240731-0006" +} diff --git a/backend/social-work-app/docs/it_staff_onboarding_instructions.md b/backend/social-work-app/docs/it_staff_onboarding_instructions.md new file mode 100644 index 0000000000..776ee3ea7e --- /dev/null +++ b/backend/social-work-app/docs/it_staff_onboarding_instructions.md @@ -0,0 +1,338 @@ +# CompactConnect Automated License Data Upload Instructions + +## Overview + +CompactConnect is a centralized platform that facilitates interstate license recognition for healthcare professionals +through occupational licensing compacts. These compacts allow practitioners with licenses in good standing to work +across state lines without obtaining additional licenses. + +As a state IT department responsible for managing professional license data, your role is crucial in this process. +This document provides instructions for integrating your existing licensing systems with CompactConnect through its API. + +By automating license data uploads, your state will: + +- **Ensure Timely Data Synchronization**: Keep the compact database up-to-date with your state's latest license + information +- **Reduce Manual Work**: Eliminate the need for manual license data entry by staff +- **Improve Accuracy**: Minimize human error in license data transmission +- **Support Interstate Mobility**: Enable qualified professionals to practice in participating states +- **Meet Compact Obligations**: Fulfill your state's requirements as a compact member + +This document outlines the technical process for setting up machine-to-machine authentication and automated license data +uploads to CompactConnect's API, as well as common strategies for uploading data into the system. +Following these instructions will help you establish a secure, reliable connection +between your licensing systems and the CompactConnect platform. + +## Credential Security + +This article assumes you have received a one-time use link to access your API credentials, along with an email containing contextual +information about your integration. After retrieving the credentials, please: + +1. Store the credentials securely in a password manager or secrets management system +2. Do not share these credentials with unauthorized personnel +3. Do not hardcode these credentials in source code repositories +4. Keep the contextual information (compact, state, URLs) for reference during integration + +> **Important**: If the link provided has already been used when you attempt to access the credentials, please contact +> the individual who sent the link to you as the credentials will need to be regenerated and sent using another link. +> +> Likewise, if these credentials are ever accidentally shared or compromised, please inform the CompactConnect team as +> soon as possible, so the credentials can be deactivated and regenerated to prevent abuse of the system. + +The credentials will be sent to you in this format: + +```json +{ + "clientId": "", + "clientSecret": "" +} +``` + +You will also receive an email with the following contextual information: +- **Compact**: The full name of the compact (e.g., "Social Work") +- **State**: Your state's postal abbreviation (e.g., "KY", "LA") +- **Auth URL**: The authentication endpoint URL +- **License Upload URL**: The API endpoint for uploading license data + +## Authentication Process for Uploading License Data + +Follow these steps to obtain an access token and make requests to the CompactConnect License API: + +### Step 1: Generate an Access Token + +You must first obtain an access token to authenticate your API requests. The access token will be used in the +Authorization header of subsequent API calls. While the following curl command demonstrates how to generate a token, you should implement this authentication flow in your application's programming language using +appropriate HTTPS request libraries: + +> **Note**: When copying commands, be careful of line breaks. You may need to remove any extra spaces or +> line breaks that occur when pasting. + +```bash +curl --location --request POST '' \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--header 'Accept: application/json' \ +--data-urlencode 'grant_type=client_credentials' \ +--data-urlencode 'client_id=' \ +--data-urlencode 'client_secret=' \ +--data-urlencode 'scope=/.write' +``` + +Replace: +- `` with your client ID +- `` with your client secret +- `` with your lower-cased two-letter state code (e.g., `ky` for Kentucky) - this information was provided + in the email +- `` with the lower-cased compact abbreviation (`cosm` for the 'Social Work' Compact) - this + information was provided in the email + +Example response: +```json +{ + "access_token": "", + "expires_in": 900, + "token_type": "Bearer" +} +``` + +For more information about this authentication process, please see the following +AWS documentation: https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html + +**Important Notes**: +- For security reasons, the access token is valid for 15 minutes from the time it is generated (900 seconds) +- Your application should request a new token before the current one expires +- Store the `access_token` value for use in API requests + +### Step 2: Upload License Data (JSON POST Endpoint) + +The CompactConnect License API can be called through a POST REST endpoint which takes in a list of license record +objects. The following curl command example demonstrates how to upload license data, but +you should implement this API call in your application's programming language using appropriate HTTPS request libraries. +You will need to replace the example payload with valid license data that includes the correct license types for your +specific compact. See the +[Technical User Guide](./README.md) for more details about API use: + +```bash +curl --location --request POST 'https://state-api.beta.compactconnect.org/v1/compacts//jurisdictions//licenses' \ +--header 'Authorization: Bearer ' \ +--header 'Content-Type: application/json' \ +--header 'User-Agent: / ()' \ +--data '[ + { + "ssn":"123-45-6789", + "licenseNumber":"LIC123456", + "licenseStatusName":"Active", + "licenseStatus":"active", + "compactEligibility":"eligible", + "licenseType":"cosmetologist", + "givenName":"Jane", + "middleName":"Marie", + "familyName":"Smith", + "dateOfIssuance":"2023-01-15", + "dateOfRenewal":"2023-01-15", + "dateOfExpiration":"2025-01-14", + "dateOfBirth":"1980-05-20", + "homeAddressStreet1":"123 Main Street", + "homeAddressStreet2":"Apt 4B", + "homeAddressCity":"Louisville", + "homeAddressState":"KY", + "homeAddressPostalCode":"40202", + "emailAddress":"jane.smith@example.com", + "phoneNumber":"+15555551234" + } +]' +``` + +Replace: +- `` with the access token from Step 1 +- `` with the lower-cased compact abbreviation (e.g., `cosm`) - this information was + provided in the email. +- `` with your lower-cased two-letter state code (e.g., `ky`) - this information was provided in the email +- The `User-Agent` header value with your own application name, version, and contact information +- The example payload shown here with your test license data + +Note: The URL was provided during onboarding and is already configured for your jurisdiction and compact. + +> **Note**: While the JSON API accepts arrays of up to 100 records, some states have found it's easier to send one license record per request. See the [Common Upload Strategies](#common-upload-strategies-json-vs-csv) section below for detailed guidance. + +### Step 2 Alternative: Upload License Data via CSV File + +In addition to calling the POST endpoint, there is also an option to upload license data in a CSV file format. +This method may be preferable for the initial data upload or for systems that already generate CSV exports. + +#### CSV Upload Process + +The CSV upload process involves two steps: + +**Step 2a: Get Upload Configuration** + +First, obtain the upload URL and required form fields: + +```bash +curl --location --request GET 'https://state-api.beta.compactconnect.org/v1/compacts//jurisdictions//licenses/bulk-upload' \ +--header 'Authorization: Bearer ' \ +--header 'Accept: application/json' \ +--header 'User-Agent: / ()' +``` + +Replace: +- `` with the access token from Step 1 +- `` with the lower-cased compact abbreviation (e.g., `cosm`) - this information was + provided in the email. +- `` with your lower-cased two-letter state code (e.g., `ky`) - this information was provided in the email +- The `User-Agent` header value with your own application name, version, and contact information + +This will return a response like: +```json +{ + "upload": { + "url": "", + "fields": { + "key": "", + "x-amz-algorithm": "AWS4-HMAC-SHA256", + "x-amz-credential": "", + "x-amz-date": "20240101T000000Z", + "x-amz-security-token": "", + "policy": "", + "x-amz-signature": "", + } + } +} +``` + +**Step 2b: Upload Your CSV File** + +Using the URL and fields from Step 2a, upload your CSV file. **Important**: You must include all the fields from the response, plus a `content-type` field set to `text/csv`, and your file: + +```bash +curl --location --request POST '' \ +--form 'key=""' \ +--form 'x-amz-algorithm=""' \ +--form 'x-amz-credential=""' \ +--form 'x-amz-date=""' \ +--form 'x-amz-signature=""' \ +--form 'x-amz-security-token=""' \ +--form 'policy=""' \ +--form 'content-type="text/csv"' \ +--form 'file=@"/path/to/your/licenses.csv"' +``` + +**IMPORTANT**: The order of form fields matters for S3 uploads. Ensure the `file` field comes last, and all AWS signature fields are included as shown. + +Note that, when using the bulk-upload feature, processing of licenses is asynchronous, and so feedback on invalid +licenses is slow. The operational reports contact email will be sent a nightly report with a sample of validation +errors, if there were any, from the day's uploads. For faster feedback, we highly recommend that states with the +capability integrate with the JSON endpoint described above in step 2 instead, for more efficient communication and feedback. + +## License Data Schema Requirements + +For the latest information about the license data field requirements, along with descriptions of each field, please see the field description +section of [the technical user guide](./README.md#field-descriptions). + +See the API specification at [Open API Specification](./README.md#open-api-specification) for more API schema details. + +For your convenience, use of this feature is included in the [Postman Collection](./postman/postman-collection.json). + +**Important Notes**: +- If `licenseStatus` is "inactive", `compactEligibility` cannot be "eligible" +- `licenseType` must match exactly with one of the valid types for the specified compact +- All date fields must use the `YYYY-MM-DD` format +- The API does not accept `null` values. For optional fields with no value, omit the field or leave it empty in CSV. +- For CSV uploads, SSNs must be unique within a single file. Do not include multiple rows with the same `ssn` in one upload. If duplicate SSNs are sent within the same file, the first row will be processed, but all other duplicate rows will be rejected. +- For JSON uploads, SSNs must be unique within a single request payload (array). Do not include duplicate `ssn` values in the same batch. Attempting to do so will cause the entire request to be rejected. + +## Common Upload Strategies: JSON vs CSV + +Based on feedback from state IT departments that have successfully integrated with CompactConnect, the following approaches have been used for different use cases: + +### Bulk Uploads: CSV Methods + +For states that need to upload a large number of existing license records (typically during initial onboarding), or systems that are set up to primarily export data in CSV formats, CSV upload has been the preferred approach. + +Note that CSV uploads are an asynchronous process, meaning that **there may still be validation errors in the data even if the file is uploaded successfully.** CompactConnect will process all the valid records in the CSV file, and will report on any licenses in the file that could not be processed due to validation error. In order to receive data validation error notifications from CompactConnect, your state administrator must configure your email address as a point of contact for operation support. See [System Configuration section of the Staff User Documentation](../../../staff-user-documentation/README.md#system-configuration) + +#### Manual CSV uploads +If a state IT department intends to use the CSV upload process only once for the initial upload, or their system is not able to process automated uploads, consider using the **web-based CSV upload interface**. Your compact state administrator will need to create a staff user account for you with **Write permissions**, which you will use to access the application and upload the data, see [Data Upload section of the Staff User Documentation](../../../staff-user-documentation/README.md#data-upload) + +#### API CSV uploads +If a state IT department intends to use the CSV upload process continuously for every upload of new and updated data into the system, consider integrating your system with the CSV Bulk Upload API as described above. + +### Ongoing Updates: JSON Method + +For ongoing license updates after the initial bulk upload, consider using the **JSON API**. The JSON API supports processing up to 100 license records per request, but if any records within the batch are invalid, **the entire batch will be rejected**. Some states have found that sending one license record per JSON request (rather than batching multiple records) makes it easier to track issues within their data. + +The JSON API is not recommended for performing initial massive bulk uploads, as there are rate limits in place that will throttle high volumes of requests from the same IT department. + + +## Verification that License Records are Uploaded + +After submitting license data to the JSON API, you can verify that your records were successfully uploaded by checking the API response. See [Troubleshooting Common Issues](it_staff_onboarding_instructions.md#troubleshooting-common-issues) + +For CSV uploads, if your email address is configured to receive the upload error reports, you will receive an email in the event that the license data had any errors. See [System Configuration section of the Staff User Documentation](../../../staff-user-documentation/README.md#system-configuration) for setting up contacts to receive these error notifications. + +### 1. Successful Upload +If the API responds with a 200 status code, your request was accepted and basic validation passed (e.g., schema and +field-level checks). The data is then queued for asynchronous ingest and business processing. The response will be: + +```json +{ + "message": "OK" +} +``` + +### 2. Error Responses +If you receive an error response, check the status code and message: +- **400**: Bad Request - Your request data is invalid (check the response body for validation errors) +- **401**: Unauthorized - Your access token is invalid or expired +- **403**: Forbidden - Your app client doesn't have permission to upload to the specified jurisdiction/compact + Also note that some firewall rules require a valid `User-Agent` header; omitting it will result in 403. +- **415**: Unsupported Media Type - Ensure `Content-Type: application/json` is set for this endpoint. +- **502**: Internal Server Error - There was a problem processing your request + +### 3. Validation Errors +If your license data fails validation, the API will return a 400 status code with details about the +validation errors in the response body. + +> **Note**: 200 status code means your request passed synchronous validation and was accepted for processing. Ingest and +> downstream processing are asynchronous and may take several minutes. + +## Troubleshooting Common Issues + +### 1. "Unknown error parsing request body" +- Ensure your JSON data is properly formatted with no trailing commas +- Check that all quoted strings use double quotes, not single quotes +- Verify that your payload is a valid JSON array, even for a single license record + +### 2. Authentication errors (401) +- Your access token might have expired - generate a new one +- Make sure you're including the "Bearer" prefix before the token in the Authorization header + +### 3. Validation errors (400) +- Check the error response for specific validation issues +- Ensure all required fields are present and formatted correctly + +```json +{ + "message": "Invalid license records in request. See errors for more detail.", + "errors": { + "0": { + "licenseType": ["Missing data for required field."], + }, + "1": { + "dateOfBirth": ["Not a valid date."], + "compactEligibility": ["Must be one of: eligible, ineligible."] + } + } +} +``` + +## Implementation Recommendations + +1. Implement token refresh logic to get a new token before the current one expires +2. Implement error handling for API responses +3. Configure your application to securely store and access the client credentials, **do not store the credentials in your +application code.** + +## Support and Feedback + +If you encounter any issues, have questions, or would like to provide feedback based on your experience working with +the CompactConnect API, please contact the individual which sent you the credentials. diff --git a/backend/social-work-app/docs/postman/postman-collection.json b/backend/social-work-app/docs/postman/postman-collection.json new file mode 100644 index 0000000000..c6328c0c6c --- /dev/null +++ b/backend/social-work-app/docs/postman/postman-collection.json @@ -0,0 +1,824 @@ +{ + "auth": { + "bearer": [ + { + "key": "token", + "type": "string", + "value": "{{accessToken}}" + } + ], + "type": "bearer" + }, + "info": { + "_postman_id": "aa3254dc-cc8c-4d01-a794-29804caee5aa", + "description": { + "content": "", + "type": "text/plain" + }, + "name": "CompactConnect API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "item": [ + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "client-credentials-grant", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "client_credentials" + }, + { + "key": "client_id", + "value": "{{m2mClientId}}" + }, + { + "key": "client_secret", + "value": "{{m2mClientSecret}}" + }, + { + "key": "scope", + "value": "{{m2mScopes}}" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=client_credentials&client_id={{m2mClientId}}&client_secret={{m2mClientSecret}}&scope={{m2mScopes}}" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});", + "", + "pm.test('Identity token returned', () => {", + " var id_token = pm.response.json().id_token;", + " pm.expect(id_token).not.to.be.empty;", + " pm.environment.set(\"idToken\", id_token);", + " console.log('id token: ' + id_token);", + "});", + "", + "pm.test('Refresh token returned', () => {", + " var refresh_token = pm.response.json().refresh_token;", + " pm.expect(refresh_token).not.to.be.empty;", + " pm.environment.set(\"refreshToken\", refresh_token);", + " console.log('refresh token: ' + refresh_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-grant-token", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "authorization_code" + }, + { + "key": "code", + "value": "f23723c3-1d21-40e1-89ec-64807d2d658d" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "scope", + "value": "openid" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-authorize", + "protocolProfileBehavior": { + "followRedirects": false + }, + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "disabled": true, + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "disabled": true, + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "authorize" + ], + "query": [ + { + "key": "response_type", + "value": "code" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + }, + { + "key": "scope", + "value": "openid" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-login", + "protocolProfileBehavior": { + "followRedirects": false + }, + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "authorize" + ], + "query": [ + { + "key": "scope", + "value": "openid" + }, + { + "key": "response_type", + "value": "code" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + }, + { + "key": "identity_provider", + "value": "COGNITO" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});", + "", + "pm.test('Identity token returned', () => {", + " var id_token = pm.response.json().id_token;", + " pm.environment.set(\"idToken\", id_token);", + " console.log('id token: ' + id_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "refresh-token", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "refresh_token" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "refresh_token", + "value": "{{refreshToken}}" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=refresh_token&client_id={{clientId}}&refresh_token={{refreshToken}}" + } + }, + "response": [] + } + ], + "name": "Staff-Auth" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "950dbc5d-759c-46e3-9db1-ae172063583e", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction/licenses", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "[\n {\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2159-10-07\",\n \"dateOfExpiration\": \"1122-03-03\",\n \"dateOfIssuance\": \"1692-10-30\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"esthetician\",\n \"ssn\": \"172-51-9536\",\n \"homeAddressStreet2\": \"\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+25906623\",\n \"dateOfRenewal\": \"1987-09-27\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n },\n {\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2188-08-31\",\n \"dateOfExpiration\": \"2548-02-08\",\n \"dateOfIssuance\": \"2942-06-30\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"esthetician\",\n \"ssn\": \"058-01-7527\",\n \"homeAddressStreet2\": \"\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+4163553436\",\n \"dateOfRenewal\": \"2585-11-02\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n }\n]" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction/licenses", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction", + "licenses" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "16298898-f09f-429b-8fdc-70c26ca237f9", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "[\n {\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2159-10-07\",\n \"dateOfExpiration\": \"1122-03-03\",\n \"dateOfIssuance\": \"1692-10-30\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"esthetician\",\n \"ssn\": \"172-51-9536\",\n \"homeAddressStreet2\": \"\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+25906623\",\n \"dateOfRenewal\": \"1987-09-27\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n },\n {\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2188-08-31\",\n \"dateOfExpiration\": \"2548-02-08\",\n \"dateOfIssuance\": \"2942-06-30\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"esthetician\",\n \"ssn\": \"058-01-7527\",\n \"homeAddressStreet2\": \"\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+4163553436\",\n \"dateOfRenewal\": \"2585-11-02\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n }\n]" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction", + "licenses" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + }, + { + "_postman_previewlanguage": "json", + "body": "{\n \"message\": \"\",\n \"errors\": {\n \"nulla76a\": {\n \"tempor_1\": [\n \"\",\n \"\"\n ]\n }\n }\n}", + "code": 400, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "de4d86f6-3083-42ec-a2cd-1f5531148be5", + "name": "400 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "[\n {\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2159-10-07\",\n \"dateOfExpiration\": \"1122-03-03\",\n \"dateOfIssuance\": \"1692-10-30\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"esthetician\",\n \"ssn\": \"172-51-9536\",\n \"homeAddressStreet2\": \"\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+25906623\",\n \"dateOfRenewal\": \"1987-09-27\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n },\n {\n \"compactEligibility\": \"eligible\",\n \"dateOfBirth\": \"2188-08-31\",\n \"dateOfExpiration\": \"2548-02-08\",\n \"dateOfIssuance\": \"2942-06-30\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"esthetician\",\n \"ssn\": \"058-01-7527\",\n \"homeAddressStreet2\": \"\",\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"phoneNumber\": \"+4163553436\",\n \"dateOfRenewal\": \"2585-11-02\",\n \"middleName\": \"\",\n \"licenseStatusName\": \"\"\n }\n]" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction", + "licenses" + ], + "query": [], + "variable": [] + } + }, + "status": "Bad Request" + } + ] + }, + { + "description": "", + "item": [ + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('URL returned', () => {", + " var url = pm.response.json().upload.url;", + " pm.expect(url).not.to.be.empty;", + " pm.environment.set(\"docUrl\", url);", + " console.log('Upload url: ' + url);", + "});", + "", + "pm.test('Fields returned', () => {", + " var fields = pm.response.json().upload.fields;", + " pm.expect(fields).not.to.be.empty;", + " for (const [key, value] of Object.entries(fields)) {", + " pm.environment.set(`docField-${key}`, value);", + " console.log(`Doc field \"${key}\": ${value}`);", + " }", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "id": "e2d7115d-bc92-4c6e-b6d1-396eecee8832", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction/licenses/bulk-upload", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": {}, + "description": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "name": "/v1/compacts/:compact/jurisdictions/:jurisdiction/licenses/bulk-upload", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction", + "licenses", + "bulk-upload" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + }, + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "jurisdiction", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"upload\": {\n \"fields\": {\n \"velitd2\": \"\",\n \"quis_b\": \"\"\n },\n \"url\": \"\"\n }\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "55a4e48a-b54d-48a3-bd95-2dc7ec625e63", + "name": "200 response", + "originalRequest": { + "body": {}, + "header": [ + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "GET", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "jurisdictions", + ":jurisdiction", + "licenses", + "bulk-upload" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "bulk-upload" + } + ], + "name": "licenses" + } + ], + "name": "{jurisdiction}" + } + ], + "name": "jurisdictions" + } + ], + "name": "{compact}" + } + ], + "name": "compacts" + } + ], + "name": "v1" + }, + { + "name": "Upload Document", + "request": { + "auth": { + "type": "noauth" + }, + "body": { + "formdata": [ + { + "key": "key", + "type": "text", + "value": "{{docField-key}}" + }, + { + "key": "x-amz-algorithm", + "type": "text", + "value": "{{docField-x-amz-algorithm}}" + }, + { + "key": "x-amz-credential", + "type": "text", + "value": "{{docField-x-amz-credential}}" + }, + { + "key": "x-amz-date", + "type": "text", + "value": "{{docField-x-amz-date}}" + }, + { + "key": "x-amz-signature", + "type": "text", + "value": "{{docField-x-amz-signature}}" + }, + { + "key": "x-amz-security-token", + "type": "text", + "value": "{{docField-x-amz-security-token}}" + }, + { + "key": "policy", + "type": "text", + "value": "{{docField-policy}}" + }, + { + "key": "file", + "src": "AmVhGkArk/octp-nc-mock-data.csv", + "type": "file" + } + ], + "mode": "formdata" + }, + "header": [], + "method": "POST", + "url": { + "host": [ + "{{docUrl}}" + ], + "raw": "{{docUrl}}" + } + }, + "response": [] + } + ] +} diff --git a/backend/social-work-app/docs/postman/postman-environment.json b/backend/social-work-app/docs/postman/postman-environment.json new file mode 100644 index 0000000000..b96c62bb66 --- /dev/null +++ b/backend/social-work-app/docs/postman/postman-environment.json @@ -0,0 +1,45 @@ +{ + "id": "65234e00-5ac9-4819-8620-d1b9076dcbce", + "name": "CompactConnect - beta", + "values": [ + { + "key": "boardUserPoolUrl", + "value": "https://staff-auth.beta.socialwork.compactconnect.org", + "type": "default", + "enabled": true + }, + { + "key": "baseUrl", + "value": "https://state-api.beta.socialwork.compactconnect.org", + "type": "default", + "enabled": true + }, + { + "key": "uiUrl", + "value": "https://app.beta.socialwork.compactconnect.org", + "type": "default", + "enabled": true + }, + { + "key": "m2mClientId", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "m2mClientSecret", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "m2mScopes", + "value": "ky/socw.write ky/socw.readPrivate socw/readGeneral", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2024-08-02T19:23:16.240Z", + "_postman_exported_using": "Postman/11.6.2-240731-0006" +} diff --git a/backend/social-work-app/docs/search-internal/api-specification/latest-oas30.json b/backend/social-work-app/docs/search-internal/api-specification/latest-oas30.json new file mode 100644 index 0000000000..e0c6f8026d --- /dev/null +++ b/backend/social-work-app/docs/search-internal/api-specification/latest-oas30.json @@ -0,0 +1,896 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "SearchApi", + "version": "2026-04-17T19:42:45Z" + }, + "servers": [ + { + "url": "https://search.beta.compactconnect.org", + "x-amazon-apigateway-endpoint-configuration": { + "disableExecuteApiEndpoint": true + } + } + ], + "paths": { + "/v1/compacts/{compact}/providers/search": { + "post": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSSearch7KTSu0MG6ez" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestSSearcCrqH7R9Q0ysc" + } + } + } + } + }, + "security": [ + { + "TestBackendCosmetologyTestSearchAPIStackSearchApiStaffUsersPoolAuthorizer02E7F51B": [ + "socw/readGeneral" + ] + } + ] + } + } + }, + "components": { + "schemas": { + "TestSSearcCrqH7R9Q0ysc": { + "required": [ + "providers", + "total" + ], + "type": "object", + "properties": { + "total": { + "type": "object", + "properties": { + "value": { + "type": "integer" + }, + "relation": { + "type": "string", + "enum": [ + "eq", + "gte" + ] + } + }, + "description": "Total hits information from OpenSearch" + }, + "lastSort": { + "type": "array", + "description": "Sort values from the last hit to use with search_after for the next page" + }, + "providers": { + "type": "array", + "items": { + "required": [ + "birthMonthDay", + "compact", + "compactEligibility", + "dateOfExpiration", + "dateOfUpdate", + "familyName", + "givenName", + "jurisdictionUploadedCompactEligibility", + "jurisdictionUploadedLicenseStatus", + "licenseJurisdiction", + "licenseStatus", + "providerId", + "type" + ], + "type": "object", + "properties": { + "privileges": { + "type": "array", + "items": { + "required": [ + "administratorSetStatus", + "compact", + "dateOfExpiration", + "jurisdiction", + "licenseJurisdiction", + "licenseType", + "providerId", + "status", + "type" + ], + "type": "object", + "properties": { + "investigationStatus": { + "type": "string", + "enum": [ + "underInvestigation" + ] + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "investigations": { + "type": "array", + "items": { + "required": [ + "compact", + "creationDate", + "dateOfUpdate", + "investigationId", + "jurisdiction", + "licenseType", + "providerId", + "submittingUser", + "type" + ], + "type": "object", + "properties": { + "licenseType": { + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "submittingUser": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "investigation" + ] + }, + "creationDate": { + "type": "string", + "format": "date-time" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "type": { + "type": "string", + "enum": [ + "privilege" + ] + }, + "compactTransactionId": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "administratorSetStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "adverseActions": { + "type": "array", + "items": { + "required": [ + "actionAgainst", + "adverseActionId", + "compact", + "creationDate", + "dateOfUpdate", + "effectiveStartDate", + "encumbranceType", + "jurisdiction", + "licenseType", + "licenseTypeAbbreviation", + "providerId", + "submittingUser", + "type" + ], + "type": "object", + "properties": { + "clinicalPrivilegeActionCategories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "adverseAction" + ] + }, + "creationDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "actionAgainst": { + "type": "string", + "enum": [ + "license", + "privilege" + ] + }, + "licenseType": { + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "submittingUser": { + "type": "string" + }, + "effectiveStartDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "encumbranceType": { + "type": "string" + }, + "liftingUser": { + "type": "string" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + } + }, + "licenseJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "givenName": { + "maxLength": 100, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "jurisdictionUploadedCompactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "jurisdictionUploadedLicenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "type": { + "type": "string", + "enum": [ + "provider" + ] + }, + "suffix": { + "maxLength": 100, + "type": "string" + }, + "currentHomeJurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "licenses": { + "type": "array", + "items": { + "required": [ + "compact", + "compactEligibility", + "dateOfExpiration", + "dateOfIssuance", + "dateOfUpdate", + "familyName", + "givenName", + "homeAddressCity", + "homeAddressPostalCode", + "homeAddressState", + "homeAddressStreet1", + "jurisdiction", + "jurisdictionUploadedCompactEligibility", + "jurisdictionUploadedLicenseStatus", + "licenseNumber", + "licenseStatus", + "licenseType", + "providerId", + "type" + ], + "type": "object", + "properties": { + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "investigations": { + "type": "array", + "items": { + "required": [ + "compact", + "creationDate", + "dateOfUpdate", + "investigationId", + "jurisdiction", + "licenseType", + "providerId", + "submittingUser", + "type" + ], + "type": "object", + "properties": { + "licenseType": { + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "submittingUser": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "investigation" + ] + }, + "creationDate": { + "type": "string", + "format": "date-time" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "type": { + "type": "string", + "enum": [ + "license-home" + ] + }, + "suffix": { + "maxLength": 100, + "type": "string" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "licenseType": { + "type": "string" + }, + "emailAddress": { + "type": "string", + "format": "email" + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "familyName": { + "maxLength": 100, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "investigationStatus": { + "type": "string", + "enum": [ + "underInvestigation" + ] + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "compactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "givenName": { + "maxLength": 100, + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "type": "string", + "enum": [ + "eligible", + "ineligible" + ] + }, + "jurisdictionUploadedLicenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "licenseStatusName": { + "maxLength": 100, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "type": "string" + }, + "adverseActions": { + "type": "array", + "items": { + "required": [ + "actionAgainst", + "adverseActionId", + "compact", + "creationDate", + "dateOfUpdate", + "effectiveStartDate", + "encumbranceType", + "jurisdiction", + "licenseType", + "licenseTypeAbbreviation", + "providerId", + "submittingUser", + "type" + ], + "type": "object", + "properties": { + "clinicalPrivilegeActionCategories": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "fraud", + "consumer harm", + "other" + ] + } + }, + "compact": { + "type": "string", + "enum": [ + "socw" + ] + }, + "jurisdiction": { + "type": "string", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ] + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "adverseAction" + ] + }, + "creationDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "actionAgainst": { + "type": "string", + "enum": [ + "license", + "privilege" + ] + }, + "licenseType": { + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "submittingUser": { + "type": "string" + }, + "effectiveStartDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string", + "format": "date" + }, + "encumbranceType": { + "type": "string" + }, + "liftingUser": { + "type": "string" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + }, + "dateOfExpiration": { + "type": "string", + "format": "date" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "licenseStatus": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + }, + "familyName": { + "maxLength": 100, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "type": "string" + }, + "birthMonthDay": { + "pattern": "^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}", + "type": "string" + }, + "compactConnectRegisteredEmailAddress": { + "type": "string", + "format": "email" + }, + "dateOfUpdate": { + "type": "string", + "format": "date-time" + } + } + } + } + } + }, + "TestSSearch7KTSu0MG6ez": { + "required": [ + "query" + ], + "type": "object", + "properties": { + "search_after": { + "type": "array", + "description": "Sort values from the last hit of the previous page for cursor-based pagination" + }, + "size": { + "maximum": 100, + "minimum": 1, + "type": "integer", + "description": "Number of results to return" + }, + "query": { + "type": "object", + "description": "The OpenSearch query body" + }, + "from": { + "minimum": 0, + "type": "integer", + "description": "Starting document offset for pagination" + }, + "sort": { + "type": "array", + "description": "Sort order for results (required for search_after pagination)", + "items": { + "type": "object" + } + } + }, + "additionalProperties": false + } + }, + "securitySchemes": { + "TestBackendCosmetologyTestSearchAPIStackSearchApiStaffUsersPoolAuthorizer02E7F51B": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "cognito_user_pools" + } + } + }, + "x-amazon-apigateway-security-policy": "TLS_1_0" +} diff --git a/backend/social-work-app/docs/search-internal/api-specification/swagger.html b/backend/social-work-app/docs/search-internal/api-specification/swagger.html new file mode 100644 index 0000000000..44396776c4 --- /dev/null +++ b/backend/social-work-app/docs/search-internal/api-specification/swagger.html @@ -0,0 +1,22 @@ + + + + + + + SwaggerUI + + + +
+ + + + diff --git a/backend/social-work-app/docs/search-internal/postman/postman-collection.json b/backend/social-work-app/docs/search-internal/postman/postman-collection.json new file mode 100644 index 0000000000..9975537a22 --- /dev/null +++ b/backend/social-work-app/docs/search-internal/postman/postman-collection.json @@ -0,0 +1,544 @@ +{ + "auth": { + "bearer": [ + { + "key": "token", + "type": "string", + "value": "{{accessToken}}" + } + ], + "type": "bearer" + }, + "info": { + "_postman_id": "109c916e-8844-4045-a72f-985c936f7236", + "description": { + "content": "", + "type": "text/plain" + }, + "name": "CompactConnect API", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "item": [ + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "client-credentials-grant", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "client_credentials" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "client_secret", + "value": "{{clientSecret}}" + }, + { + "key": "scope", + "value": "{{jurisdiction}}/{{compact}}.write" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=client_credentials&client_id={{clientId}}&client_secret={{clientSecret}}&scope={{jurisdiction}}/{{compact}}.write" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});", + "", + "pm.test('Identity token returned', () => {", + " var id_token = pm.response.json().id_token;", + " pm.expect(id_token).not.to.be.empty;", + " pm.environment.set(\"idToken\", id_token);", + " console.log('id token: ' + id_token);", + "});", + "", + "pm.test('Refresh token returned', () => {", + " var refresh_token = pm.response.json().refresh_token;", + " pm.expect(refresh_token).not.to.be.empty;", + " pm.environment.set(\"refreshToken\", refresh_token);", + " console.log('refresh token: ' + refresh_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-grant-token", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "authorization_code" + }, + { + "key": "code", + "value": "f23723c3-1d21-40e1-89ec-64807d2d658d" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "scope", + "value": "openid" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=authorization_code&code=f23723c3-1d21-40e1-89ec-64807d2d658d&client_id={{clientId}}&scope=openid&redirect_uri=http://localhost:3018/auth/callback" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-authorize", + "protocolProfileBehavior": { + "followRedirects": false + }, + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "disabled": true, + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "disabled": true, + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "authorize" + ], + "query": [ + { + "key": "response_type", + "value": "code" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + }, + { + "key": "scope", + "value": "openid" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&scope=openid" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.expect(access_token).not.to.be.empty;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "authorization-code-login", + "protocolProfileBehavior": { + "followRedirects": false + }, + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "authorize" + ], + "query": [ + { + "key": "scope", + "value": "openid" + }, + { + "key": "response_type", + "value": "code" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "redirect_uri", + "value": "http://localhost:3018/auth/callback" + }, + { + "key": "identity_provider", + "value": "COGNITO" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/authorize?scope=openid&response_type=code&client_id={{clientId}}&redirect_uri=http://localhost:3018/auth/callback&identity_provider=COGNITO" + } + }, + "response": [] + }, + { + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test('Access token returned', () => {", + " var access_token = pm.response.json().access_token;", + " pm.environment.set(\"accessToken\", access_token);", + " console.log('Access token: ' + access_token);", + "});", + "", + "pm.test('Identity token returned', () => {", + " var id_token = pm.response.json().id_token;", + " pm.environment.set(\"idToken\", id_token);", + " console.log('id token: ' + id_token);", + "});" + ], + "packages": {}, + "type": "text/javascript" + } + } + ], + "name": "refresh-token", + "request": { + "auth": { + "type": "noauth" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "url": { + "host": [ + "{{staffUserPoolUrl}}" + ], + "path": [ + "oauth2", + "token" + ], + "query": [ + { + "key": "grant_type", + "value": "refresh_token" + }, + { + "key": "client_id", + "value": "{{clientId}}" + }, + { + "key": "refresh_token", + "value": "{{refreshToken}}" + } + ], + "raw": "{{staffUserPoolUrl}}/oauth2/token?grant_type=refresh_token&client_id={{clientId}}&refresh_token={{refreshToken}}" + } + }, + "response": [] + } + ], + "name": "Staff-Auth" + }, + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "description": "", + "item": [ + { + "event": [], + "id": "9a224db1-0801-49dc-bc77-c227ec4ae5d6", + "name": "/v1/compacts/:compact/providers/search", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"query\": {},\n \"search_after\": \"\",\n \"size\": \"\",\n \"from\": \"\"\n}" + }, + "description": {}, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "POST", + "name": "/v1/compacts/:compact/providers/search", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + "search" + ], + "query": [], + "variable": [ + { + "description": { + "content": "(Required) ", + "type": "text/plain" + }, + "disabled": false, + "key": "compact", + "type": "any", + "value": "" + } + ] + } + }, + "response": [ + { + "_postman_previewlanguage": "json", + "body": "{\n \"providers\": [\n {\n \"birthMonthDay\": \"13-27\",\n \"compact\": \"cosm\",\n \"compactEligibility\": \"ineligible\",\n \"dateOfExpiration\": \"\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"jurisdictionUploadedLicenseStatus\": \"inactive\",\n \"licenseJurisdiction\": \"oh\",\n \"licenseStatus\": \"inactive\",\n \"providerId\": \"18bebbfa-e8ca-4869-98c4-ffff83095cc5\",\n \"type\": \"provider\",\n \"privileges\": [\n {\n \"administratorSetStatus\": \"active\",\n \"compact\": \"cosm\",\n \"dateOfExpiration\": \"1056-09-29\",\n \"jurisdiction\": \"wa\",\n \"licenseJurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"providerId\": \"3d05da9b-c1f5-4592-97f8-3ad8c1146499\",\n \"status\": \"inactive\",\n \"type\": \"privilege\",\n \"investigationStatus\": \"underInvestigation\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"wa\",\n \"licenseType\": \"\",\n \"providerId\": \"5b6e2c5d-32fb-45d7-9470-0f8b5f305a85\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ky\",\n \"licenseType\": \"\",\n \"providerId\": \"db5e051a-9bde-4174-af6a-414a45ee680d\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"compactTransactionId\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1994-10-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1317-12-01\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"va\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"e22dc06c-faec-4961-835f-3dd6af6fee8b\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1740-02-30\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2731-11-23\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1810-01-30\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"b0b0b419-b41d-4927-9587-8f0592b35abf\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"consumer harm\"\n ],\n \"effectiveLiftDate\": \"1536-11-16\",\n \"liftingUser\": \"\"\n }\n ]\n },\n {\n \"administratorSetStatus\": \"active\",\n \"compact\": \"cosm\",\n \"dateOfExpiration\": \"2833-10-15\",\n \"jurisdiction\": \"tn\",\n \"licenseJurisdiction\": \"co\",\n \"licenseType\": \"\",\n \"providerId\": \"4e64bdce-d338-42db-814f-ab46e53e11a0\",\n \"status\": \"inactive\",\n \"type\": \"privilege\",\n \"investigationStatus\": \"underInvestigation\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"providerId\": \"85b4ee0a-e8a0-4e16-b5b7-86ecdd686b38\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ky\",\n \"licenseType\": \"\",\n \"providerId\": \"cffb7fe0-9bec-4b44-ac08-56091fce440a\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"compactTransactionId\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"license\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2290-01-31\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1055-10-30\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"ks\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"04dcfddc-ff19-40c7-8380-036f041c076a\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"other\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1323-12-30\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1122-05-09\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2752-08-31\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"az\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"d479b788-5309-4b30-9716-89ada3964fd3\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"2600-12-16\",\n \"liftingUser\": \"\"\n }\n ]\n }\n ],\n \"suffix\": \"\",\n \"currentHomeJurisdiction\": \"co\",\n \"licenses\": [\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"ineligible\",\n \"dateOfExpiration\": \"1164-01-21\",\n \"dateOfIssuance\": \"2025-10-04\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdiction\": \"va\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"\",\n \"providerId\": \"ee1923a7-7899-4847-be5d-7b5793f64864\",\n \"type\": \"license-home\",\n \"homeAddressStreet2\": \"\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"wa\",\n \"licenseType\": \"\",\n \"providerId\": \"b3387c78-bb50-4ba6-9706-dc83e7ba9156\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ks\",\n \"licenseType\": \"\",\n \"providerId\": \"3c3bc16f-ad2d-4494-aa23-54b5e3c2146f\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"dateOfRenewal\": \"2273-06-31\",\n \"investigationStatus\": \"underInvestigation\",\n \"phoneNumber\": \"+80837827816\",\n \"licenseStatusName\": \"\",\n \"middleName\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1327-12-21\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1923-11-01\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"1fa7c5ef-ceb2-452c-ba75-f01d08b36332\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1979-10-31\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1566-03-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1493-10-31\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"ks\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"d9793eb4-5bfe-4c79-940b-3955a567f473\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"fraud\"\n ],\n \"effectiveLiftDate\": \"2231-10-17\",\n \"liftingUser\": \"\"\n }\n ]\n },\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"eligible\",\n \"dateOfExpiration\": \"2933-03-12\",\n \"dateOfIssuance\": \"1248-12-31\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdiction\": \"ks\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"jurisdictionUploadedLicenseStatus\": \"inactive\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"\",\n \"providerId\": \"eefd95b6-9870-4dcc-a988-ffa28375aeea\",\n \"type\": \"license-home\",\n \"homeAddressStreet2\": \"\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ky\",\n \"licenseType\": \"\",\n \"providerId\": \"6ad74b74-6abd-4fc7-b1cd-0290e10fc256\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"oh\",\n \"licenseType\": \"\",\n \"providerId\": \"bbf87366-cc78-450d-9d95-3079573becb1\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"dateOfRenewal\": \"1790-01-10\",\n \"investigationStatus\": \"underInvestigation\",\n \"phoneNumber\": \"+05715949804\",\n \"licenseStatusName\": \"\",\n \"middleName\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1762-12-16\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2890-11-30\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"al\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"03a1be0c-1e4b-46c4-bb8d-df0ac470eeb0\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"consumer harm\"\n ],\n \"effectiveLiftDate\": \"1315-03-31\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1410-09-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1082-07-31\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"az\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"e1001d2f-644d-4413-af38-6aefbb9228bf\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"2107-10-19\",\n \"liftingUser\": \"\"\n }\n ]\n }\n ],\n \"middleName\": \"\",\n \"compactConnectRegisteredEmailAddress\": \"\"\n },\n {\n \"birthMonthDay\": \"13-15\",\n \"compact\": \"cosm\",\n \"compactEligibility\": \"eligible\",\n \"dateOfExpiration\": \"\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"licenseJurisdiction\": \"al\",\n \"licenseStatus\": \"inactive\",\n \"providerId\": \"02c384d9-c7c1-4bf1-8402-ad53128eb4b7\",\n \"type\": \"provider\",\n \"privileges\": [\n {\n \"administratorSetStatus\": \"inactive\",\n \"compact\": \"cosm\",\n \"dateOfExpiration\": \"1863-12-30\",\n \"jurisdiction\": \"wa\",\n \"licenseJurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"providerId\": \"39e9cb58-baf9-4897-b8bb-cc2956c2696d\",\n \"status\": \"inactive\",\n \"type\": \"privilege\",\n \"investigationStatus\": \"underInvestigation\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"oh\",\n \"licenseType\": \"\",\n \"providerId\": \"3eccb946-d179-4d02-a028-ceb13b435e1c\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"wa\",\n \"licenseType\": \"\",\n \"providerId\": \"642dd70a-e6a1-462d-ac58-8a668bf05e2c\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"compactTransactionId\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"license\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2935-08-17\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2301-11-31\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"ded9577f-5f97-4204-91ab-0cbfed4e2fcc\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"fraud\"\n ],\n \"effectiveLiftDate\": \"2088-06-17\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"license\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1620-12-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2898-04-20\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"wa\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"b216230a-42ce-46b0-9ffb-dfc0a3ba10e6\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1344-12-07\",\n \"liftingUser\": \"\"\n }\n ]\n },\n {\n \"administratorSetStatus\": \"active\",\n \"compact\": \"cosm\",\n \"dateOfExpiration\": \"1799-11-12\",\n \"jurisdiction\": \"md\",\n \"licenseJurisdiction\": \"wa\",\n \"licenseType\": \"\",\n \"providerId\": \"e6efe045-71ac-4c62-a85c-c383af048d75\",\n \"status\": \"active\",\n \"type\": \"privilege\",\n \"investigationStatus\": \"underInvestigation\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"providerId\": \"8c28f233-25a3-445a-afce-8581dfcb0735\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"providerId\": \"07fd36be-4b66-4fdb-88a7-8ad918918b5f\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"compactTransactionId\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1818-10-08\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1090-12-08\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"az\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"d83df1f7-0b05-4c21-9f4b-4db4da8f3bec\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1059-10-30\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"license\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2981-06-31\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1499-09-05\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"va\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"3793e448-9c55-4c58-ba1d-a492b5900424\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1432-03-20\",\n \"liftingUser\": \"\"\n }\n ]\n }\n ],\n \"suffix\": \"\",\n \"currentHomeJurisdiction\": \"wa\",\n \"licenses\": [\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"ineligible\",\n \"dateOfExpiration\": \"1990-01-31\",\n \"dateOfIssuance\": \"2640-06-31\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdiction\": \"tn\",\n \"jurisdictionUploadedCompactEligibility\": \"ineligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"\",\n \"providerId\": \"e72a1eb4-db31-4daf-84a5-752965792065\",\n \"type\": \"license-home\",\n \"homeAddressStreet2\": \"\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"ks\",\n \"licenseType\": \"\",\n \"providerId\": \"653ae65b-5170-4ef7-b736-0851ff1b5479\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"az\",\n \"licenseType\": \"\",\n \"providerId\": \"798235ed-c128-4919-b444-f7b762408915\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"dateOfRenewal\": \"1967-02-31\",\n \"investigationStatus\": \"underInvestigation\",\n \"phoneNumber\": \"+5188469175\",\n \"licenseStatusName\": \"\",\n \"middleName\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"license\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1073-08-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2221-05-16\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"fd50f39a-bfaa-4d5f-a864-d443a901b86c\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"fraud\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"2482-12-06\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"2747-12-24\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1309-03-27\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"5650de1a-c7b7-4045-ab5a-b6e9555dd0f0\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"other\"\n ],\n \"effectiveLiftDate\": \"1738-03-31\",\n \"liftingUser\": \"\"\n }\n ]\n },\n {\n \"compact\": \"cosm\",\n \"compactEligibility\": \"eligible\",\n \"dateOfExpiration\": \"2929-10-11\",\n \"dateOfIssuance\": \"1314-05-07\",\n \"dateOfUpdate\": \"\",\n \"familyName\": \"\",\n \"givenName\": \"\",\n \"homeAddressCity\": \"\",\n \"homeAddressPostalCode\": \"\",\n \"homeAddressState\": \"\",\n \"homeAddressStreet1\": \"\",\n \"jurisdiction\": \"co\",\n \"jurisdictionUploadedCompactEligibility\": \"eligible\",\n \"jurisdictionUploadedLicenseStatus\": \"active\",\n \"licenseNumber\": \"\",\n \"licenseStatus\": \"active\",\n \"licenseType\": \"\",\n \"providerId\": \"beb6d699-b726-4189-9bfd-9c7f37021a65\",\n \"type\": \"license-home\",\n \"homeAddressStreet2\": \"\",\n \"investigations\": [\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"tn\",\n \"licenseType\": \"\",\n \"providerId\": \"842380bf-3c7d-4310-b691-2552f329bc7b\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n },\n {\n \"compact\": \"cosm\",\n \"creationDate\": \"\",\n \"dateOfUpdate\": \"\",\n \"investigationId\": \"\",\n \"jurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"providerId\": \"dae71a10-e40d-4a8b-a266-7a01c1f4b542\",\n \"submittingUser\": \"\",\n \"type\": \"investigation\"\n }\n ],\n \"suffix\": \"\",\n \"emailAddress\": \"\",\n \"dateOfRenewal\": \"1033-12-05\",\n \"investigationStatus\": \"underInvestigation\",\n \"phoneNumber\": \"+0657366487662\",\n \"licenseStatusName\": \"\",\n \"middleName\": \"\",\n \"adverseActions\": [\n {\n \"actionAgainst\": \"privilege\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1480-11-30\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"2751-08-08\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"md\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"2bedc81f-1a98-4753-b168-393787bad1e2\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"fraud\"\n ],\n \"effectiveLiftDate\": \"1058-01-01\",\n \"liftingUser\": \"\"\n },\n {\n \"actionAgainst\": \"license\",\n \"adverseActionId\": \"\",\n \"compact\": \"cosm\",\n \"creationDate\": \"1999-06-11\",\n \"dateOfUpdate\": \"\",\n \"effectiveStartDate\": \"1422-12-10\",\n \"encumbranceType\": \"\",\n \"jurisdiction\": \"oh\",\n \"licenseType\": \"\",\n \"licenseTypeAbbreviation\": \"\",\n \"providerId\": \"a470dc2b-802b-4d7b-b858-4a286213d4b5\",\n \"submittingUser\": \"\",\n \"type\": \"adverseAction\",\n \"clinicalPrivilegeActionCategories\": [\n \"consumer harm\",\n \"consumer harm\"\n ],\n \"effectiveLiftDate\": \"1438-12-03\",\n \"liftingUser\": \"\"\n }\n ]\n }\n ],\n \"middleName\": \"\",\n \"compactConnectRegisteredEmailAddress\": \"\"\n }\n ],\n \"total\": {\n \"value\": \"\",\n \"relation\": \"eq\"\n },\n \"lastSort\": \"\"\n}", + "code": 200, + "cookie": [], + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "id": "383ab925-b14e-427a-8d2b-3ccbfcd51e17", + "name": "200 response", + "originalRequest": { + "body": { + "mode": "raw", + "options": { + "raw": { + "headerFamily": "json", + "language": "json" + } + }, + "raw": "{\n \"query\": {},\n \"search_after\": \"\",\n \"size\": \"\",\n \"from\": \"\"\n}" + }, + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Accept", + "value": "application/json" + }, + { + "description": { + "content": "Added as a part of security scheme: apikey", + "type": "text/plain" + }, + "key": "Authorization", + "value": "" + } + ], + "method": "POST", + "url": { + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "compacts", + ":compact", + "providers", + "search" + ], + "query": [], + "variable": [] + } + }, + "status": "OK" + } + ] + } + ], + "name": "search" + } + ], + "name": "providers" + } + ], + "name": "{compact}" + } + ], + "name": "compacts" + } + ], + "name": "v1" + } + ] +} diff --git a/backend/social-work-app/lambdas/nodejs/.gitignore b/backend/social-work-app/lambdas/nodejs/.gitignore new file mode 100644 index 0000000000..22f3e812b8 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/.gitignore @@ -0,0 +1,6 @@ +lib/**/*.mjs +lib/**/*.js +lib/**/*.js.map +bin/**/*.mjs +bin/**/*.js +bin/**/*.js.map diff --git a/backend/social-work-app/lambdas/nodejs/README.md b/backend/social-work-app/lambdas/nodejs/README.md new file mode 100644 index 0000000000..5b6ca82300 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/README.md @@ -0,0 +1,39 @@ +# NodeJS Lambdas + +This folder contains all lambda runtimes that are written with NodeJS/TypeScript. Because these lambdas are each bundled through CDK with ESBuild, we can pull common code and tests together, leaving only the entrypoints in a lambda-specific folder, leaving ESBuild to pull in only needed lib code. + + +## Prerequisites +* **[Node](https://github.com/creationix/nvm#installation) `24.X`** +* **[Yarn](https://yarnpkg.com/en/) `1.22.22`** + * `npm install --global yarn@1.22.22` + +_[back to top](#ingest-event-reporter-lambda)_ + +--- +## Installing dependencies +- `yarn install` + +## Bundling the runtime +- `yarn build` + +_[back to top](#ingest-event-reporter-lambda)_ + +--- +## Local development +- **Linting** + - `yarn run lint` + - Lints all code in all the Lambda function +- **Running an individual Lambda** + - The easiest way to execute the Lambda is to run the tests ([see below](#tests)) + - Commenting out certain tests to limit the execution scope & repetition is trivial + +_[back to top](#ingest-event-reporter-lambda)_ + +--- +## Testing +This project uses `jest` and `aws-sdk-client-mock` for approachable unit testing. The code in this folder can be tested by running: +- `yarn install` +- `yarn test` + +or by using the utility scripts located at `backend/bin`. diff --git a/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.js b/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.js new file mode 100644 index 0000000000..032227bcbf --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.js @@ -0,0 +1,9 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Lambda } from './lambda'; +const lambda = new Lambda({ + dynamoDBClient: new DynamoDBClient(), + sesClient: new SESv2Client(), +}); +export const customMessage = lambda.handler.bind(lambda); +//# sourceMappingURL=handler.js.map \ No newline at end of file diff --git a/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.js.map b/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.js.map new file mode 100644 index 0000000000..0928d7e048 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.js.map @@ -0,0 +1 @@ +{"version":3,"file":"handler.js","sourceRoot":"","sources":["handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC;IACtB,cAAc,EAAE,IAAI,cAAc,EAAE;IACpC,SAAS,EAAE,IAAI,WAAW,EAAE;CAC/B,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC"} \ No newline at end of file diff --git a/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.ts b/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.ts new file mode 100644 index 0000000000..c0d225cc85 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/cognito-emails/handler.ts @@ -0,0 +1,10 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Lambda } from './lambda'; + +const lambda = new Lambda({ + dynamoDBClient: new DynamoDBClient(), + sesClient: new SESv2Client(), +}); + +export const customMessage = lambda.handler.bind(lambda); diff --git a/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.js b/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.js new file mode 100644 index 0000000000..46dd9a29f3 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.js @@ -0,0 +1,61 @@ +import { __decorate } from "tslib"; +import { Logger } from '@aws-lambda-powertools/logger'; +import { EnvironmentVariablesService } from '../lib/environment-variables-service'; +import { CompactConfigurationClient } from '../lib/compact-configuration-client'; +import { JurisdictionClient } from '../lib/jurisdiction-client'; +import { CognitoEmailService } from '../lib/email'; +const environmentVariables = new EnvironmentVariablesService(); +const logger = new Logger({ logLevel: environmentVariables.getLogLevel() }); +export class Lambda { + emailService; + constructor(props) { + const compactConfigurationClient = new CompactConfigurationClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + const jurisdictionClient = new JurisdictionClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + this.emailService = new CognitoEmailService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, + jurisdictionClient: jurisdictionClient + }); + } + /** + * Lambda handler for Cognito custom messages + * https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-message.html + * + * This handler generates custom email templates for various Cognito triggers + * like sign up verification, password reset, etc. + * + * @param event - Cognito custom message event + * @param context - Lambda context + * @returns Modified event with custom email message and subject + */ + async handler(event, _context) { + logger.info('Processing Cognito custom message event', { + triggerSource: event.triggerSource, + userPoolId: event.userPoolId, + userName: event.userName + }); + try { + const { subject, htmlContent } = this.emailService.generateCognitoMessage(event.triggerSource, event.request.codeParameter, event.request.usernameParameter); + // Update the event response with our custom message + event.response.emailSubject = subject; + event.response.emailMessage = htmlContent; + logger.info('Successfully generated custom message'); + return event; + } + catch (error) { + logger.error('Error generating custom message', { error: error }); + throw error; + } + } +} +__decorate([ + logger.injectLambdaContext({ resetKeys: true }) +], Lambda.prototype, "handler", null); +//# sourceMappingURL=lambda.js.map \ No newline at end of file diff --git a/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.js.map b/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.js.map new file mode 100644 index 0000000000..630d467ed4 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.js.map @@ -0,0 +1 @@ +{"version":3,"file":"lambda.js","sourceRoot":"","sources":["lambda.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,+BAA+B,CAAC;AAKvD,OAAO,EAAE,2BAA2B,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChE,OAAO,EAAE,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAEnD,MAAM,oBAAoB,GAAG,IAAI,2BAA2B,EAAE,CAAC;AAC/D,MAAM,MAAM,GAAG,IAAI,MAAM,CAAC,EAAE,QAAQ,EAAE,oBAAoB,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;AAkC5E,MAAM,OAAO,MAAM;IACE,YAAY,CAAsB;IAEnD,YAAY,KAAuB;QAC/B,MAAM,0BAA0B,GAAG,IAAI,0BAA0B,CAAC;YAC9D,MAAM,EAAE,MAAM;YACd,cAAc,EAAE,KAAK,CAAC,cAAc;SACvC,CAAC,CAAC;QAEH,MAAM,kBAAkB,GAAG,IAAI,kBAAkB,CAAC;YAC9C,MAAM,EAAE,MAAM;YACd,cAAc,EAAE,KAAK,CAAC,cAAc;SACvC,CAAC,CAAC;QAEH,IAAI,CAAC,YAAY,GAAG,IAAI,mBAAmB,CAAC;YACxC,MAAM,EAAE,MAAM;YACd,SAAS,EAAE,KAAK,CAAC,SAAS;YAC1B,0BAA0B,EAAE,0BAA0B;YACtD,kBAAkB,EAAE,kBAAkB;SACzC,CAAC,CAAC;IACP,CAAC;IAED;;;;;;;;;;OAUG;IAEU,AAAN,KAAK,CAAC,OAAO,CAAC,KAAgC,EAAE,QAAiB;QACpE,MAAM,CAAC,IAAI,CAAC,yCAAyC,EAAE;YACnD,aAAa,EAAE,KAAK,CAAC,aAAa;YAClC,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,QAAQ,EAAE,KAAK,CAAC,QAAQ;SAC3B,CAAC,CAAC;QAEH,IAAI,CAAC;YACD,MAAM,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC,YAAY,CAAC,sBAAsB,CACrE,KAAK,CAAC,aAAa,EACnB,KAAK,CAAC,OAAO,CAAC,aAAa,EAC3B,KAAK,CAAC,OAAO,CAAC,iBAAiB,CAClC,CAAC;YAEF,oDAAoD;YACpD,KAAK,CAAC,QAAQ,CAAC,YAAY,GAAG,OAAO,CAAC;YACtC,KAAK,CAAC,QAAQ,CAAC,YAAY,GAAG,WAAW,CAAC;YAE1C,MAAM,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAC;YACrD,OAAO,KAAK,CAAC;QACjB,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACb,MAAM,CAAC,KAAK,CAAC,iCAAiC,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;YAClE,MAAM,KAAK,CAAC;QAChB,CAAC;IACL,CAAC;CACJ;AAzBgB;IADZ,MAAM,CAAC,mBAAmB,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;qCAyB/C"} \ No newline at end of file diff --git a/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.ts b/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.ts new file mode 100644 index 0000000000..dda0a42946 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/cognito-emails/lambda.ts @@ -0,0 +1,106 @@ +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { Context } from 'aws-lambda'; + +import { EnvironmentVariablesService } from '../lib/environment-variables-service'; +import { CompactConfigurationClient } from '../lib/compact-configuration-client'; +import { JurisdictionClient } from '../lib/jurisdiction-client'; +import { CognitoEmailService } from '../lib/email'; + +const environmentVariables = new EnvironmentVariablesService(); +const logger = new Logger({ logLevel: environmentVariables.getLogLevel() }); + +interface CognitoCustomMessageEvent { + version: string; + triggerSource: string; + region: string; + userPoolId: string; + userName: string; + callerContext: { + awsSdkVersion: string; + clientId: string; + }; + request: { + userAttributes: { + [key: string]: string; + }; + codeParameter?: string; + usernameParameter?: string; + clientMetadata: { + [key: string]: string; + }; + }; + response: { + smsMessage?: string; + emailMessage?: string; + emailSubject?: string; + }; +} + +interface LambdaProperties { + dynamoDBClient: DynamoDBClient; + sesClient: SESv2Client; +} + +export class Lambda implements LambdaInterface { + private readonly emailService: CognitoEmailService; + + constructor(props: LambdaProperties) { + const compactConfigurationClient = new CompactConfigurationClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + + const jurisdictionClient = new JurisdictionClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + + this.emailService = new CognitoEmailService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, + jurisdictionClient: jurisdictionClient + }); + } + + /** + * Lambda handler for Cognito custom messages + * https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-message.html + * + * This handler generates custom email templates for various Cognito triggers + * like sign up verification, password reset, etc. + * + * @param event - Cognito custom message event + * @param context - Lambda context + * @returns Modified event with custom email message and subject + */ + @logger.injectLambdaContext({ resetKeys: true }) + public async handler(event: CognitoCustomMessageEvent, _context: Context): Promise { + logger.info('Processing Cognito custom message event', { + triggerSource: event.triggerSource, + userPoolId: event.userPoolId, + userName: event.userName + }); + + try { + const { subject, htmlContent } = this.emailService.generateCognitoMessage( + event.triggerSource, + event.request.codeParameter, + event.request.usernameParameter + ); + + // Update the event response with our custom message + event.response.emailSubject = subject; + event.response.emailMessage = htmlContent; + + logger.info('Successfully generated custom message'); + return event; + } catch (error) { + logger.error('Error generating custom message', { error: error }); + throw error; + } + } +} diff --git a/backend/social-work-app/lambdas/nodejs/email-notification-service/.gitignore b/backend/social-work-app/lambdas/nodejs/email-notification-service/.gitignore new file mode 100644 index 0000000000..a9db021f9f --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/email-notification-service/.gitignore @@ -0,0 +1,3 @@ +*.mjs +*.js +*.js.map diff --git a/backend/social-work-app/lambdas/nodejs/email-notification-service/README.md b/backend/social-work-app/lambdas/nodejs/email-notification-service/README.md new file mode 100644 index 0000000000..b426e66aa8 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/email-notification-service/README.md @@ -0,0 +1,31 @@ +# Email Notification Service Lambda + +This package contains code required to generate system emails for users in CompactConnect, as well as +compacts/jurisdictions staff. It leverages [EmailBuilderJS](https://github.com/usewaypoint/email-builder-js) to dynamically render email HTML content that should +be rendered consistently across email clients. + +The lambda is intended to be invoked directly, rather than through an API endpoint. It uses the following payload structure: +``` +{ + template: string; // Name of the template to use (e.g. licenseEncumbranceProviderNotification) + recipientType: // must be one of the following + | 'COMPACT_OPERATIONS_TEAM' // compactOperationsTeamEmails + | 'COMPACT_ADVERSE_ACTIONS' // compactAdverseActionsNotificationEmails + | 'JURISDICTION_OPERATIONS_TEAM' // jurisdictionOperationsTeamEmails + | 'JURISDICTION_ADVERSE_ACTIONS' // jurisdictionAdverseActionsNotificationEmails + | 'SPECIFIC'; // specificEmails provided in payload + compact: string; // Compact identifier + jurisdiction?: string; // Optional jurisdiction identifier, must be specified if sending to a Jurisdiction based email list + specificEmails?: string[]; // Optional list of specific email addresses to send the message to + templateVariables: { // Template variables for hydration + [key: string]: any; + }; +} +``` + +This schema provides flexibility for adding new notification template types. Each template type corresponds to a +particular method in the `EmailServiceTemplater` class. The `recipientType` field is used to determine which email addresses to +send the email to, and correspond to email lists defined in the compact/jurisdiction configurations used by the system. +The `specificEmails` field is used to send the email to a specific list of email addresses, and is only used when +`recipientType` is set to `SPECIFIC`. The `templateVariables` field is used to hydrate the email template with dynamic content. +if needed. diff --git a/backend/social-work-app/lambdas/nodejs/email-notification-service/handler.ts b/backend/social-work-app/lambdas/nodejs/email-notification-service/handler.ts new file mode 100644 index 0000000000..fab4a8046d --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/email-notification-service/handler.ts @@ -0,0 +1,11 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Lambda } from './lambda'; + + +const lambda = new Lambda({ + dynamoDBClient: new DynamoDBClient(), + sesClient: new SESv2Client(), +}); + +export const sendEmail = lambda.handler.bind(lambda); diff --git a/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts b/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts new file mode 100644 index 0000000000..a10b985715 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts @@ -0,0 +1,361 @@ +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Context } from 'aws-lambda'; + +import { EnvironmentVariablesService } from '../lib/environment-variables-service'; +import { CompactConfigurationClient } from '../lib/compact-configuration-client'; +import { JurisdictionClient } from '../lib/jurisdiction-client'; +import { EmailNotificationService, EncumbranceNotificationService, InvestigationNotificationService } from '../lib/email'; +import { EmailNotificationEvent, EmailNotificationResponse } from '../lib/models/email-notification-service-event'; + +const environmentVariables = new EnvironmentVariablesService(); +const logger = new Logger({ logLevel: environmentVariables.getLogLevel() }); + +interface LambdaProperties { + dynamoDBClient: DynamoDBClient; + sesClient: SESv2Client; +} + +export class Lambda implements LambdaInterface { + private readonly emailService: EmailNotificationService; + private readonly encumbranceService: EncumbranceNotificationService; + private readonly investigationService: InvestigationNotificationService; + + constructor(props: LambdaProperties) { + const compactConfigurationClient = new CompactConfigurationClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + + const jurisdictionClient = new JurisdictionClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + + this.encumbranceService = new EncumbranceNotificationService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, + jurisdictionClient: jurisdictionClient + }); + + this.emailService = new EmailNotificationService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, + jurisdictionClient: jurisdictionClient + }); + + this.investigationService = new InvestigationNotificationService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, + jurisdictionClient: jurisdictionClient + }); + } + + /** + * Lambda handler for email notification service + * + * This handler sends an email notification based on the requested email template. + * See README in this directory for information on using this service. + * + * @param event - Email notification event + * @param context - Lambda context + * @returns Email notification response + */ + @logger.injectLambdaContext({ resetKeys: true }) + public async handler(event: EmailNotificationEvent, _context: Context): Promise { + logger.info('Processing event', { template: event.template, compact: event.compact, jurisdiction: event.jurisdiction }); + + // Check if FROM_ADDRESS is configured + if (environmentVariables.getFromAddress() === 'NONE') { + logger.info('No from address configured for environment'); + return { + message: 'No from address configured for environment, unable to send email' + }; + } + + switch (event.template) { + case 'licenseEncumbranceProviderNotification': + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.encumberedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveStartDate) { + throw new Error('Missing required template variables for licenseEncumbranceProviderNotification template.'); + } + await this.encumbranceService.sendLicenseEncumbranceProviderNotificationEmail( + event.compact, + event.specificEmails || [], + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.encumberedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveStartDate + ); + break; + case 'licenseEncumbranceStateNotification': + if (!event.jurisdiction) { + throw new Error('Missing required jurisdiction field for licenseEncumbranceStateNotification template.'); + } + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.providerId + || !event.templateVariables.encumberedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveStartDate) { + throw new Error('Missing required template variables for licenseEncumbranceStateNotification template.'); + } + await this.encumbranceService.sendLicenseEncumbranceStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.encumberedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveStartDate + ); + break; + case 'licenseEncumbranceLiftingProviderNotification': + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.liftedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveLiftDate) { + throw new Error('Missing required template variables for licenseEncumbranceLiftingProviderNotification template.'); + } + await this.encumbranceService.sendLicenseEncumbranceLiftingProviderNotificationEmail( + event.compact, + event.specificEmails || [], + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.liftedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveLiftDate + ); + break; + case 'licenseEncumbranceLiftingStateNotification': + if (!event.jurisdiction) { + throw new Error('Missing required jurisdiction field for licenseEncumbranceLiftingStateNotification template.'); + } + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.providerId + || !event.templateVariables.liftedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveLiftDate) { + throw new Error('Missing required template variables for licenseEncumbranceLiftingStateNotification template.'); + } + await this.encumbranceService.sendLicenseEncumbranceLiftingStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.liftedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveLiftDate + ); + break; + case 'privilegeEncumbranceProviderNotification': + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.encumberedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveStartDate) { + throw new Error('Missing required template variables for privilegeEncumbranceProviderNotification template.'); + } + await this.encumbranceService.sendPrivilegeEncumbranceProviderNotificationEmail( + event.compact, + event.specificEmails || [], + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.encumberedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveStartDate + ); + break; + case 'privilegeEncumbranceStateNotification': + if (!event.jurisdiction) { + throw new Error('Missing required jurisdiction field for privilegeEncumbranceStateNotification template.'); + } + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.providerId + || !event.templateVariables.encumberedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveStartDate) { + throw new Error('Missing required template variables for privilegeEncumbranceStateNotification template.'); + } + await this.encumbranceService.sendPrivilegeEncumbranceStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.encumberedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveStartDate + ); + break; + case 'privilegeEncumbranceLiftingProviderNotification': + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.liftedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveLiftDate) { + throw new Error('Missing required template variables for privilegeEncumbranceLiftingProviderNotification template.'); + } + await this.encumbranceService.sendPrivilegeEncumbranceLiftingProviderNotificationEmail( + event.compact, + event.specificEmails || [], + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.liftedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveLiftDate + ); + break; + case 'privilegeEncumbranceLiftingStateNotification': + if (!event.jurisdiction) { + throw new Error('Missing required jurisdiction field for privilegeEncumbranceLiftingStateNotification template.'); + } + if (!event.templateVariables.providerFirstName + || !event.templateVariables.providerLastName + || !event.templateVariables.providerId + || !event.templateVariables.liftedJurisdiction + || !event.templateVariables.licenseType + || !event.templateVariables.effectiveLiftDate) { + throw new Error('Missing required template variables for privilegeEncumbranceLiftingStateNotification template.'); + } + await this.encumbranceService.sendPrivilegeEncumbranceLiftingStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.liftedJurisdiction, + event.templateVariables.licenseType, + event.templateVariables.effectiveLiftDate + ); + break; + case 'licenseInvestigationStateNotification': + if (!event.jurisdiction) { + throw new Error('No jurisdiction provided for license investigation state notification email'); + } + if (!event.templateVariables?.providerFirstName + || !event.templateVariables?.providerLastName + || !event.templateVariables?.providerId + || !event.templateVariables?.investigationJurisdiction + || !event.templateVariables?.licenseType) { + throw new Error('Missing required template variables for licenseInvestigationStateNotification template.'); + } + await this.investigationService.sendLicenseInvestigationStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.investigationJurisdiction, + event.templateVariables.licenseType + ); + break; + case 'licenseInvestigationClosedStateNotification': + if (!event.jurisdiction) { + throw new Error('No jurisdiction provided for license investigation closed state notification email'); + } + if (!event.templateVariables?.providerFirstName + || !event.templateVariables?.providerLastName + || !event.templateVariables?.providerId + || !event.templateVariables?.investigationJurisdiction + || !event.templateVariables?.licenseType) { + throw new Error('Missing required template variables for licenseInvestigationClosedStateNotification template.'); + } + await this.investigationService.sendLicenseInvestigationClosedStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.investigationJurisdiction, + event.templateVariables.licenseType + ); + break; + case 'privilegeInvestigationStateNotification': + if (!event.jurisdiction) { + throw new Error('No jurisdiction provided for privilege investigation state notification email'); + } + if (!event.templateVariables?.providerFirstName + || !event.templateVariables?.providerLastName + || !event.templateVariables?.providerId + || !event.templateVariables?.investigationJurisdiction + || !event.templateVariables?.licenseType) { + throw new Error('Missing required template variables for privilegeInvestigationStateNotification template.'); + } + await this.investigationService.sendPrivilegeInvestigationStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.investigationJurisdiction, + event.templateVariables.licenseType + ); + break; + case 'privilegeInvestigationClosedStateNotification': + if (!event.jurisdiction) { + throw new Error('No jurisdiction provided for privilege investigation closed state notification email'); + } + if (!event.templateVariables?.providerFirstName + || !event.templateVariables?.providerLastName + || !event.templateVariables?.providerId + || !event.templateVariables?.investigationJurisdiction + || !event.templateVariables?.licenseType) { + throw new Error('Missing required template variables for privilegeInvestigationClosedStateNotification template.'); + } + await this.investigationService.sendPrivilegeInvestigationClosedStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.investigationJurisdiction, + event.templateVariables.licenseType + ); + break; + case 'homeJurisdictionChangeNotification': + if (!event.jurisdiction) { + throw new Error('Missing required jurisdiction field for home jurisdiction change notification template.'); + } + if (!event.templateVariables?.providerFirstName + || !event.templateVariables?.providerLastName + || !event.templateVariables?.providerId + || !event.templateVariables?.previousJurisdiction + || !event.templateVariables?.newJurisdiction) { + throw new Error('Missing required template variables for home jurisdiction change notification template.'); + } + await this.emailService.sendHomeJurisdictionChangeStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.previousJurisdiction, + event.templateVariables.newJurisdiction + ); + break; + default: + logger.info('Unsupported email template provided', { template: event.template }); + throw new Error(`Unsupported email template: ${event.template}`); + } + + logger.info('Completing handler'); + return { + message: 'Email message sent' + }; + } +} diff --git a/backend/social-work-app/lambdas/nodejs/eslint.config.mjs b/backend/social-work-app/lambdas/nodejs/eslint.config.mjs new file mode 100644 index 0000000000..0961638b72 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/eslint.config.mjs @@ -0,0 +1,90 @@ +import typescriptParser from '@typescript-eslint/parser'; +import typescriptPlugin from '@typescript-eslint/eslint-plugin'; + +const OFF = 0; +const WARNING = 1; +const ERROR = 2; + +export default [ + { + ignores: ['cdk.out/**/*'], + }, + { + files: ['**/*.ts', '**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parser: typescriptParser, + globals: { + es2022: true, + node: true, + }, + }, + plugins: { + '@typescript-eslint': typescriptPlugin, + }, + rules: { + indent: [ERROR, 4], + 'linebreak-style': [ERROR, 'unix'], + quotes: [ERROR, 'single', { allowTemplateLiterals: true }], + semi: [ERROR, 'always'], + 'max-len': [ERROR, { + code: 120, + ignoreComments: true, + ignoreUrls: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + ignoreStrings: true, + }], + 'no-multi-spaces': [ERROR, { ignoreEOLComments: true }], + 'arrow-parens': [ERROR, 'always'], + 'comma-dangle': [ERROR, { + functions: 'never', + imports: 'never', + exports: 'ignore', + arrays: 'ignore', + objects: 'ignore', + }], + 'array-bracket-spacing': OFF, + 'object-curly-spacing': [ERROR, 'always', { + objectsInObjects: false, + arraysInObjects: false, + }], + 'no-param-reassign': [ERROR, { props: false }], + 'max-classes-per-file': [WARNING, 8], + 'lines-between-class-members': [ERROR, 'always', { exceptAfterSingleLine: true }], + 'implicit-arrow-linebreak': OFF, + 'class-methods-use-this': OFF, + '@typescript-eslint/no-explicit-any': OFF, + 'no-unused-vars': OFF, // Disabled in favor of @typescript-eslint/no-unused-vars + '@typescript-eslint/no-unused-vars': [ERROR, { + vars: 'all', + args: 'after-used', + ignoreRestSiblings: true, + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }], + 'padding-line-between-statements': [ + ERROR, + { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, + { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, + ], + } + }, + { + files: ['**/*.js'], + rules: { + '@typescript-eslint/no-var-requires': OFF, + '@typescript-eslint/camelcase': OFF, + }, + }, + { + files: ['**/__tests__/*.{j,t}s?(x)'], + rules: { + 'no-unused-expressions': OFF, + 'quote-props': OFF, + 'import/no-extraneous-dependencies': OFF, + '@typescript-eslint/no-var-requires': OFF, + }, + } +]; diff --git a/backend/social-work-app/lambdas/nodejs/generated-email-templates/.gitignore b/backend/social-work-app/lambdas/nodejs/generated-email-templates/.gitignore new file mode 100644 index 0000000000..96576723a7 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/generated-email-templates/.gitignore @@ -0,0 +1,8 @@ +# +# Generated email templates directory — ignore all build/test artifacts. +# Allow only files that document or control ignoring. +# + +* +!.gitignore +!README.md diff --git a/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/.gitignore b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/.gitignore new file mode 100644 index 0000000000..a9db021f9f --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/.gitignore @@ -0,0 +1,3 @@ +*.mjs +*.js +*.js.map diff --git a/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/README.md b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/README.md new file mode 100644 index 0000000000..a32e0d6873 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/README.md @@ -0,0 +1,5 @@ +# Ingest Event Reporter Lambda + +This package contains code required to generate emailed reports for compacts/jurisdictions. It leverages +[EmailBuilderJS](https://github.com/usewaypoint/email-builder-js) to dynamically render email +HTML content that should be rendered consistently across email clients. diff --git a/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/handler.ts b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/handler.ts new file mode 100644 index 0000000000..38ffccb94d --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/handler.ts @@ -0,0 +1,11 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Lambda } from './lambda'; + + +const lambda = new Lambda({ + dynamoDBClient: new DynamoDBClient(), + sesClient: new SESv2Client(), +}); + +export const collectEvents = lambda.handler.bind(lambda); diff --git a/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/lambda.ts b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/lambda.ts new file mode 100644 index 0000000000..75cbfd616b --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/ingest-event-reporter/lambda.ts @@ -0,0 +1,180 @@ +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Context } from 'aws-lambda'; + +import { EnvironmentVariablesService } from '../lib/environment-variables-service'; +import { CompactConfigurationClient } from '../lib/compact-configuration-client'; +import { JurisdictionClient } from '../lib/jurisdiction-client'; +import { IEventBridgeEvent } from '../lib/models/event-bridge-event-detail'; +import { IngestEventEmailService } from '../lib/email'; +import { EventClient } from '../lib/event-client'; +import { Compact, IJurisdiction } from '../lib/models'; + +const environmentVariables = new EnvironmentVariablesService(); +const logger = new Logger({ logLevel: environmentVariables.getLogLevel() }); + +interface LambdaProperties { + dynamoDBClient: DynamoDBClient; + sesClient: SESv2Client; +} + +/* + * Basic Lambda class to integrate the primary lambda entrypoint logic, logging, and error handling + */ +export class Lambda implements LambdaInterface { + private readonly jurisdictionClient: JurisdictionClient; + private readonly compactConfigurationClient: CompactConfigurationClient; + private readonly eventClient: EventClient; + private readonly emailService: IngestEventEmailService; + + constructor(props: LambdaProperties) { + this.jurisdictionClient = new JurisdictionClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + + this.compactConfigurationClient = new CompactConfigurationClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + + this.eventClient = new EventClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + this.emailService = new IngestEventEmailService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: this.compactConfigurationClient, + jurisdictionClient: this.jurisdictionClient + }); + } + + @logger.injectLambdaContext({ resetKeys: true }) + public async handler(event: IEventBridgeEvent, context: Context): Promise { + logger.info('Processing event', { event: event }); + logger.debug('Context wait for event loop', { wait_for_empty_event_loop: context.callbackWaitsForEmptyEventLoop }); + + // Loop over each compact the system knows about + for (const compact of environmentVariables.getCompacts()) { + let compactConfig; + + try { + compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + + logger.warn('Compact configuration not found, skipping compact', { compact, error: errorMessage }); + continue; + } + + const jurisdictionConfigs = await this.jurisdictionClient.getJurisdictionConfigurations(compact); + + // Loop over each jurisdiction that we have contacts configured for + for (const jurisdictionConfig of jurisdictionConfigs) { + switch (event.eventType) { + case 'weekly': + await this.runWeeklyReports(compactConfig, jurisdictionConfig); + break; + default: + // frequent case (every 15 minutes) + await this.runFrequentReports(compactConfig, jurisdictionConfig); + break; + }; + + } + } + logger.info('Completing handler'); + } + + public async runFrequentReports(compactConfig: Compact, jurisdictionConfig: IJurisdiction) { + const [ startTimeStamp, endTimeStamp ] = this.eventClient.getLast15MinuteTimestamps(); + + const ingestEvents = await this.eventClient.getEvents( + compactConfig.compactAbbr, jurisdictionConfig.postalAbbreviation, startTimeStamp, endTimeStamp + ); + + // If there were any issues, send a report email summarizing them + if (ingestEvents.ingestFailures.length || ingestEvents.validationErrors.length) { + const messageId = await this.emailService.sendReportEmail( + ingestEvents, + compactConfig.compactName, + jurisdictionConfig.jurisdictionName, + jurisdictionConfig.jurisdictionOperationsTeamEmails + ); + + logger.info( + 'Sent event summary email', + { + compact: compactConfig.compactAbbr, + jurisdiction: jurisdictionConfig.postalAbbreviation, + startTimeStamp, + endTimeStamp, + message_id: messageId + } + ); + } else { + logger.info( + 'No events in window', + { + compact: compactConfig.compactAbbr, + jurisdiction: jurisdictionConfig.postalAbbreviation, + startTimeStamp, + endTimeStamp + } + ); + } + } + + public async runWeeklyReports(compactConfig: Compact, jurisdictionConfig: IJurisdiction) { + const [ weekStartStamp, weekEndStamp ] = this.eventClient.getLastWeekTimestamps(); + const weeklyIngestEvents = await this.eventClient.getEvents( + compactConfig.compactAbbr, + jurisdictionConfig.postalAbbreviation, + weekStartStamp, + weekEndStamp + ); + + // verify that the jurisdiction uploaded licenses within the last week without any errors + if (!weeklyIngestEvents.ingestFailures.length + && !weeklyIngestEvents.validationErrors.length + && weeklyIngestEvents.ingestSuccesses.length + ) { + const messageId = await this.emailService.sendAllsWellEmail( + compactConfig.compactName, + jurisdictionConfig.jurisdictionName, + jurisdictionConfig.jurisdictionOperationsTeamEmails + ); + + logger.info( + 'Sent alls well email', + { + compact: compactConfig.compactName, + jurisdiction: jurisdictionConfig.postalAbbreviation, + message_id: messageId + } + ); + } + else if(!weeklyIngestEvents.ingestSuccesses.length) { + const messageId = await this.emailService.sendNoLicenseUpdatesEmail( + compactConfig.compactName, + jurisdictionConfig.jurisdictionName, + [ + ...jurisdictionConfig.jurisdictionOperationsTeamEmails, + ...compactConfig.compactOperationsTeamEmails + ] + ); + + logger.warn( + 'No licenses uploaded within the last week', + { + compact: compactConfig.compactName, + jurisdiction: jurisdictionConfig.postalAbbreviation, + message_id: messageId + } + ); + } + } +} diff --git a/backend/social-work-app/lambdas/nodejs/jest.config.mjs b/backend/social-work-app/lambdas/nodejs/jest.config.mjs new file mode 100644 index 0000000000..50e5fd068d --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/jest.config.mjs @@ -0,0 +1,33 @@ +export default { + preset: 'ts-jest', + transform: {}, // Disables all transformations to commonJS + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.[jt]s?(x)'], + moduleFileExtensions: ['ts', 'js'], + verbose: true, + setupFilesAfterEnv: ['/tests/jest.setup.ts'], + testPathIgnorePatterns: [ + '/node_modules/', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/node_modules/**', + '!**/tests/**', + '!**/coverage/**', + '!**/*.d.ts', + '!**/__mocks__/**', + '!**/__fixtures__/**', + '!**/__snapshots__/**', + '!**/*.config.*', + '!**/*.test.*', + '!**/*.spec.*' + ], + coverageThreshold: { + global: { + branches: 90, + functions: 90, + lines: 90, + statements: 90, + } + } +}; diff --git a/backend/social-work-app/lambdas/nodejs/lib/compact-configuration-client.ts b/backend/social-work-app/lambdas/nodejs/lib/compact-configuration-client.ts new file mode 100644 index 0000000000..586e2b083c --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/compact-configuration-client.ts @@ -0,0 +1,42 @@ +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { EnvironmentVariablesService } from './environment-variables-service'; +import { Compact } from './models/compact'; + +const environmentVariables = new EnvironmentVariablesService(); + +interface CompactConfigurationClientProperties { + logger: Logger; + dynamoDBClient: DynamoDBClient; +} + +export class CompactConfigurationClient { + private readonly logger: Logger; + private readonly dynamoDBClient: DynamoDBClient; + + constructor(props: CompactConfigurationClientProperties) { + this.logger = props.logger; + this.dynamoDBClient = props.dynamoDBClient; + } + + public async getCompactConfiguration(compact: string): Promise { + this.logger.info('Getting compact configuration', { compact }); + + const command = new GetItemCommand({ + TableName: environmentVariables.getCompactConfigurationTableName(), + Key: { + 'pk': { S: `${compact}#CONFIGURATION` }, + 'sk': { S: `${compact}#CONFIGURATION` } + } + }); + + const response = await this.dynamoDBClient.send(command); + + if (!response.Item) { + throw new Error(`No configuration found for compact: ${compact}`); + } + + return unmarshall(response.Item) as Compact; + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/base-email-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/base-email-service.ts new file mode 100644 index 0000000000..7f7689658c --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/base-email-service.ts @@ -0,0 +1,536 @@ +import * as crypto from 'crypto'; +import * as nodemailer from 'nodemailer'; +import type SESTransport from 'nodemailer/lib/ses-transport'; + +import { Logger } from '@aws-lambda-powertools/logger'; +import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; +import { TReaderDocument, renderToStaticMarkup } from '@csg-org/email-builder'; +import { CompactConfigurationClient } from '../compact-configuration-client'; +import { JurisdictionClient } from '../jurisdiction-client'; +import { EnvironmentVariablesService } from '../environment-variables-service'; +import { EnvironmentBannerService } from './environment-banner-service'; + +const environmentVariableService = new EnvironmentVariablesService(); + +interface EmailServiceProperties { + logger: Logger; + sesClient: SESv2Client; + compactConfigurationClient: CompactConfigurationClient; + jurisdictionClient: JurisdictionClient; +} + +interface StyledBlockOptions { + title: string; + content: string; + blockType: 'warning'; +} + +/** + * Base class for email services that provides common email functionality + */ +export abstract class BaseEmailService { + protected readonly logger: Logger; + protected readonly sesClient: SESv2Client; + protected readonly compactConfigurationClient: CompactConfigurationClient; + protected readonly jurisdictionClient: JurisdictionClient; + private readonly environmentBannerService = new EnvironmentBannerService(); + protected readonly shouldShowEnvironmentBannerIfNonProdEnvironment: boolean = true; + private readonly emailTemplate: TReaderDocument = { + 'root': { + 'type': 'EmailLayout', + 'data': { + 'backdropColor': '#E9EFF9', + 'canvasColor': '#FFFFFF', + 'textColor': '#09122B', + 'fontFamily': 'MODERN_SANS', + 'childrenIds': [] + } + } + }; + + protected getNewEmailTemplate() { + // Make a deep copy of the template so we can modify it without affecting the original + return JSON.parse(JSON.stringify(this.emailTemplate)); + } + + public constructor(props: EmailServiceProperties) { + this.logger = props.logger; + this.sesClient = props.sesClient; + this.compactConfigurationClient = props.compactConfigurationClient; + this.jurisdictionClient = props.jurisdictionClient; + } + + protected static getEmailImageBaseUrl() { + return `${environmentVariableService.getUiBasePathUrl()}/img/email`; + } + + protected async sendEmail({ htmlContent, subject, recipients, errorMessage }: + {htmlContent: string, subject: string, recipients: string[], errorMessage: string}) { + try { + // Send the email + const command = new SendEmailCommand({ + Destination: { + ToAddresses: recipients, + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: htmlContent + } + }, + Subject: { + Charset: 'UTF-8', + Data: subject + } + } + }, + // We're required by the IAM policy to use this display name + FromEmailAddress: `CompactConnect <${environmentVariableService.getFromAddress()}>`, + }); + + return (await this.sesClient.send(command)).MessageId; + } catch (error) { + this.logger.error(errorMessage, { error: error }); + throw error; + } + } + + protected async sendEmailWithAttachments({ + htmlContent, + subject, + recipients, + errorMessage, + attachments + }: { + htmlContent: string; + subject: string; + recipients: string[]; + errorMessage: string; + attachments: { filename: string; content: string | Buffer; contentType: string; }[]; + }) { + try { + // Create a nodemailer transport that generates raw MIME messages + const sesTransportOptions: SESTransport.Options = { + SES: { sesClient: this.sesClient, SendEmailCommand }, + }; + const transport = nodemailer.createTransport(sesTransportOptions); + + // Create the email message + const message = { + from: `CompactConnect <${environmentVariableService.getFromAddress()}>`, + to: recipients, + subject: subject, + html: htmlContent, + attachments: attachments.map((attachment) => ({ + filename: attachment.filename, + content: attachment.content, + contentType: attachment.contentType + })) + }; + + // Send the email + const result = await transport.sendMail(message); + + return result.messageId; + } catch (error) { + this.logger.error(errorMessage, { error: error }); + throw error; + } + } + + protected insertDiv(report: TReaderDocument) { + // We use a constant block ID to reuse the same block + const blockDivId = 'block-div'; + + report[blockDivId] = { + 'type': 'Divider', + 'data': { + 'style': { + 'padding': { + 'top': 12, + 'bottom': 16, + 'right': 0, + 'left': 0 + } + }, + 'props': { + 'lineColor': '#CCCCCC' + } + } + }; + + report['root']['data']['childrenIds'].push(blockDivId); + } + + protected insertHeaderWithJurisdiction(report: TReaderDocument, + compactName: string, + jurisdiction: string, + heading: string) { + + // Insert environment banner first (above logo) + if (this.shouldShowEnvironmentBannerIfNonProdEnvironment) { + this.environmentBannerService.insertEnvironmentBannerIfNonProd(report); + } + + const blockLogoId = 'block-logo'; + const blockHeaderId = 'block-header'; + const blockJurisdictionId = 'block-jurisdiction'; + + report[blockLogoId] = { + 'type': 'Image', + 'data': { + 'style': { + 'padding': { + 'top': 40, + 'bottom': 8, + 'right': 68, + 'left': 68 + }, + 'backgroundColor': null, + 'textAlign': 'center' + }, + 'props': { + 'width': null, + 'height': 100, + 'url': `${BaseEmailService.getEmailImageBaseUrl()}/compact-connect-logo-final.png`, + 'alt': '', + 'linkHref': null, + 'contentAlignment': 'middle' + } + } + }; + report[blockHeaderId] = { + 'type': 'Heading', + 'data': { + 'props': { + 'text': heading, + 'level': 'h1' + }, + 'style': { + 'textAlign': 'center', + 'padding': { + 'top': 28, + 'bottom': 12, + 'right': 24, + 'left': 24 + } + } + } + }; + report[blockJurisdictionId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': '#09122B', + 'fontSize': 18, + 'fontWeight': 'bold', + 'textAlign': 'center', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'markdown': true, + 'text': `${compactName} / ${jurisdiction}` + } + } + }; + + report['root']['data']['childrenIds'].push(blockLogoId); + report['root']['data']['childrenIds'].push(blockHeaderId); + report['root']['data']['childrenIds'].push(blockJurisdictionId); + } + + protected insertSubHeading(report: TReaderDocument, subHeading: string) { + const blockId = `block-${crypto.randomUUID()}`; + + report[blockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'fontSize': 18, + 'fontWeight': 'normal', + 'textAlign': 'center', + 'padding': { + 'top': 0, + 'bottom': 52, + 'right': 40, + 'left': 40 + } + }, + 'props': { + 'text': subHeading + } + } + }; + + report['root']['data']['childrenIds'].push(blockId); + } + + protected insertHeader(report: TReaderDocument, heading: string) { + // Insert environment banner first (above logo) + if (this.shouldShowEnvironmentBannerIfNonProdEnvironment) { + this.environmentBannerService.insertEnvironmentBannerIfNonProd(report); + } + + const blockLogoId = 'block-logo'; + const blockHeaderId = 'block-header'; + + report[blockLogoId] = { + 'type': 'Image', + 'data': { + 'style': { + 'padding': { + 'top': 40, + 'bottom': 8, + 'right': 68, + 'left': 68 + }, + 'backgroundColor': null, + 'textAlign': 'center' + }, + 'props': { + 'width': null, + 'height': 100, + 'url': `${BaseEmailService.getEmailImageBaseUrl()}/compact-connect-logo-final.png`, + 'alt': '', + 'linkHref': null, + 'contentAlignment': 'middle' + } + } + }; + + report[blockHeaderId] = { + 'type': 'Heading', + 'data': { + 'props': { + 'text': heading, + 'level': 'h1' + }, + 'style': { + 'textAlign': 'center', + 'color': '#09122B', + 'padding': { + 'top': 28, + 'bottom': 12, + 'right': 24, + 'left': 24 + } + } + } + }; + + report['root']['data']['childrenIds'].push(blockLogoId); + report['root']['data']['childrenIds'].push(blockHeaderId); + } + + protected insertBody( + report: TReaderDocument, + bodyText: string, + textAlign: 'center' | 'right' | 'left' | null = null, + markdown: boolean = false, + styleOverrides: Record = {} + ) { + const blockId = `block-${crypto.randomUUID()}`; + + report[blockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'fontSize': 16, + 'fontWeight': 'normal', + 'color': '#09122B', + 'padding': { + 'top': 24, + 'bottom': 24, + 'right': 40, + 'left': 40 + }, + ...styleOverrides + }, + 'props': { + 'text': bodyText, + 'markdown': markdown + } + } + }; + + if (textAlign && report[blockId]['data']['style']) { + report[blockId]['data']['style']['textAlign'] = textAlign; + } + + report['root']['data']['childrenIds'].push(blockId); + } + + protected insertFooter(report: TReaderDocument) { + const blockId = `block-footer`; + + report[blockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': '#ffffff', + 'backgroundColor': '#2459A9', + 'fontSize': 13, + 'fontFamily': 'MODERN_SANS', + 'fontWeight': 'normal', + 'textAlign': 'center', + 'padding': { + 'top': 40, + 'bottom': 40, + 'right': 68, + 'left': 68 + } + }, + 'props': { + 'text': `© ${new Date().getFullYear()} CompactConnect` + } + } + }; + + report['root']['data']['childrenIds'].push(blockId); + + // Insert test email warning footer last (below copyright) + if (this.shouldShowEnvironmentBannerIfNonProdEnvironment) { + this.environmentBannerService.insertTestEmailFooterIfNonProd(report); + } + } + + /** + * Inserts a styled block with title and content + * Currently supports 'warning' style with orange/yellow color scheme + */ + protected insertStyledBlock(report: TReaderDocument, options: StyledBlockOptions) { + const outerContainerId = `block-${crypto.randomUUID()}`; + const innerContainerId = `block-${crypto.randomUUID()}`; + const titleBlockId = `block-${crypto.randomUUID()}`; + const contentBlockId = `block-${crypto.randomUUID()}`; + + // Define styling based on block type + const getBlockStyles = (blockType: 'warning') => { + switch (blockType) { + case 'warning': + return { + backgroundColor: '#FFF9EE', + borderColor: '#FDBD4B', + textColor: '#9F2D00', + titleFontSize: 20, + contentFontSize: 16 + }; + default: + throw new Error(`Unsupported block type: ${blockType}`); + } + }; + + const styles = getBlockStyles(options.blockType); + + // Create the title text block + report[titleBlockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': styles.textColor, + 'fontSize': styles.titleFontSize, + 'fontWeight': 'bold', + 'textAlign': 'left', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 0, + 'left': 0 + } + }, + 'props': { + 'text': options.title + } + } + }; + + // Create the content text block + report[contentBlockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': styles.textColor, + 'fontSize': styles.contentFontSize, + 'fontWeight': 'normal', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 0, + 'left': 0 + } + }, + 'props': { + 'markdown': true, + 'text': options.content + } + } + }; + + // Create the inner container (styled) + report[innerContainerId] = { + 'type': 'Container', + 'data': { + 'style': { + 'backgroundColor': styles.backgroundColor, + 'borderColor': styles.borderColor, + 'borderRadius': 12, + 'padding': { + 'top': 16, + 'bottom': 16, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'childrenIds': [ + titleBlockId, + contentBlockId + ] + } + } + }; + + // Create the outer container (white background) + report[outerContainerId] = { + 'type': 'Container', + 'data': { + 'style': { + 'backgroundColor': '#FFFFFF', + 'borderColor': '#FFFFFF', + 'borderRadius': 12, + 'padding': { + 'top': 16, + 'bottom': 16, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'childrenIds': [ + innerContainerId + ] + } + } + }; + + // Add the outer container to the root + report['root']['data']['childrenIds'].push(outerContainerId); + } + + /** + * Renders a template to HTML + * This method should be used by all subclasses instead of calling renderToStaticMarkup directly + * @param template - The TReaderDocument template to render + * @returns The rendered HTML string + */ + protected renderTemplate(template: TReaderDocument): string { + return renderToStaticMarkup(template, { rootBlockId: 'root' }); + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/cognito-email-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/cognito-email-service.ts new file mode 100644 index 0000000000..0b543550b0 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/cognito-email-service.ts @@ -0,0 +1,262 @@ +import { BaseEmailService } from './base-email-service'; +import { EnvironmentVariablesService } from '../environment-variables-service'; + +const environmentVariableService = new EnvironmentVariablesService(); + +/** + * Email service for handling Cognito custom messages + */ +export class CognitoEmailService extends BaseEmailService { + // We don't want to show the environment banner for Cognito emails + // so that users know the welcome email is valid and not a test email + protected readonly shouldShowEnvironmentBannerIfNonProdEnvironment: boolean = false; + /** + * Generates the appropriate email template based on Cognito trigger source + * @param triggerSource - The Cognito trigger source + * @param codeParameter - The code parameter to include in the message (if applicable) + * @param usernameParameter - The username parameter to include in the message (if applicable) + * @returns An object containing the subject and HTML content for the email + */ + public generateCognitoMessage( + triggerSource: string, + codeParameter?: string, + usernameParameter?: string + ): { subject: string; htmlContent: string } { + /* + * We don't actually anticipate using all of these triggers, but we're including them just to avoid breaking + * any Cognito flows. + */ + switch (triggerSource) { + // Sent as an invite, after a user is created by our API. + // Based on the triggerSource value, we know which parameters are defined, so we will + // assert that they are defined for each particular template. + case 'CustomMessage_AdminCreateUser': + return this.generateAdminCreateUserTemplate(codeParameter!, usernameParameter!); + // Sent if a user requests to reset their password + case 'CustomMessage_ForgotPassword': + return this.generateForgotPasswordTemplate(codeParameter!); + // Sent if a user changes their email attribute + case 'CustomMessage_UpdateUserAttribute': + return this.generateUpdateUserAttributeTemplate(codeParameter!); + // These next ones, we don't anticipate actually using + case 'CustomMessage_VerifyUserAttribute': + return this.generateVerifyUserAttributeTemplate(codeParameter!); + case 'CustomMessage_ResendCode': + return this.generateResendCodeTemplate(codeParameter!); + case 'CustomMessage_SignUp': + return this.generateSignUpTemplate(codeParameter!); + default: + throw new Error(`Unsupported Cognito trigger source: ${triggerSource}`); + } + } + + /** + * Generates a template for when an admin creates a new user + */ + private generateAdminCreateUserTemplate( + codeParameter: string, + usernameParameter: string + ): { subject: string; htmlContent: string } { + const subject = 'Welcome to CompactConnect'; + // Make a deep copy of the template so we can modify it without affecting the original + const template = this.getNewEmailTemplate(); + + this.insertHeader(template, subject); + + const loginUrl = `${environmentVariableService.getUiBasePathUrl()}/Dashboard?bypass=login-staff-socw`; + const loginText = `Please immediately [sign in](${loginUrl}) and change your password when prompted.`; + + this.insertBody(template, + `Your temporary password is: \n**${codeParameter}**\n\nYour username is: \n**${usernameParameter}**\n`, + 'center', + true, + { + 'padding': { + 'top': '8', + 'bottom': '8', + 'right': '40', + 'left': '40', + } + } + ); + + this.insertBody(template, + loginText, + 'center', + true, + { + 'size': 14, + 'color': '#727272', + 'padding': { + 'top': '8', + 'bottom': '16', + 'right': '40', + 'left': '40', + } + } + ); + + // Add MFA instructions block + this.insertStyledBlock(template, { + blockType: 'warning', + title: 'Multi-Factor Authentication (MFA) Required', + content: `For security, you'll need to set up Multi-Factor Authentication (MFA) after your first login. MFA adds an extra layer of security by requiring a second form of verification. + +**What is an Authenticator App?** +An authenticator app generates time-based codes that change every 30 seconds. You'll use these codes along with your password to sign in. + +**Recommended Authenticator Apps:** +- **Google Authenticator** - [Android](https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2) | [iOS](https://apps.apple.com/app/google-authenticator/id388497605) +- **Microsoft Authenticator** - [Android](https://play.google.com/store/apps/details?id=com.azure.authenticator) | [iOS](https://apps.apple.com/app/microsoft-authenticator/id983156458) +- **Authy** - [Android](https://play.google.com/store/apps/details?id=com.authy.authy) | [iOS](https://apps.apple.com/app/authy/id494168017) + +**Setup steps:** +1. Download one of the authenticator apps above +2. Click the sign-in link above and sign in to CompactConnect with your temporary credentials +3. Reset your password when prompted +4. In your authenticator app, add an account and scan the QR code presented on the MFA setup page +5. Enter the 6-digit code from your authenticator into the CompactConnect MFA setup page (codes refresh every 30 seconds) +6. Click "Sign in" to complete setup + +> **Note:** There is a *3-minute time limit* from when you log in to when you must have your MFA set-up, before the login screen will kick you out and make you start over. If you are prompted to set up MFA again, *delete your previous account entry from your MFA app before you try again*. + +**How to sign in with MFA (after setup):** +1. Click the sign-in link above or go to ${loginUrl} +2. Enter your username and password as usual +3. When prompted, open your authenticator app +4. Find the authenticator app entry you created during MFA setup +5. Enter the current 6-digit code from your authenticator app (codes refresh every 30 seconds) +6. Click "Sign in"` + }); + + this.insertFooter(template); + + return { + subject, + htmlContent: this.renderTemplate(template) + }; + } + + /** + * Generates a template for password reset requests + */ + private generateForgotPasswordTemplate(codeParameter: string): { subject: string; htmlContent: string } { + const subject = 'Reset your password'; + // Make a deep copy of the template so we can modify it without affecting the original + const template = this.getNewEmailTemplate(); + + this.insertHeader(template, subject); + this.insertBody(template, + 'You requested to reset your password. Enter the following code to proceed:', + 'center', + true + ); + this.insertSubHeading(template, codeParameter); + this.insertBody(template, + `**Important:** If you have lost access to your multi-factor authentication (MFA), you will need to recover your account by visiting the following link instead: ${environmentVariableService.getUiBasePathUrl()}/mfarecoverystart`, + 'center', + true + ); + this.insertFooter(template); + + return { + subject, + htmlContent: this.renderTemplate(template) + }; + } + + /** + * Generates a template for email attribute updates + */ + private generateUpdateUserAttributeTemplate(codeParameter: string): { subject: string; htmlContent: string } { + const subject = 'Verify your email'; + // Make a deep copy of the template so we can modify it without affecting the original + const template = this.getNewEmailTemplate(); + + this.insertHeader(template, subject); + this.insertBody(template, + 'Please verify your new email address by entering the following code:', + 'center', + true + ); + this.insertSubHeading(template, codeParameter); + this.insertFooter(template); + + return { + subject, + htmlContent: this.renderTemplate(template) + }; + } + + /** + * Generates a template for user attribute verification + * Note: Not anticipated to be used in normal flows + */ + private generateVerifyUserAttributeTemplate(codeParameter: string): { subject: string; htmlContent: string } { + const subject = 'Verify your email'; + // Make a deep copy of the template so we can modify it without affecting the original + const template = this.getNewEmailTemplate(); + + this.insertHeader(template, subject); + this.insertBody(template, + 'Please verify your email address by entering the following code:', + 'center', + true + ); + this.insertSubHeading(template, codeParameter); + this.insertFooter(template); + + return { + subject, + htmlContent: this.renderTemplate(template) + }; + } + + /** + * Generates a template for code resend requests + * Note: Not anticipated to be used in normal flows + */ + private generateResendCodeTemplate(codeParameter: string): { subject: string; htmlContent: string } { + const subject = 'New verification code for CompactConnect'; + // Make a deep copy of the template so we can modify it without affecting the original + const template = this.getNewEmailTemplate(); + + this.insertHeader(template, subject); + this.insertBody(template, + 'Your new verification code is:', + 'center', + true + ); + this.insertSubHeading(template, codeParameter); + this.insertFooter(template); + + return { + subject, + htmlContent: this.renderTemplate(template) + }; + } + + /** + * Generates a template for new user sign-ups + * Note: Not anticipated to be used in normal flows + */ + private generateSignUpTemplate(codeParameter: string): { subject: string; htmlContent: string } { + const subject = 'Welcome to CompactConnect'; + // Make a deep copy of the template so we can modify it without affecting the original + const template = this.getNewEmailTemplate(); + + this.insertHeader(template, subject); + this.insertBody(template, + 'Please verify your email address by entering the following code:', + 'center', + true + ); + this.insertSubHeading(template, codeParameter); + this.insertFooter(template); + + return { + subject, + htmlContent: this.renderTemplate(template) + }; + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts new file mode 100644 index 0000000000..ea1543e8e6 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts @@ -0,0 +1,80 @@ +import { BaseEmailService } from './base-email-service'; +import { EnvironmentVariablesService } from '../environment-variables-service'; +import { RecipientType } from '../models/email-notification-service-event'; + +const environmentVariableService = new EnvironmentVariablesService(); + +/** + * Email service for handling email notifications + */ +export class EmailNotificationService extends BaseEmailService { + + private async getJurisdictionRecipients( + compact: string, + jurisdiction: string, + recipientType: RecipientType + ): Promise { + + const jurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration(compact, jurisdiction); + + switch (recipientType) { + case 'JURISDICTION_OPERATIONS_TEAM': + return jurisdictionConfig.jurisdictionOperationsTeamEmails; + default: + throw new Error(`Unsupported recipient type for compact configuration: ${recipientType}`); + } + } + + /** + * Sends a notification email to a jurisdiction operations team when a practitioner's home state license changes + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param previousJurisdiction - The previous home jurisdiction + * @param newJurisdiction - The new home jurisdiction + */ + public async sendHomeJurisdictionChangeStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + previousJurisdiction: string, + newJurisdiction: string + ): Promise { + this.logger.info('Sending home jurisdiction change state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const recipients = await this.getJurisdictionRecipients( + compact, + jurisdiction, + 'JURISDICTION_OPERATIONS_TEAM' + ); + + if (recipients.length === 0) { + throw new Error(`No recipients found for jurisdiction ${jurisdiction} in compact ${compact}`); + } + + const formattedPreviousJurisdiction = previousJurisdiction.toUpperCase(); + const formattedNewJurisdiction = newJurisdiction.toUpperCase(); + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `Practitioner Home State Change - ${compactConfig.compactName}`; + const bodyText = `This is to notify you that ${providerFirstName} ${providerLastName} has changed their home state from ${formattedPreviousJurisdiction} to ${formattedNewJurisdiction}.\n\n` + + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send home jurisdiction change state notification email' }); + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/encumbrance-notification-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/encumbrance-notification-service.ts new file mode 100644 index 0000000000..6e8432812e --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/encumbrance-notification-service.ts @@ -0,0 +1,461 @@ +import { BaseEmailService } from './base-email-service'; +import { EnvironmentVariablesService } from '../environment-variables-service'; +import { IJurisdiction } from '../models/jurisdiction'; + +const environmentVariableService = new EnvironmentVariablesService(); + +/** + * Service for handling encumbrance-related email notifications + */ +export class EncumbranceNotificationService extends BaseEmailService { + private async getJurisdictionAdverseActionRecipients( + jurisdictionConfig: IJurisdiction + ): Promise { + const recipients = jurisdictionConfig.jurisdictionAdverseActionsNotificationEmails; + + if (recipients.length === 0) { + // If the state hasn't provided a contact for adverse actions, we note it and move on, preferring to + // continue with other notifications, rather than failing the entire notification process. + this.logger.warn('No adverse action notification recipients found for jurisdiction', { + compact: jurisdictionConfig.compact, + jurisdiction: jurisdictionConfig.postalAbbreviation + }); + return []; + } + + return recipients; + } + + /** + * Gets jurisdiction configurations and adverse action recipients for state notifications, + * handling errors gracefully by logging warnings and continuing + * @param compact - The compact name + * @param notifyingJurisdiction - The jurisdiction that should be notified + * @param affectedJurisdiction - The jurisdiction where the encumbrance/lifting occurred + * @param context - Context for logging (e.g., 'license encumbrance', 'privilege lifting') + * @returns Object containing recipients and affected jurisdiction config, or empty if error occurred + */ + private async getStateNotificationData( + compact: string, + notifyingJurisdiction: string, + affectedJurisdiction: string, + context: string + ): Promise<{ + recipients: string[]; + affectedJurisdictionConfig: IJurisdiction | undefined; + }> { + let affectedJurisdictionConfig: IJurisdiction | undefined; + let recipients: string[] = []; + + try { + const notifyingJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, notifyingJurisdiction + ); + + if (notifyingJurisdictionConfig.postalAbbreviation !== affectedJurisdiction) { + affectedJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, affectedJurisdiction + ); + } else { + affectedJurisdictionConfig = notifyingJurisdictionConfig; + } + + recipients = await this.getJurisdictionAdverseActionRecipients(notifyingJurisdictionConfig); + } catch (error) { + // If we have missing jurisdiction configuration, we note it and move on, preferring to + // continue, rather than failing the entire notification process. + this.logger.warn(`Error getting jurisdiction configuration for state ${context} notification email`, { + compact: compact, + notifyingJurisdiction: notifyingJurisdiction, + affectedJurisdiction: affectedJurisdiction, + error: error + }); + } + + return { recipients, affectedJurisdictionConfig }; + } + + /** + * Sends a license encumbrance notification email to the provider + * @param compact - The compact name + * @param specificEmails - The provider's email address + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param jurisdiction - The jurisdiction where the license was encumbered + * @param licenseType - The license type that was encumbered + * @param effectiveStartDate - The date the encumbrance became effective + */ + public async sendLicenseEncumbranceProviderNotificationEmail( + compact: string, + specificEmails: string[], + providerFirstName: string, + providerLastName: string, + encumberedJurisdiction: string, + licenseType: string, + effectiveStartDate: string + ): Promise { + this.logger.info('Sending license encumbrance provider notification email', { compact: compact }); + + if (specificEmails.length === 0) { + throw new Error('No recipients specified for provider license encumbrance notification email'); + } + + const encumberedJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, encumberedJurisdiction + ); + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + + const report = this.getNewEmailTemplate(); + const subject = `Your ${licenseType} license in ${encumberedJurisdictionConfig.jurisdictionName} is encumbered`; + const bodyText = `${providerFirstName} ${providerLastName},\n\n` + + `This message is to notify you that your *${licenseType}* license in ${encumberedJurisdictionConfig.jurisdictionName} was encumbered, effective **${effectiveStartDate}**. ` + + `This encumbrance restricts your ability to practice under the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${encumberedJurisdictionConfig.jurisdictionName} for more information about this encumbrance.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients: specificEmails, errorMessage: 'Unable to send provider license encumbrance notification email' }); + } + + /** + * Sends a license encumbrance notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param encumberedJurisdiction - The jurisdiction where the license was encumbered + * @param licenseType - The license type that was encumbered + * @param effectiveStartDate - The date the encumbrance became effective + */ + public async sendLicenseEncumbranceStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + encumberedJurisdiction: string, + licenseType: string, + effectiveStartDate: string + ): Promise { + this.logger.info('Sending license encumbrance state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, encumberedJurisdiction, 'license encumbrance' + ); + + if (recipients.length > 0 && affectedJurisdictionConfig !== undefined) { + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `License Encumbrance Notification - ${providerFirstName} ${providerLastName}`; + const bodyText = `This message is to notify you that the *${licenseType}* license held by ${providerFirstName} ${providerLastName} ` + + `in ${affectedJurisdictionConfig.jurisdictionName} was encumbered, effective **${effectiveStartDate}**.\n\n` + + `This encumbrance restricts the provider's ability to practice under the ${compactConfig.compactName} compact.\n\n` + + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send state license encumbrance notification email' }); + } + } + + /** + * Sends a license encumbrance lifting notification email to the provider + * @param compact - The compact name + * @param specificEmails - The provider's email address + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param jurisdiction - The jurisdiction where the license encumbrance was lifted + * @param licenseType - The license type that had encumbrance lifted + * @param effectiveLiftDate - The date the encumbrance was lifted + */ + public async sendLicenseEncumbranceLiftingProviderNotificationEmail( + compact: string, + specificEmails: string[], + providerFirstName: string, + providerLastName: string, + liftedJurisdiction: string, + licenseType: string, + effectiveLiftDate: string + ): Promise { + this.logger.info('Sending license encumbrance lifting provider notification email', { compact: compact }); + + if (specificEmails.length === 0) { + throw new Error('No recipients specified for provider license encumbrance lifting notification email'); + } + + const liftedJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, liftedJurisdiction + ); + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + + const report = this.getNewEmailTemplate(); + const subject = `Your ${licenseType} license in ${liftedJurisdictionConfig.jurisdictionName} is no longer encumbered`; + const bodyText = `${providerFirstName} ${providerLastName},\n\n` + + `This message is to notify you that an encumbrance on your *${licenseType}* license in ${liftedJurisdictionConfig.jurisdictionName} was lifted, effective **${effectiveLiftDate}**. ` + + `This encumbrance no longer restricts your ability to practice under the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${liftedJurisdictionConfig.jurisdictionName} if you have any questions about this change.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients: specificEmails, errorMessage: 'Unable to send provider license encumbrance lifting notification email' }); + } + + /** + * Sends a license encumbrance lifting notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param liftedJurisdiction - The jurisdiction where the license encumbrance was lifted + * @param licenseType - The license type that had encumbrance lifted + * @param effectiveLiftDate - The date the encumbrance was lifted + */ + public async sendLicenseEncumbranceLiftingStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + liftedJurisdiction: string, + licenseType: string, + effectiveLiftDate: string + ): Promise { + this.logger.info('Sending license encumbrance lifting state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, liftedJurisdiction, 'license lifting' + ); + + if (recipients.length > 0 && affectedJurisdictionConfig !== undefined) { + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `License Encumbrance Lifted Notification - ${providerFirstName} ${providerLastName}`; + const bodyText = `This message is to notify you that an encumbrance on the *${licenseType}* license held by ${providerFirstName} ${providerLastName} ` + + `in ${affectedJurisdictionConfig.jurisdictionName} was lifted, effective **${effectiveLiftDate}**.\n\n` + + `The encumbrance no longer restricts the provider's ability to practice under the ${compactConfig.compactName} compact.\n\n` + + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send state license encumbrance lifting notification email' }); + } + } + + /** + * Sends a privilege encumbrance notification email to the provider + * @param compact - The compact name + * @param specificEmails - The provider's email address + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param jurisdiction - The jurisdiction where the privilege was encumbered + * @param licenseType - The license type associated with the privilege + * @param effectiveStartDate - The date the encumbrance became effective + */ + public async sendPrivilegeEncumbranceProviderNotificationEmail( + compact: string, + specificEmails: string[], + providerFirstName: string, + providerLastName: string, + encumberedJurisdiction: string, + licenseType: string, + effectiveStartDate: string + ): Promise { + this.logger.info('Sending privilege encumbrance provider notification email', { compact: compact }); + + if (specificEmails.length === 0) { + throw new Error('No recipients specified for provider privilege encumbrance notification email'); + } + + const encumberedJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, encumberedJurisdiction + ); + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + + const report = this.getNewEmailTemplate(); + const subject = `Your ${licenseType} privilege in ${encumberedJurisdictionConfig.jurisdictionName} is encumbered`; + const bodyText = `${providerFirstName} ${providerLastName},\n\n` + + `This message is to notify you that your *${licenseType}* privilege in ${encumberedJurisdictionConfig.jurisdictionName} has been encumbered, effective **${effectiveStartDate}**. ` + + `This encumbrance restricts your ability to practice in ${encumberedJurisdictionConfig.jurisdictionName} under the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${encumberedJurisdictionConfig.jurisdictionName} for more information about this encumbrance.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients: specificEmails, errorMessage: 'Unable to send provider privilege encumbrance notification email' }); + } + + /** + * Sends a privilege encumbrance notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param encumberedJurisdiction - The jurisdiction where the privilege was encumbered + * @param licenseType - The license type associated with the privilege + * @param effectiveStartDate - The date the encumbrance became effective + */ + public async sendPrivilegeEncumbranceStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + encumberedJurisdiction: string, + licenseType: string, + effectiveStartDate: string + ): Promise { + this.logger.info('Sending privilege encumbrance state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, encumberedJurisdiction, 'privilege encumbrance' + ); + + if (recipients.length > 0 && affectedJurisdictionConfig !== undefined) { + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `Privilege Encumbrance Notification - ${providerFirstName} ${providerLastName}`; + const bodyText = `This message is to notify you that the *${licenseType}* privilege held by ${providerFirstName} ${providerLastName} ` + + `in ${affectedJurisdictionConfig.jurisdictionName} was encumbered, effective **${effectiveStartDate}**.\n\n` + + `This encumbrance restricts the provider's ability to practice in ${affectedJurisdictionConfig.jurisdictionName} under the ${compactConfig.compactName} compact.\n\n` + + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send state privilege encumbrance notification email' }); + } + } + + /** + * Sends a privilege encumbrance lifting notification email to the provider + * @param compact - The compact name + * @param specificEmails - The provider's email address + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param jurisdiction - The jurisdiction where the privilege encumbrance was lifted + * @param licenseType - The license type associated with the privilege + * @param effectiveLiftDate - The date the encumbrance was lifted + */ + public async sendPrivilegeEncumbranceLiftingProviderNotificationEmail( + compact: string, + specificEmails: string[], + providerFirstName: string, + providerLastName: string, + liftedJurisdiction: string, + licenseType: string, + effectiveLiftDate: string + ): Promise { + this.logger.info('Sending privilege encumbrance lifting provider notification email', { compact: compact }); + + if (specificEmails.length === 0) { + throw new Error('No recipients specified for provider privilege encumbrance lifting notification email'); + } + + const liftedJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, liftedJurisdiction + ); + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + + const report = this.getNewEmailTemplate(); + const subject = `Your ${licenseType} privilege in ${liftedJurisdictionConfig.jurisdictionName} is no longer encumbered`; + const bodyText = `${providerFirstName} ${providerLastName},\n\n` + + `This message is to notify you that an encumbrance on your *${licenseType}* privilege in ${liftedJurisdictionConfig.jurisdictionName} was lifted, effective **${effectiveLiftDate}**. ` + + `The encumbrance no longer restricts your ability to practice in ${liftedJurisdictionConfig.jurisdictionName} under the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${liftedJurisdictionConfig.jurisdictionName} if you have any questions.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients: specificEmails, errorMessage: 'Unable to send provider privilege encumbrance lifting notification email' }); + } + + /** + * Sends a privilege encumbrance lifting notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param liftedJurisdiction - The jurisdiction where the privilege encumbrance was lifted + * @param licenseType - The license type associated with the privilege + * @param effectiveLiftDate - The date the encumbrance was lifted + */ + public async sendPrivilegeEncumbranceLiftingStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + liftedJurisdiction: string, + licenseType: string, + effectiveLiftDate: string + ): Promise { + this.logger.info('Sending privilege encumbrance lifting state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, liftedJurisdiction, 'privilege lifting' + ); + + if (recipients.length > 0 && affectedJurisdictionConfig !== undefined) { + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `Privilege Encumbrance Lifted Notification - ${providerFirstName} ${providerLastName}`; + const bodyText = `This message is to notify you that an encumbrance on the *${licenseType}* privilege held by ${providerFirstName} ${providerLastName} ` + + `in ${affectedJurisdictionConfig.jurisdictionName} was lifted, effective **${effectiveLiftDate}**.\n\n` + + `The encumbrance no longer restricts the provider's ability to practice in ${affectedJurisdictionConfig.jurisdictionName} under the ${compactConfig.compactName} compact.\n\n` + + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send state privilege encumbrance lifting notification email' }); + } + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/environment-banner-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/environment-banner-service.ts new file mode 100644 index 0000000000..883b0d1b05 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/environment-banner-service.ts @@ -0,0 +1,128 @@ +import * as crypto from 'crypto'; +import { TReaderDocument } from '@csg-org/email-builder'; +import { EnvironmentVariablesService } from '../environment-variables-service'; + +/** + * Service for adding environment-specific banners and footers to email templates + */ +export class EnvironmentBannerService { + private readonly environmentVariablesService = new EnvironmentVariablesService(); + + /** + * Inserts environment banner if current environment is non-production + */ + public insertEnvironmentBannerIfNonProd(template: TReaderDocument): void { + try { + if (this.shouldShowBanner()) { + this.insertBanner(template, this.getBannerText()); + } + } catch (error) { + // Log error but don't throw - email should still send without banner + console.error('Error inserting environment banner:', error); + } + } + + /** + * Inserts red "test email" footer if current environment is non-production + */ + public insertTestEmailFooterIfNonProd(template: TReaderDocument): void { + try { + if (this.shouldShowBanner()) { + this.insertTestWarningFooter(template); + } + } catch (error) { + // Log error but don't throw - email should still send without footer + console.error('Error inserting test email footer:', error); + } + } + + /** + * Determines if banner/footer should be shown based on environment + * Returns true for all environments except production + */ + private shouldShowBanner(): boolean { + try { + const envName = this.environmentVariablesService.getEnvironmentName().toLowerCase().trim(); + + // only show banner for non-production environments and if the environment is defined + return envName !== 'prod' && envName !== ''; + } catch (error) { + // If environment detection fails, default to not showing banner + // (better to not show than to show the banner in a prod environment) + console.error('Error detecting environment, defaulting to not showing banner:', error); + return false; + } + } + + /** + * Gets environment-specific banner text + */ + private getBannerText(): string { + return `⚠️ TEST: The info in this email is from a testing environment and is for testing purposes only.`; + } + + /** + * Inserts the environment banner at the top of the email template + */ + private insertBanner(template: TReaderDocument, bannerText: string): void { + const blockId = `block-environment-banner-${crypto.randomUUID()}`; + + template[blockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'backgroundColor': '#FFA726', + 'color': '#000000', + 'fontSize': 14, + 'fontWeight': 'bold', + 'textAlign': 'center', + 'padding': { + 'top': 16, + 'bottom': 16, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'text': `${bannerText}`, + 'markdown': false + } + } + }; + + // Insert banner at the beginning of the email (before existing content) + template['root']['data']['childrenIds'].unshift(blockId); + } + + /** + * Inserts the test email warning footer at the bottom of the email template + */ + private insertTestWarningFooter(template: TReaderDocument): void { + const blockId = `block-test-warning-footer-${crypto.randomUUID()}`; + + template[blockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': '#DA2525', + 'fontSize': 13, + 'fontWeight': 'normal', + 'textAlign': 'center', + 'padding': { + 'top': 16, + 'bottom': 24, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'text': 'You\'re viewing a test email.', + 'markdown': false + } + } + }; + + // Insert footer at the end of the email (after existing content) + template['root']['data']['childrenIds'].push(blockId); + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/index.ts b/backend/social-work-app/lambdas/nodejs/lib/email/index.ts new file mode 100644 index 0000000000..6f9d5a2c5d --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/index.ts @@ -0,0 +1,6 @@ +export { CognitoEmailService } from './cognito-email-service'; +export { EncumbranceNotificationService } from './encumbrance-notification-service'; +export { InvestigationNotificationService } from './investigation-notification-service'; +export { IngestEventEmailService } from './ingest-event-email-service'; +export { EnvironmentBannerService } from './environment-banner-service'; +export { EmailNotificationService } from './email-notification-service'; \ No newline at end of file diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/ingest-event-email-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/ingest-event-email-service.ts new file mode 100644 index 0000000000..839e3c07ba --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/ingest-event-email-service.ts @@ -0,0 +1,454 @@ +import * as crypto from 'crypto'; +import { TReaderDocument } from '@csg-org/email-builder'; +import { IIngestFailureEventRecord, IValidationErrorEventRecord } from '../models'; +import { BaseEmailService } from './base-email-service'; + +interface IIngestEvents { + ingestFailures: IIngestFailureEventRecord[]; + validationErrors: IValidationErrorEventRecord[]; +} + +/** + * Email service for handling ingest event reporting + */ +export class IngestEventEmailService extends BaseEmailService { + public async sendReportEmail(events: IIngestEvents, + compactName: string, + jurisdiction: string, + recipients: string[] + ) { + this.logger.info('Sending report email', { recipients: recipients }); + + // Generate the HTML report + const htmlContent = this.generateReport(events, compactName, jurisdiction); + + return this.sendEmail({ + htmlContent, + subject: `License Data Error Summary: ${compactName} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending report email' + }); + } + + public async sendAllsWellEmail(compactName: string, jurisdiction: string, recipients: string[]) { + this.logger.info('Sending alls well email', { recipients: recipients }); + + // Generate the HTML report + const report = this.getNewEmailTemplate(); + + this.insertHeaderWithJurisdiction(report, compactName, jurisdiction, 'License Data Summary'); + this.insertNoErrorImage(report); + this.insertSubHeading(report, 'There have been no license data errors this week!'); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + return this.sendEmail({ + htmlContent, + subject: `License Data Summary: ${compactName} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending alls well email' + }); + } + + public async sendNoLicenseUpdatesEmail(compactName: string, jurisdiction: string, recipients: string[]) { + this.logger.info('Sending no license updates email', { recipients: recipients }); + + // Generate the HTML report + const report = this.getNewEmailTemplate(); + + this.insertHeaderWithJurisdiction(report, compactName, jurisdiction, 'License Data Summary'); + this.insertClockImage(report); + this.insertSubHeading(report, 'There have been no licenses uploaded in the last 7 days.'); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + return this.sendEmail({ + htmlContent, + subject: `No License Updates for Last 7 Days: ${compactName} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending no license updates email' + }); + } + + public generateReport(events: IIngestEvents, compactName: string, jurisdiction: string): string { + const report = this.getNewEmailTemplate(); + + this.insertHeaderWithJurisdiction( + report, + compactName, + jurisdiction, + 'License Data Error Summary' + ); + this.insertSubHeading( + report, + 'There have been some license data errors that prevented ingest. ' + + 'They are listed below:' + ); + for (const ingestFailure of events.ingestFailures) { + this.insertDiv(report); + this.insertIngestFailure(report, ingestFailure); + } + + // Sort the validation errors by record number then by event time + const validationErrors = this.sortValidationErrors(events.validationErrors); + + for (const validationError of validationErrors) { + this.insertDiv(report); + this.insertValidationError(report, validationError); + } + + this.insertFooter(report); + + return this.renderTemplate(report); + } + + protected sortValidationErrors(validationErrors: IValidationErrorEventRecord[]) { + validationErrors.sort((a, b) => { + if (a.recordNumber != b.recordNumber) { + return a.recordNumber - b.recordNumber; + } else { + return new Date(a.eventTime).getTime() - new Date(b.eventTime).getTime(); + } + }); + return validationErrors; + } + + private insertIngestFailure(report: TReaderDocument, ingestFailure: IIngestFailureEventRecord) { + const blockAId = `block-${crypto.randomUUID()}`; + + report[blockAId] = { + 'type': 'Heading', + 'data': { + 'props': { + 'text': 'Ingest error', + 'level': 'h3' + }, + 'style': { + 'color': '#DA2525', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + } + } + }; + + const blockBId: string = `block-${crypto.randomUUID()}`; + + report[blockBId] = { + 'type': 'Text', + 'data': { + 'style': { + 'fontWeight': 'normal', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'text': '' + } + } + }; + + const blockCId = `block-${crypto.randomUUID()}`; + + report[blockCId] = { + 'type': 'Text', + 'data': { + 'style': { + 'fontWeight': 'normal', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'text': '' + } + } + }; + + const blockDId = `block-${crypto.randomUUID()}`; + const ingestErrorMessage = ingestFailure.errors.join('\n'); + + report[blockDId] = { + 'type': 'Text', + 'data': { + 'style': { + 'fontWeight': 'normal', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'text': ingestErrorMessage + } + } + }; + + const primaryBlockId = `block-${crypto.randomUUID()}`; + + report[primaryBlockId] = { + 'type': 'ColumnsContainer', + 'data': { + 'style': { + 'padding': { + 'top': 4, + 'bottom': 12, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'columnsCount': 2, + 'columnsGap': 16, + 'columns': [ + { + 'childrenIds': [ + blockAId, + blockBId + ] + }, + { + 'childrenIds': [ + blockCId, + blockDId + ] + }, + { + 'childrenIds': [] + } + ] + } + } + }; + + // Add the ingest error block to the root block + report['root']['data']['childrenIds'].push(primaryBlockId); + } + + private insertValidationError(report: TReaderDocument, validationError: IValidationErrorEventRecord) { + const blockAId = `block-${crypto.randomUUID()}`; + + // Insert the new blocks into the report + report[blockAId] = { + 'type': 'Heading', + 'data': { + 'props': { + 'text': `Line ${validationError.recordNumber}`, + 'level': 'h3' + }, + 'style': { + 'color': '#2459A9', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + } + } + }; + + const blockBId = `block-${crypto.randomUUID()}`; + + const errorText: string[] = []; + + /* Format the error map structure into an error string: + * errors: { 'licenseType': ['must be one of X, Y', 'smells bad'] } + * + * becomes + * + * licenseType: + * must be one of X, Y + * smells bad + */ + for (const [key, value] of Object.entries(validationError.errors)) { + this.logger.debug('Assembling text', { key: key, value: value }); + + errorText.push(`${key}:\n${value.join('\n')}`); + } + + report[blockBId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': null, + 'fontWeight': 'normal', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'markdown': true, + 'text': errorText.sort().join('\n'), + } + } + }; + + const blockCId = `block-${crypto.randomUUID()}`; + const validDataText: string[] = []; + + for (const [key, value] of Object.entries(validationError.validData)) { + validDataText.push(`${key}: ${value}`); + } + + report[blockCId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': '#A3A3A3', + 'fontSize': 14, + 'fontWeight': 'normal', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'markdown': true, + 'text': 'PRACTITIONER INFO' + } + } + }; + + const blockDId = `block-${crypto.randomUUID()}`; + + report[blockDId] = { + 'type': 'Text', + 'data': { + 'style': { + 'color': null, + 'fontSize': 16, + 'fontWeight': 'normal', + 'padding': { + 'top': 0, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'markdown': true, + 'text': validDataText.sort().join('\n') + } + } + }; + + const primaryBlockId = `block-${crypto.randomUUID()}`; + + report[primaryBlockId] = { + 'type': 'ColumnsContainer', + 'data': { + 'style': { + 'padding': { + 'top': 4, + 'bottom': 0, + 'right': 24, + 'left': 24 + } + }, + 'props': { + 'columnsCount': 2, + 'columnsGap': 16, + 'contentAlignment': 'top', + 'columns': [ + { + 'childrenIds': [ + blockAId, + blockBId + ] + }, + { + 'childrenIds': [ + blockCId, + blockDId + ] + }, + { + 'childrenIds': [] + } + ] + } + } + }; + + // Add the ingest error block to the root block + report['root']['data']['childrenIds'].push(primaryBlockId); + } + + private insertClockImage(report: TReaderDocument) { + const blockId = `block-clock-image`; + + report[blockId] = { + 'type': 'Image', + 'data': { + 'style': { + 'padding': { + 'top': 68, + 'bottom': 16, + 'right': 24, + 'left': 24 + }, + 'textAlign': 'center' + }, + 'props': { + 'width': 100, + 'height': 100, + 'url': `${IngestEventEmailService.getEmailImageBaseUrl()}/ico-noupdates@2x.png`, + 'alt': 'Clock icon', + 'linkHref': null, + 'contentAlignment': 'middle' + } + } + }; + + report['root']['data']['childrenIds'].push(blockId); + } + + private insertNoErrorImage(report: TReaderDocument) { + const blockId = `block-no-error-image`; + + report[blockId] = { + 'type': 'Image', + 'data': { + 'style': { + 'padding': { + 'top': 68, + 'bottom': 16, + 'right': 24, + 'left': 24 + }, + 'textAlign': 'center' + }, + 'props': { + 'width': 100, + 'height': 100, + 'url': `${IngestEventEmailService.getEmailImageBaseUrl()}/ico-noerrors@2x.png`, + 'alt': 'Success icon', + 'linkHref': null, + 'contentAlignment': 'middle' + } + } + }; + + report['root']['data']['childrenIds'].push(blockId); + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/investigation-notification-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/investigation-notification-service.ts new file mode 100644 index 0000000000..8b39a94a32 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/email/investigation-notification-service.ts @@ -0,0 +1,287 @@ +import { BaseEmailService } from './base-email-service'; +import { IJurisdiction } from '../models/jurisdiction'; + + +/** + * Service for handling investigation-related email notifications + */ +export class InvestigationNotificationService extends BaseEmailService { + private async getJurisdictionAdverseActionRecipients( + jurisdictionConfig: IJurisdiction + ): Promise { + const recipients = jurisdictionConfig.jurisdictionAdverseActionsNotificationEmails; + + if (recipients.length === 0) { + // If the state hasn't provided a contact for adverse actions, we note it and move on, preferring to + // continue with other notifications, rather than failing the entire notification process. + this.logger.warn('No adverse action notification recipients found for jurisdiction', { + compact: jurisdictionConfig.compact, + jurisdiction: jurisdictionConfig.postalAbbreviation + }); + return []; + } + + return recipients; + } + + /** + * Gets jurisdiction configurations and adverse action recipients for state notifications, + * handling errors gracefully by logging warnings and continuing + * @param compact - The compact name + * @param notifyingJurisdiction - The jurisdiction that should be notified + * @param affectedJurisdiction - The jurisdiction where the investigation occurred + * @param context - Context for logging (e.g., 'license investigation', 'privilege investigation closed') + * @returns Object containing recipients and affected jurisdiction config, or empty if error occurred + */ + private async getStateNotificationData( + compact: string, + notifyingJurisdiction: string, + affectedJurisdiction: string, + context: string + ): Promise<{ + recipients: string[]; + affectedJurisdictionConfig: IJurisdiction | undefined; + }> { + let affectedJurisdictionConfig: IJurisdiction | undefined; + let recipients: string[] = []; + + try { + const notifyingJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, notifyingJurisdiction + ); + + if (notifyingJurisdictionConfig.postalAbbreviation !== affectedJurisdiction) { + affectedJurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration( + compact, affectedJurisdiction + ); + } else { + affectedJurisdictionConfig = notifyingJurisdictionConfig; + } + + recipients = await this.getJurisdictionAdverseActionRecipients(notifyingJurisdictionConfig); + } catch (error) { + // If we have missing jurisdiction configuration, we note it and move on, preferring to + // continue, rather than failing the entire notification process. + this.logger.warn(`Error getting jurisdiction configuration for state ${context} notification email`, { + compact: compact, + notifyingJurisdiction: notifyingJurisdiction, + affectedJurisdiction: affectedJurisdiction, + error: error + }); + } + + return { recipients, affectedJurisdictionConfig }; + } + + /** + * Sends a license investigation notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param investigationJurisdiction - The jurisdiction where the license is under investigation + * @param licenseType - The license type that is under investigation + */ + public async sendLicenseInvestigationStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + investigationJurisdiction: string, + licenseType: string + ): Promise { + this.logger.info('Sending license investigation state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, investigationJurisdiction, 'license investigation' + ); + + if (recipients.length === 0) { + this.logger.warn('No recipients found for license investigation state notification', { + compact: compact, + jurisdiction: jurisdiction + }); + return; + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `${providerFirstName} ${providerLastName} holding ${licenseType} license in ${affectedJurisdictionConfig?.jurisdictionName} is under investigation`; + const bodyText = `This message is to notify you that ${providerFirstName} ${providerLastName} (Provider ID: ${providerId}) ` + + `holding a *${licenseType}* license in ${affectedJurisdictionConfig?.jurisdictionName} is under investigation ` + + `in the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${affectedJurisdictionConfig?.jurisdictionName} for more information about this investigation.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send license investigation state notification email' }); + } + + /** + * Sends a license investigation closed notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param investigationJurisdiction - The jurisdiction where the license investigation was closed + * @param licenseType - The license type that was under investigation + */ + public async sendLicenseInvestigationClosedStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + investigationJurisdiction: string, + licenseType: string + ): Promise { + this.logger.info('Sending license investigation closed state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, investigationJurisdiction, 'license investigation closed' + ); + + if (recipients.length === 0) { + this.logger.warn('No recipients found for license investigation closed state notification', { + compact: compact, + jurisdiction: jurisdiction + }); + return; + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `Investigation on ${providerFirstName} ${providerLastName}'s ${licenseType} license in ${affectedJurisdictionConfig?.jurisdictionName} has been closed`; + const bodyText = `This message is to notify you that the investigation on ${providerFirstName} ${providerLastName} (Provider ID: ${providerId}) ` + + `holding a *${licenseType}* license in ${affectedJurisdictionConfig?.jurisdictionName} has been closed ` + + `in the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${affectedJurisdictionConfig?.jurisdictionName} for more information about this investigation closure.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send license investigation closed state notification email' }); + } + + /** + * Sends a privilege investigation notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param investigationJurisdiction - The jurisdiction where the privilege is under investigation + * @param licenseType - The license type that is under investigation + */ + public async sendPrivilegeInvestigationStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + investigationJurisdiction: string, + licenseType: string + ): Promise { + this.logger.info('Sending privilege investigation state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, investigationJurisdiction, 'privilege investigation' + ); + + if (recipients.length === 0) { + this.logger.warn('No recipients found for privilege investigation state notification', { + compact: compact, + jurisdiction: jurisdiction + }); + return; + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `${providerFirstName} ${providerLastName} holding ${licenseType} privilege in ${affectedJurisdictionConfig?.jurisdictionName} is under investigation`; + const bodyText = `This message is to notify you that ${providerFirstName} ${providerLastName} (Provider ID: ${providerId}) ` + + `holding a *${licenseType}* privilege in ${affectedJurisdictionConfig?.jurisdictionName} is under investigation ` + + `in the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${affectedJurisdictionConfig?.jurisdictionName} for more information about this investigation.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send privilege investigation state notification email' }); + } + + /** + * Sends a privilege investigation closed notification email to state authorities + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param investigationJurisdiction - The jurisdiction where the privilege investigation was closed + * @param licenseType - The license type that was under investigation + */ + public async sendPrivilegeInvestigationClosedStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + investigationJurisdiction: string, + licenseType: string + ): Promise { + this.logger.info('Sending privilege investigation closed state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const { recipients, affectedJurisdictionConfig } = await this.getStateNotificationData( + compact, jurisdiction, investigationJurisdiction, 'privilege investigation closed' + ); + + if (recipients.length === 0) { + this.logger.warn('No recipients found for privilege investigation closed state notification', { + compact: compact, + jurisdiction: jurisdiction + }); + return; + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `Investigation on ${providerFirstName} ${providerLastName}'s ${licenseType} privilege in ${affectedJurisdictionConfig?.jurisdictionName} has been closed`; + const bodyText = `This message is to notify you that the investigation on ${providerFirstName} ${providerLastName} (Provider ID: ${providerId}) ` + + `holding a *${licenseType}* privilege in ${affectedJurisdictionConfig?.jurisdictionName} has been closed ` + + `in the ${compactConfig.compactName} compact.\n\n` + + `Please contact the licensing board in ${affectedJurisdictionConfig?.jurisdictionName} for more information about this investigation closure.`; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send privilege investigation closed state notification email' }); + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/environment-variables-service.ts b/backend/social-work-app/lambdas/nodejs/lib/environment-variables-service.ts new file mode 100644 index 0000000000..c8c9d097da --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/environment-variables-service.ts @@ -0,0 +1,51 @@ +export class EnvironmentVariablesService { + private readonly compactsVariable = 'COMPACTS'; + private readonly compactConfigurationTableNameVariable = 'COMPACT_CONFIGURATION_TABLE_NAME'; + private readonly dataEventTableNameVariable = 'DATA_EVENT_TABLE_NAME'; + private readonly uiBasePathUrlVariable = 'UI_BASE_PATH_URL'; + private readonly fromAddressVariable = 'FROM_ADDRESS'; + private readonly debugVariable = 'DEBUG'; + private readonly transactionReportsBucketNameVariable = 'TRANSACTION_REPORTS_BUCKET_NAME'; + private readonly userPoolTypeVariable = 'USER_POOL_TYPE'; + private readonly environmentNameVariable = 'ENVIRONMENT_NAME'; + + public getEnvVar(name: string): string { + return process.env[name]?.trim() || ''; + } + + public getDataEventTableName() { + return this.getEnvVar(this.dataEventTableNameVariable); + } + + public getUiBasePathUrl() { + return this.getEnvVar(this.uiBasePathUrlVariable); + } + + public getCompactConfigurationTableName() { + return this.getEnvVar(this.compactConfigurationTableNameVariable); + } + + public getCompacts(): string[] { + return JSON.parse(this.getEnvVar(this.compactsVariable)); + } + + public getLogLevel() { + return this.getEnvVar(this.debugVariable).toLowerCase() == 'true' ? 'DEBUG' : 'INFO'; + } + + public getFromAddress() { + return this.getEnvVar(this.fromAddressVariable); + } + + public getTransactionReportsBucketName() { + return this.getEnvVar(this.transactionReportsBucketNameVariable); + } + + public getUserPoolType() { + return this.getEnvVar(this.userPoolTypeVariable); + } + + public getEnvironmentName() { + return this.getEnvVar(this.environmentNameVariable); + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/event-client.ts b/backend/social-work-app/lambdas/nodejs/lib/event-client.ts new file mode 100644 index 0000000000..c27299211a --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/event-client.ts @@ -0,0 +1,226 @@ +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { EnvironmentVariablesService } from './environment-variables-service'; +import { IIngestFailureEventRecord, IIngestSuccessEventRecord, IValidationErrorEventRecord } from './models'; + +const environmentVariables = new EnvironmentVariablesService(); + + +interface EventClientProps { + logger: Logger; + dynamoDBClient: DynamoDBClient; +} + + +/* + * Event Client that can retrieve validation error and ingest failure events from the License Events + * DynamoDB table. + */ +export class EventClient { + private readonly logger: Logger; + private readonly dynamoDBClient: DynamoDBClient; + + public constructor(props: EventClientProps) { + this.logger = props.logger; + this.dynamoDBClient = props.dynamoDBClient; + } + + /* + * Returns timestamps for the last complete 15-minute block + * i.e. if now is 13:05, returns 12:45-13:00 + * if now is 13:15, returns 13:00-13:15 + */ + public getLast15MinuteTimestamps(): [number, number] { + const now: Date = new Date(); + const last15MinuteBlockStart: Date = new Date(); + const last15MinuteBlockEnd: Date = new Date(); + + // Calculate the start of the current 15-minute block + const currentBlockStartMinutes = now.getUTCMinutes() - (now.getUTCMinutes() % 15); + + last15MinuteBlockStart.setUTCMinutes(currentBlockStartMinutes, 0, 0); + + // The end of the previous complete block is the start of the current block + last15MinuteBlockEnd.setTime(last15MinuteBlockStart.getTime()); + + // The start of the previous complete block is 15 minutes before the end + last15MinuteBlockStart.setUTCMinutes(currentBlockStartMinutes - 15, 0, 0); + + return [ + Math.floor((last15MinuteBlockStart.valueOf()/1000)), + Math.floor((last15MinuteBlockEnd.valueOf()/1000)), + ]; + } + + /* + * Returns timestamps for the beginning and end of the previous UTC day + */ + public getYesterdayTimestamps() { + const today: Date = new Date(); + const yesterday: Date = new Date(); + + today.setUTCHours(0, 0, 0, 0); + yesterday.setUTCHours(0, 0, 0, 0); + + yesterday.setUTCDate(today.getUTCDate() - 1); + // Uncomment to manually force today's events into the time window (for development/testing) + // today.setUTCDate(today.getUTCDate() + 1); + return [ + Math.floor((yesterday.valueOf()/1000)), + Math.floor((today.valueOf()/1000)), + ]; + } + + /* + * Returns timestamps for the beginning and end of the previous UTC week (rolling) + */ + public getLastWeekTimestamps() { + const today: Date = new Date(); + const lastWeek: Date = new Date(); + + today.setUTCHours(0, 0, 0, 0); + lastWeek.setUTCHours(0, 0, 0, 0); + + lastWeek.setUTCDate(today.getUTCDate() - 7); + // Uncomment to manually force today's events into the time window (for development/testing) + // today.setUTCDate(today.getUTCDate() + 1); + return [ + Math.floor((lastWeek.valueOf()/1000)), + Math.floor((today.valueOf()/1000)), + ]; + } + + /* + * Queries the data event table for validation errors by looking for validation errors for the + * given compact/jurisdiction in the given time window. + */ + public async getValidationErrors( + compact: string, + jurisdiction: string, + startTimeStamp: number, + endTimeStamp: number + ): Promise { + this.logger.info('Getting validation errors', { + compact: compact, + jurisdiction: jurisdiction, + start_time_stamp: startTimeStamp, + end_time_stamp: endTimeStamp, + }); + const tableName = environmentVariables.getDataEventTableName(); + const resp = await this.dynamoDBClient.send(new QueryCommand({ + TableName: tableName, + Select: 'ALL_ATTRIBUTES', + // We don't really want to present more than 100 errors in an email, so we won't bother + // querying that many up + Limit: 100, + KeyConditionExpression: 'pk = :pk and sk BETWEEN :skBegin and :skEnd', + ExpressionAttributeValues: { + ':pk': { 'S': `COMPACT#${compact}#JURISDICTION#${jurisdiction}` }, + ':skBegin': { 'S': `TYPE#license.validation-error#TIME#${startTimeStamp}#` }, + ':skEnd': { 'S': `TYPE#license.validation-error#TIME#${endTimeStamp}#` }, + } + })); + + return resp.Items?.map((item) => unmarshall(item) as IValidationErrorEventRecord) || []; + } + + /* + * Queries the data event table for ingest failures by looking for each comp + */ + public async getIngestFailures( + compact: string, + jurisdiction: string, + startTimeStamp: number, + endTimeStamp: number + ): Promise { + this.logger.info('Getting ingest failures', { + compact: compact, + jurisdiction: jurisdiction, + start_time_stamp: startTimeStamp, + end_time_stamp: endTimeStamp, + }); + const resp = await this.dynamoDBClient.send(new QueryCommand({ + TableName: environmentVariables.getDataEventTableName(), + Select: 'ALL_ATTRIBUTES', + // We don't really want to present more than 100 errors in an email, so we won't bother + // querying that many up + Limit: 100, + KeyConditionExpression: 'pk = :pk and sk BETWEEN :skBegin and :skEnd', + ExpressionAttributeValues: { + ':pk': { 'S': `COMPACT#${compact}#JURISDICTION#${jurisdiction}` }, + ':skBegin': { 'S': `TYPE#license.ingest-failure#TIME#${startTimeStamp}#` }, + ':skEnd': { 'S': `TYPE#license.ingest-failure#TIME#${endTimeStamp}#` }, + } + })); + + return resp.Items?.map((item) => unmarshall(item) as IIngestFailureEventRecord) || []; + } + + /* + * Queries the data event table for ingest successes. + * + * This is used to determine if a jurisdiction has had any successful uploads within the + * time window. + */ + public async getIngestSuccesses( + compact: string, + jurisdiction: string, + startTimeStamp: number, + endTimeStamp: number + ): Promise { + this.logger.info('Getting ingest failures', { + compact: compact, + jurisdiction: jurisdiction, + start_time_stamp: startTimeStamp, + end_time_stamp: endTimeStamp, + }); + const resp = await this.dynamoDBClient.send(new QueryCommand({ + TableName: environmentVariables.getDataEventTableName(), + Select: 'ALL_ATTRIBUTES', + // We don't necessarily return all the matching records for this query + // since we're just looking for the presence of any ingest successes + Limit: 1, + KeyConditionExpression: 'pk = :pk and sk BETWEEN :skBegin and :skEnd', + ExpressionAttributeValues: { + ':pk': { 'S': `COMPACT#${compact}#JURISDICTION#${jurisdiction}` }, + ':skBegin': { 'S': `TYPE#license.ingest#TIME#${startTimeStamp}#` }, + ':skEnd': { 'S': `TYPE#license.ingest#TIME#${endTimeStamp}#` }, + } + })); + + return resp.Items?.map((item) => unmarshall(item) as IIngestSuccessEventRecord) || []; + } + + public async getEvents(compact: string, jurisdiction: string, startTimeStamp: number, endTimeStamp: number) { + this.logger.info('Gathering events', { + compact: compact, + jurisdiction: jurisdiction, + start_time_stamp: startTimeStamp, + end_time_stamp: endTimeStamp, + }); + + const validationPromise = this.getValidationErrors( + compact, jurisdiction, startTimeStamp, endTimeStamp + ); + + const ingestFailurePromise = this.getIngestFailures( + compact, jurisdiction, startTimeStamp, endTimeStamp + ); + + const ingestSuccessPromise = this.getIngestSuccesses( + compact, jurisdiction, startTimeStamp, endTimeStamp + ); + + + const [ validationErrors, ingestFailures, ingestSuccesses ] = await Promise.all([ + validationPromise, ingestFailurePromise, ingestSuccessPromise + ]); + + return { + ingestFailures: ingestFailures, + ingestSuccesses: ingestSuccesses, + validationErrors: validationErrors, + }; + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/jurisdiction-client.ts b/backend/social-work-app/lambdas/nodejs/lib/jurisdiction-client.ts new file mode 100644 index 0000000000..a94389425f --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/jurisdiction-client.ts @@ -0,0 +1,76 @@ +/* + * Jurisdiction Client that can retrieve jurisdiction configuration data from the compact configuration + * DynamoDB table. + */ +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient, QueryCommand, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { EnvironmentVariablesService } from './environment-variables-service'; +import { IJurisdiction } from './models'; + +const environmentVariables = new EnvironmentVariablesService(); + +interface JurisdictionClientProps { + logger: Logger; + dynamoDBClient: DynamoDBClient; +} + +export class JurisdictionClient { + private readonly logger: Logger; + private readonly dynamoDBClient: DynamoDBClient; + private readonly tableName: string; + + public constructor(props: JurisdictionClientProps) { + this.logger = props.logger; + this.dynamoDBClient = props.dynamoDBClient; + this.tableName = environmentVariables.getCompactConfigurationTableName(); + } + + /* + * Queries the table for configured jurisdictions in the given compact + */ + public async getJurisdictionConfigurations( + compactAbbr: string + ): Promise { + const resp = await this.dynamoDBClient.send(new QueryCommand({ + TableName: this.tableName, + Select: 'ALL_ATTRIBUTES', + KeyConditionExpression: 'pk = :pk and begins_with (sk, :sk)', + ExpressionAttributeValues: { + ':pk': { 'S': `${compactAbbr}#CONFIGURATION` }, + ':sk': { 'S': `${compactAbbr}#JURISDICTION#` }, + } + })); + + return resp.Items?.map((item) => unmarshall(item) as IJurisdiction) || []; + } + + /** + * Get a specific jurisdiction configuration + * + * @param compact - The compact abbreviation + * @param jurisdiction - The jurisdiction postal abbreviation + * @returns The jurisdiction configuration + * @throws CCNotFoundException if the jurisdiction configuration is not found + */ + public async getJurisdictionConfiguration(compact: string, jurisdiction: string): Promise { + this.logger.info('Getting jurisdiction configuration', { compact, jurisdiction }); + + const response = await this.dynamoDBClient.send( + new GetItemCommand({ + TableName: this.tableName, + Key: { + 'pk': { S: `${compact}#CONFIGURATION` }, + 'sk': { S: `${compact}#JURISDICTION#${jurisdiction.toLowerCase()}` } + } + }) + ); + + if (!response.Item) { + throw new Error(`Jurisdiction configuration not found for ${jurisdiction}`); + } + + return unmarshall(response.Item) as IJurisdiction; + + } +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/models/compact.ts b/backend/social-work-app/lambdas/nodejs/lib/models/compact.ts new file mode 100644 index 0000000000..0c6c75d90b --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/models/compact.ts @@ -0,0 +1,16 @@ +export interface CompactCommissionFee { + feeAmount: number; + feeType: string; +} + +export interface Compact { + pk: string; + sk: string; + compactAdverseActionsNotificationEmails: string[]; + compactCommissionFee: CompactCommissionFee; + compactAbbr: string; + compactName: string; + compactOperationsTeamEmails: string[]; + dateOfUpdate: string; + type: string; +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/models/email-notification-service-event.ts b/backend/social-work-app/lambdas/nodejs/lib/models/email-notification-service-event.ts new file mode 100644 index 0000000000..f4f73c20be --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/models/email-notification-service-event.ts @@ -0,0 +1,21 @@ +export type RecipientType = + | 'COMPACT_OPERATIONS_TEAM' + | 'COMPACT_ADVERSE_ACTIONS' + | 'JURISDICTION_OPERATIONS_TEAM' + | 'JURISDICTION_ADVERSE_ACTIONS' + | 'SPECIFIC'; + +export interface EmailNotificationEvent { + template: string; + recipientType: RecipientType; + compact: string; + jurisdiction?: string; + specificEmails?: string[]; + templateVariables: { + [key: string]: any; + }; +} + +export interface EmailNotificationResponse { + message: string; +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/models/event-bridge-event-detail.ts b/backend/social-work-app/lambdas/nodejs/lib/models/event-bridge-event-detail.ts new file mode 100644 index 0000000000..0479c7ebb7 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/models/event-bridge-event-detail.ts @@ -0,0 +1,3 @@ +export interface IEventBridgeEvent { + eventType: 'nightly' | 'weekly'; +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/models/event-records.ts b/backend/social-work-app/lambdas/nodejs/lib/models/event-records.ts new file mode 100644 index 0000000000..3e15b58fbe --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/models/event-records.ts @@ -0,0 +1,44 @@ +/* + * Base License Event error record from the event database (after unmarshalling) + */ +export interface IIngestFailureEventRecord { + pk: string, + sk: string, + eventType: string, + eventTime: string, + compact: string, + jurisdiction: string, + errors: string[], +} + + +interface IValidationErrorEventErrors { + [key: string]: string[] +} + + +export interface IValidationErrorEventRecord { + pk: string, + sk: string, + eventType: string, + eventTime: string, + compact: string, + jurisdiction: string, + recordNumber: number, + validData: object, + errors: IValidationErrorEventErrors +} + +export interface IIngestSuccessEventRecord { + pk: string, + sk: string, + eventType: string, + eventTime: string; + compact: string; + jurisdiction: string; + licenseType: string; + status: 'active' | 'inactive'; + dateOfIssuance: string; + dateOfRenewal: string; + dateOfExpiration: string; +} diff --git a/backend/social-work-app/lambdas/nodejs/lib/models/index.ts b/backend/social-work-app/lambdas/nodejs/lib/models/index.ts new file mode 100644 index 0000000000..061854fae6 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/models/index.ts @@ -0,0 +1,3 @@ +export * from './event-records'; +export * from './jurisdiction'; +export * from './compact'; diff --git a/backend/social-work-app/lambdas/nodejs/lib/models/jurisdiction.ts b/backend/social-work-app/lambdas/nodejs/lib/models/jurisdiction.ts new file mode 100644 index 0000000000..a83df1eec6 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/lib/models/jurisdiction.ts @@ -0,0 +1,10 @@ +export interface IJurisdiction { + pk: string; + sk: string; + + jurisdictionName: string; + postalAbbreviation: string; + compact: string; + jurisdictionOperationsTeamEmails: string[]; + jurisdictionAdverseActionsNotificationEmails: string[]; +} diff --git a/backend/social-work-app/lambdas/nodejs/package.json b/backend/social-work-app/lambdas/nodejs/package.json new file mode 100644 index 0000000000..54a7d170b8 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/package.json @@ -0,0 +1,56 @@ +{ + "name": "compact-connect", + "version": "1.0.0", + "type": "commonjs", + "description": "NodeJS lambdas for CompactConnect", + "resolutions": { + "fast-xml-parser": "5.7.3", + "postcss": "8.5.10" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "lint:fix": "eslint '**/*.ts' --fix", + "lint": "eslint '**/*.ts'", + "test": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" jest --coverage", + "audit:dependencies": "/bin/bash -c 'yarn audit --groups dependencies --level moderate; [[ $? -ge 4 ]] && exit 1 || exit 0'" + }, + "author": "Inspiring Apps", + "license": "UNLICENSED", + "devDependencies": { + "@types/aws-lambda": "8.10.161", + "@types/jest": "^29.5.12", + "@types/node": "25.6.0", + "@types/nodemailer": "^8.0.0", + "@types/react": "^18.3.12", + "@typescript-eslint/eslint-plugin": "^8.58.1", + "@typescript-eslint/parser": "^8.58.1", + "aws-sdk-client-mock": "^4.1.0", + "aws-sdk-client-mock-jest": "^4.1.0", + "chai": "^4.1.2", + "chai-match-pattern": "^1.1.0", + "chalk": "^4.1.2", + "esbuild": "0.28.0", + "eslint": "10.2.1", + "jest": "^29.7.0", + "lambda-local": "^2.2.0", + "mocha": "^11.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "ts-jest": "^29.4.9", + "ts-node": "^10.9.2", + "tslib": "^2.8.0", + "tsx": "^4.19.2", + "typescript": "6.0.3" + }, + "dependencies": { + "@aws-lambda-powertools/logger": "^2.32.0", + "@aws-sdk/client-dynamodb": "^3.1045.0", + "@aws-sdk/client-s3": "^3.1045.0", + "@aws-sdk/client-sesv2": "^3.1045.0", + "@aws-sdk/util-dynamodb": "^3.996.2", + "@csg-org/email-builder": "^0.0.13", + "nodemailer": "^8.0.5", + "zod": "^3.23.8" + } +} diff --git a/backend/social-work-app/lambdas/nodejs/tests/cognito-emails.test.ts b/backend/social-work-app/lambdas/nodejs/tests/cognito-emails.test.ts new file mode 100644 index 0000000000..a610f12a03 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/cognito-emails.test.ts @@ -0,0 +1,126 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { Lambda } from '../cognito-emails/lambda'; +import { describe, it, beforeAll, beforeEach, jest } from '@jest/globals'; + +const SAMPLE_COGNITO_EVENT = { + version: '1', + triggerSource: 'CustomMessage_AdminCreateUser', + region: 'us-east-1', + userPoolId: 'us-east-1_123456789', + userName: 'testuser', + callerContext: { + awsSdkVersion: '1.0', + clientId: 'test-client-id' + }, + request: { + userAttributes: { + email: 'test@example.com' + }, + codeParameter: 'TEST-CODE-123', + usernameParameter: 'testuser', + clientMetadata: {} + }, + response: { + smsMessage: 'unchanged', + emailMessage: 'unchanged', + emailSubject: 'unchanged' + } +}; + +const SAMPLE_CONTEXT = { + callbackWaitsForEmptyEventLoop: true, + functionVersion: '$LATEST', + functionName: 'cognito-emails-function', + memoryLimitInMB: '128', + logGroupName: '/aws/lambda/cognito-emails-function', + logStreamName: '2024/03/09/[$LATEST]abcdef123456', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:cognito-emails-function', + awsRequestId: 'c6af9ac6-7b61-11e6-9a41-93e812345678', + getRemainingTimeInMillis: () => 1234, + done: () => console.log('Done!'), + fail: () => console.log('Failed!'), + succeed: () => console.log('Succeeded!') +}; + +/* + * Double casting to allow us to pass a mock in for the real thing + */ +const asDynamoDBClient = (mock: ReturnType) => + mock as unknown as DynamoDBClient; +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +describe('CognitoEmailsLambda', () => { + let lambda: Lambda; + let mockDynamoDBClient: ReturnType; + let mockSESClient: ReturnType; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.COMPACT_CONFIGURATION_TABLE_NAME = 'compact-table'; + process.env.AWS_REGION = 'us-east-1'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDynamoDBClient = mockClient(DynamoDBClient); + mockSESClient = mockClient(SESv2Client); + + // Reset environment variables + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + }); + + it('should process AdminCreateUser event for staff users', async () => { + process.env.USER_POOL_TYPE = 'staff'; + + const response = await lambda.handler(SAMPLE_COGNITO_EVENT, SAMPLE_CONTEXT); + + expect(response.response.emailSubject).toBe('Welcome to CompactConnect'); + expect(response.response.emailMessage).toContain('Your temporary password is:'); + expect(response.response.emailMessage).toContain('TEST-CODE-123'); + expect(response.response.emailMessage).toContain('Your username is:'); + expect(response.response.emailMessage).toContain('testuser'); + expect(response.response.emailMessage).toContain('Please immediately'); + expect(response.response.emailMessage).toContain('and change your password when prompted'); + }); + + it('should handle missing code parameter', async () => { + process.env.USER_POOL_TYPE = 'staff'; + + const eventWithoutCode = { + ...SAMPLE_COGNITO_EVENT, + request: { + ...SAMPLE_COGNITO_EVENT.request, + codeParameter: undefined + } + }; + + const response = await lambda.handler(eventWithoutCode, SAMPLE_CONTEXT); + + expect(response.response.emailSubject).toBe('Welcome to CompactConnect'); + expect(response.response.emailMessage).toContain('Your temporary password is:'); + expect(response.response.emailMessage).toContain('Your username is:'); + expect(response.response.emailMessage).toContain('testuser'); + expect(response.response.emailMessage).toContain('Please immediately'); + }); + + it('should handle unsupported trigger source', async () => { + const unsupportedEvent = { + ...SAMPLE_COGNITO_EVENT, + triggerSource: 'UnsupportedTrigger' + }; + + await expect(lambda.handler(unsupportedEvent, SAMPLE_CONTEXT)) + .rejects + .toThrow('Unsupported Cognito trigger source: UnsupportedTrigger'); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts new file mode 100644 index 0000000000..fa9886abf5 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts @@ -0,0 +1,1171 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; +import { Lambda } from '../email-notification-service/lambda'; +import { EmailNotificationEvent } from '../lib/models/email-notification-service-event'; +import { describe, it, beforeAll, beforeEach, jest } from '@jest/globals'; + +const SAMPLE_EVENT: EmailNotificationEvent = { + template: 'licenseEncumbranceProviderNotification', + recipientType: 'SPECIFIC', + compact: 'socw', + specificEmails: ['provider@example.com'], + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + encumberedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveStartDate: 'January 15, 2024' + } +}; + +const SAMPLE_COMPACT_CONFIGURATION = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#CONFIGURATION' }, + 'compactAdverseActionsNotificationEmails': { L: [{ S: 'adverse@example.com' }]}, + 'compactCommissionFee': { + M: { + 'feeAmount': { N: '3.5' }, + 'feeType': { S: 'FLAT_RATE' } + } + }, + 'compactAbbr': { S: 'socw' }, + 'compactName': { S: 'Audiology and Speech Language Pathology' }, + 'compactOperationsTeamEmails': { L: [{ S: 'operations@example.com' }]}, + 'dateOfUpdate': { S: '2024-12-10T19:27:28+00:00' }, + 'type': { S: 'compact' } +}; + +const SAMPLE_JURISDICTION_CONFIGURATION = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#oh' }, + 'jurisdictionName': { S: 'Ohio' }, + 'type': { S: 'jurisdiction' } +}; + +/* + * Double casting to allow us to pass a mock in for the real thing + */ +const asDynamoDBClient = (mock: ReturnType) => + mock as unknown as DynamoDBClient; + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +describe('EmailNotificationServiceLambda', () => { + let lambda: Lambda; + let mockDynamoDBClient: ReturnType; + let mockSESClient: ReturnType; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.COMPACT_CONFIGURATION_TABLE_NAME = 'compact-table'; + process.env.AWS_REGION = 'us-east-1'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDynamoDBClient = mockClient(DynamoDBClient); + mockSESClient = mockClient(SESv2Client); + + // Reset environment variables + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + process.env.TRANSACTION_REPORTS_BUCKET_NAME = 'test-transaction-reports-bucket'; + + // Set up default successful responses + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + const key = input.Key; + + if (key.sk.S === 'socw#CONFIGURATION') { + return Promise.resolve({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + } else if (key.sk.S === 'socw#JURISDICTION#oh') { + return Promise.resolve({ + Item: SAMPLE_JURISDICTION_CONFIGURATION + }); + } + return Promise.resolve({}); + }); + + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'message-id-123' + }); + + // Note: SESv2 with nodemailer 7.0.7 uses SendEmailCommand for all email sending + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + }); + + it('should return early when FROM_ADDRESS is NONE', async () => { + process.env.FROM_ADDRESS = 'NONE'; + + const response = await lambda.handler(SAMPLE_EVENT, {} as any); + + expect(response).toEqual({ + message: 'No from address configured for environment, unable to send email' + }); + + // Verify no calls were made to DynamoDB or SES + expect(mockDynamoDBClient).not.toHaveReceivedAnyCommand(); + expect(mockSESClient).not.toHaveReceivedAnyCommand(); + }); + + it('should throw error for unsupported template', async () => { + const event: EmailNotificationEvent = { + ...SAMPLE_EVENT, + template: 'unsupportedTemplate' + }; + + await expect(lambda.handler(event, {} as any)) + .rejects + .toThrow('Unsupported email template: unsupportedTemplate'); + + // Verify no AWS calls were made + expect(mockDynamoDBClient).not.toHaveReceivedAnyCommand(); + expect(mockSESClient).not.toHaveReceivedAnyCommand(); + }); + + describe('License Encumbrance Provider Notification', () => { + const SAMPLE_LICENSE_ENCUMBRANCE_PROVIDER_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'licenseEncumbranceProviderNotification', + recipientType: 'SPECIFIC', + compact: 'socw', + specificEmails: ['provider@example.com'], + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + encumberedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveStartDate: 'January 15, 2024' + } + }; + + it('should successfully send license encumbrance provider notification email', async () => { + const response = await lambda.handler(SAMPLE_LICENSE_ENCUMBRANCE_PROVIDER_NOTIFICATION_EVENT, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist license in Ohio is encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist license in Ohio is encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_LICENSE_ENCUMBRANCE_PROVIDER_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for licenseEncumbranceProviderNotification template.'); + }); + }); + + describe('License Encumbrance State Notification', () => { + const SAMPLE_LICENSE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'licenseEncumbranceStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + encumberedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveStartDate: 'January 15, 2024' + } + }; + + it('should successfully send license encumbrance state notification email', async () => { + const mockJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionName': { S: 'California' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler(SAMPLE_LICENSE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('License Encumbrance Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Encumbrance Notification - John Doe' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should include provider detail link with correct environment URL', async () => { + const mockJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + await lambda.handler(SAMPLE_LICENSE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT, {} as any); + + const emailData = mockSESClient.commandCalls( + SendEmailCommand)[0].args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(emailData).toContain('Provider Details: https://app.test.compactconnect.org/socw/Licensing/provider-123'); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_LICENSE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for licenseEncumbranceStateNotification template.'); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_LICENSE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('Missing required jurisdiction field for licenseEncumbranceStateNotification template.'); + }); + }); + + describe('License Encumbrance Lifting Provider Notification', () => { + const SAMPLE_LICENSE_ENCUMBRANCE_LIFTING_PROVIDER_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'licenseEncumbranceLiftingProviderNotification', + recipientType: 'SPECIFIC', + compact: 'socw', + specificEmails: ['provider@example.com'], + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + liftedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveLiftDate: 'February 15, 2024' + } + }; + + it('should successfully send license encumbrance lifting provider notification email', async () => { + const response = await lambda.handler( + SAMPLE_LICENSE_ENCUMBRANCE_LIFTING_PROVIDER_NOTIFICATION_EVENT, {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist license in Ohio is no longer encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist license in Ohio is no longer encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_LICENSE_ENCUMBRANCE_LIFTING_PROVIDER_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for licenseEncumbranceLiftingProviderNotification template.'); + }); + }); + + describe('License Encumbrance Lifting State Notification', () => { + const SAMPLE_LICENSE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'licenseEncumbranceLiftingStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + liftedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveLiftDate: 'February 15, 2024' + } + }; + + it('should successfully send license encumbrance lifting state notification email', async () => { + const mockJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler( + SAMPLE_LICENSE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT, {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('License Encumbrance Lifted Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Encumbrance Lifted Notification - John Doe' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_LICENSE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for licenseEncumbranceLiftingStateNotification template.'); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_LICENSE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('Missing required jurisdiction field for licenseEncumbranceLiftingStateNotification template.'); + }); + }); + + describe('Privilege Encumbrance Provider Notification', () => { + const SAMPLE_PRIVILEGE_ENCUMBRANCE_PROVIDER_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'privilegeEncumbranceProviderNotification', + recipientType: 'SPECIFIC', + compact: 'socw', + specificEmails: ['provider@example.com'], + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + encumberedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveStartDate: 'January 15, 2024' + } + }; + + it('should successfully send privilege encumbrance provider notification email', async () => { + const response = await lambda.handler(SAMPLE_PRIVILEGE_ENCUMBRANCE_PROVIDER_NOTIFICATION_EVENT, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist privilege in Ohio is encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist privilege in Ohio is encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_ENCUMBRANCE_PROVIDER_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for privilegeEncumbranceProviderNotification template.'); + }); + }); + + describe('Privilege Encumbrance State Notification', () => { + const SAMPLE_PRIVILEGE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'privilegeEncumbranceStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + encumberedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveStartDate: 'January 15, 2024' + } + }; + + it('should successfully send privilege encumbrance state notification email', async () => { + const mockJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler(SAMPLE_PRIVILEGE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Privilege Encumbrance Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Privilege Encumbrance Notification - John Doe' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for privilegeEncumbranceStateNotification template.'); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_ENCUMBRANCE_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('Missing required jurisdiction field for privilegeEncumbranceStateNotification template.'); + }); + }); + + describe('Privilege Encumbrance Lifting Provider Notification', () => { + const SAMPLE_PRIVILEGE_ENCUMBRANCE_LIFTING_PROVIDER_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'privilegeEncumbranceLiftingProviderNotification', + recipientType: 'SPECIFIC', + compact: 'socw', + specificEmails: ['provider@example.com'], + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + liftedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveLiftDate: 'February 15, 2024' + } + }; + + it('should successfully send privilege encumbrance lifting provider notification email', async () => { + const response = await lambda.handler( + SAMPLE_PRIVILEGE_ENCUMBRANCE_LIFTING_PROVIDER_NOTIFICATION_EVENT, {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist privilege in Ohio is no longer encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist privilege in Ohio is no longer encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_ENCUMBRANCE_LIFTING_PROVIDER_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for privilegeEncumbranceLiftingProviderNotification template.'); + }); + }); + + describe('Privilege Encumbrance Lifting State Notification', () => { + const SAMPLE_PRIVILEGE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'privilegeEncumbranceLiftingStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + liftedJurisdiction: 'OH', + licenseType: 'Audiologist', + effectiveLiftDate: 'February 15, 2024' + } + }; + + it('should successfully send privilege encumbrance lifting state notification email', async () => { + const mockJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler( + SAMPLE_PRIVILEGE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT, {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Privilege Encumbrance Lifted Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Privilege Encumbrance Lifted Notification - John Doe' + } + }}, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for privilegeEncumbranceLiftingStateNotification template.'); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_ENCUMBRANCE_LIFTING_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('Missing required jurisdiction field for privilegeEncumbranceLiftingStateNotification template.'); + }); + }); + + describe('License Investigation State Notification', () => { + const SAMPLE_LICENSE_INVESTIGATION_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'licenseInvestigationStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + investigationJurisdiction: 'OH', + licenseType: 'Audiologist' + } + }; + + it('should successfully send license investigation state notification email', async () => { + const mockCaJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + const mockOhJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#oh' }, + 'jurisdictionName': { S: 'Ohio' }, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockCaJurisdictionConfig }); + } else if (input.Key.sk.S === 'socw#JURISDICTION#oh') { + return Promise.resolve({ Item: mockOhJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler(SAMPLE_LICENSE_INVESTIGATION_STATE_NOTIFICATION_EVENT, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('John Doe holding Audiologist license in Ohio is under investigation') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'John Doe holding Audiologist license in Ohio is under investigation' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_LICENSE_INVESTIGATION_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('No jurisdiction provided for license investigation state notification email'); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_LICENSE_INVESTIGATION_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for licenseInvestigationStateNotification template.'); + }); + }); + + describe('License Investigation Closed State Notification', () => { + const SAMPLE_LICENSE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'licenseInvestigationClosedStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + investigationJurisdiction: 'OH', + licenseType: 'Audiologist' + } + }; + + it('should successfully send license investigation closed state notification email', async () => { + const mockCaJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + const mockOhJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#oh' }, + 'jurisdictionName': { S: 'Ohio' }, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockCaJurisdictionConfig }); + } else if (input.Key.sk.S === 'socw#JURISDICTION#oh') { + return Promise.resolve({ Item: mockOhJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler( + SAMPLE_LICENSE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT, {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Investigation on John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: expect.stringMatching(/Investigation on John Doe.s Audiologist license in Ohio has been closed/) + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_LICENSE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('No jurisdiction provided for license investigation closed state notification email'); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_LICENSE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for licenseInvestigationClosedStateNotification template.'); + }); + }); + + describe('Privilege Investigation State Notification', () => { + const SAMPLE_PRIVILEGE_INVESTIGATION_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'privilegeInvestigationStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + investigationJurisdiction: 'OH', + licenseType: 'Audiologist' + } + }; + + it('should successfully send privilege investigation state notification email', async () => { + const mockCaJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + const mockOhJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#oh' }, + 'jurisdictionName': { S: 'Ohio' }, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockCaJurisdictionConfig }); + } else if (input.Key.sk.S === 'socw#JURISDICTION#oh') { + return Promise.resolve({ Item: mockOhJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler(SAMPLE_PRIVILEGE_INVESTIGATION_STATE_NOTIFICATION_EVENT, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('John Doe holding Audiologist privilege in Ohio is under investigation') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'John Doe holding Audiologist privilege in Ohio is under investigation' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_INVESTIGATION_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('No jurisdiction provided for privilege investigation state notification email'); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_INVESTIGATION_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for privilegeInvestigationStateNotification template.'); + }); + }); + + describe('Privilege Investigation Closed State Notification', () => { + const SAMPLE_PRIVILEGE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'privilegeInvestigationClosedStateNotification', + recipientType: 'JURISDICTION_ADVERSE_ACTIONS', + compact: 'socw', + jurisdiction: 'ca', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + investigationJurisdiction: 'OH', + licenseType: 'Audiologist' + } + }; + + it('should successfully send privilege investigation closed state notification email', async () => { + const mockCaJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#ca' }, + 'jurisdictionAdverseActionsNotificationEmails': { L: [{ S: 'ca-adverse@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + const mockOhJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#oh' }, + 'jurisdictionName': { S: 'Ohio' }, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + if (input.Key.sk.S === 'socw#JURISDICTION#ca') { + return Promise.resolve({ Item: mockCaJurisdictionConfig }); + } else if (input.Key.sk.S === 'socw#JURISDICTION#oh') { + return Promise.resolve({ Item: mockOhJurisdictionConfig }); + } + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + }); + + const response = await lambda.handler( + SAMPLE_PRIVILEGE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT, {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['ca-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Investigation on John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: expect.stringMatching(/Investigation on John Doe.s Audiologist privilege in Ohio has been closed/) + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when jurisdiction is missing', async () => { + const eventWithMissingJurisdiction: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT, + jurisdiction: undefined + }; + + await expect(lambda.handler(eventWithMissingJurisdiction, {} as any)) + .rejects + .toThrow('No jurisdiction provided for privilege investigation closed state notification email'); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_PRIVILEGE_INVESTIGATION_CLOSED_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for privilegeInvestigationClosedStateNotification template.'); + }); + }); + + describe('Home Jurisdiction Change New State Notification', () => { + const SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'homeJurisdictionChangeNotification', + recipientType: 'JURISDICTION_OPERATIONS_TEAM', + compact: 'socw', + jurisdiction: 'tx', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + previousJurisdiction: 'tx', + newJurisdiction: 'oh' + } + }; + + it('should successfully send home jurisdiction change notification email', async () => { + const mockTxJurisdictionConfig = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#tx' }, + 'jurisdictionName': { S: 'Texas' }, + 'jurisdictionOperationsTeamEmails': { L: [{ S: 'tx-ops@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + const sk = input.Key.sk.S; + + if (sk === 'socw#JURISDICTION#tx') { + return Promise.resolve({ Item: mockTxJurisdictionConfig }); + } + if (sk === 'socw#CONFIGURATION') { + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + } + return Promise.resolve({}); + }); + + const response = await lambda.handler( + SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT, + {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockDynamoDBClient).toHaveReceivedCommand(GetItemCommand); + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['tx-ops@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Practitioner Home State Change - Audiology and Speech Language Pathology' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain( + 'This is to notify you that John Doe has changed their home state from TX to OH.' + ); + expect(htmlContent).toContain( + 'https://app.test.compactconnect.org/socw/Licensing/provider-123' + ); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for home jurisdiction change notification template.'); + }); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/ingest-event-reporter.test.ts b/backend/social-work-app/lambdas/nodejs/tests/ingest-event-reporter.test.ts new file mode 100644 index 0000000000..7bc5b4bd1a --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/ingest-event-reporter.test.ts @@ -0,0 +1,513 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Context } from 'aws-lambda'; +import { DynamoDBClient, QueryCommand, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; + +import { Lambda } from '../ingest-event-reporter/lambda'; +import { IEventBridgeEvent } from '../lib/models/event-bridge-event-detail'; +import { + SAMPLE_INGEST_FAILURE_ERROR_RECORD, + SAMPLE_JURISDICTION_CONFIGURATION, + SAMPLE_VALIDATION_ERROR_RECORD, + SAMPLE_INGEST_SUCCESS_RECORD, + SAMPLE_COMPACT_CONFIGURATION +} from './sample-records'; + + + +const SAMPLE_NIGHTLY_EVENT: IEventBridgeEvent = { + 'eventType': 'nightly' +}; + +const SAMPLE_WEEKLY_EVENT: IEventBridgeEvent = { + 'eventType': 'weekly' +}; + + +const SAMPLE_CONTEXT: Context = { + callbackWaitsForEmptyEventLoop: true, + functionVersion: '$LATEST', + functionName: 'foo-bar-function', + memoryLimitInMB: '128', + logGroupName: '/aws/lambda/foo-bar-function-123456abcdef', + logStreamName: '2021/03/09/[$LATEST]abcdef123456abcdef123456abcdef123456', + invokedFunctionArn: + 'arn:aws:lambda:eu-west-1:123456789012:function:foo-bar-function', + awsRequestId: 'c6af9ac6-7b61-11e6-9a41-93e812345678', + getRemainingTimeInMillis: () => 1234, + done: () => console.log('Done!'), + fail: () => console.log('Failed!'), + succeed: () => console.log('Succeeded!'), +}; + +/* + * Double casting to allow us to pass a mock in for the real thing + */ +const asDynamoDBClient = (mock: ReturnType) => + mock as unknown as DynamoDBClient; +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +jest.mock('../lib/email/ingest-event-email-service', () => { + return { + IngestEventEmailService: jest.fn().mockImplementation(() => ({ + sendReportEmail: mockSendReportEmail, + sendAllsWellEmail: mockSendAllsWellEmail, + sendNoLicenseUpdatesEmail: mockSendNoLicenseUpdatesEmail + })) + }; +}); + +const mockSendReportEmail = jest.fn().mockImplementation( + (_events, _recipients: string[]) => Promise.resolve('message-id-123') +); +const mockSendAllsWellEmail = jest.fn().mockImplementation( + (_recipients: string[]) => Promise.resolve('message-id-123') +); +const mockSendNoLicenseUpdatesEmail = jest.fn().mockImplementation( + (_recipients: string[]) => Promise.resolve('message-id-no-license-updates') +); + +describe('Frequent runs', () => { + let mockSESClient: ReturnType; + let lambda: Lambda; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.COMPACTS = '["socw"]'; + process.env.DATA_EVENT_TABLE_NAME = 'data-table'; + process.env.COMPACT_CONFIGURATION_TABLE_NAME = 'compact-table'; + process.env.AWS_REGION = 'us-east-1'; + + // Get the mocked client instances + mockSESClient = mockClient(SESv2Client); + }); + + beforeEach(() => { + // Clear all instances and calls to constructor and all methods: + jest.clearAllMocks(); + mockSendReportEmail.mockClear(); + mockSendAllsWellEmail.mockClear(); + }); + + it('should send a report email when there were errors', async () => { + const mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(QueryCommand).callsFake((input) => { + const tableName = input.TableName; + + switch (tableName) { + case 'data-table': + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.validation-error#TIME#' + )) { + return Promise.resolve({ + Items: [SAMPLE_VALIDATION_ERROR_RECORD] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest-failure#TIME#' + )) { + return Promise.resolve({ + Items: [SAMPLE_INGEST_FAILURE_ERROR_RECORD] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest#TIME#' + )) { + return Promise.resolve({ + Items: [SAMPLE_INGEST_SUCCESS_RECORD] + }); + } + throw Error(`Unexpected query ${JSON.stringify(input)}`); + case 'compact-table': + return Promise.resolve({ + Items: [SAMPLE_JURISDICTION_CONFIGURATION] + }); + default: + throw Error(`Table does not exist: ${tableName}`); + } + }); + + // Mock GetItemCommand for compact configuration + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + + await lambda.handler( + SAMPLE_NIGHTLY_EVENT, + SAMPLE_CONTEXT + ); + + // Verify the DynamoDB client was called correctly + // To get jurisdictions + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'compact-table', + } + ); + + // To get events + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'data-table', + } + ); + + // Verify an event report was sent with correct parameters + expect(mockSendReportEmail).toHaveBeenCalledWith( + expect.objectContaining({ + ingestFailures: [expect.objectContaining({ eventType: 'license.ingest-failure' })], + validationErrors: [expect.objectContaining({ eventType: 'license.validation-error' })] + }), + 'Audiology and Speech Language Pathology', // compactName instead of abbreviation + 'Ohio', // jurisdictionName + ['justin@inspiringapps.com'] // jurisdiction operations emails + ); + expect(mockSendAllsWellEmail).not.toHaveBeenCalled(); + }); + + it('should not send an email if there were no ingest events', async () => { + const mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(QueryCommand).callsFake((input) => { + const tableName = input.TableName; + + switch (tableName) { + case 'data-table': + return Promise.resolve({ + Items: [] + }); + case 'compact-table': + return Promise.resolve({ + Items: [SAMPLE_JURISDICTION_CONFIGURATION] + }); + default: + throw Error(`Table does not exist: ${tableName}`); + } + }); + + // Mock GetItemCommand for compact configuration + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + + await lambda.handler( + SAMPLE_NIGHTLY_EVENT, + SAMPLE_CONTEXT + ); + + // Verify the DynamoDB client was called correctly + // To get jurisdictions + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'compact-table', + } + ); + + // To get events + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'data-table', + } + ); + + // Verify no emails were sent + expect(mockSendReportEmail).not.toHaveBeenCalled(); + expect(mockSendAllsWellEmail).not.toHaveBeenCalled(); + expect(mockSendNoLicenseUpdatesEmail).not.toHaveBeenCalled(); + }); + + it('should let DynamoDB errors escape', async () => { + const mockDynamoDBClient = mockClient(DynamoDBClient); + + // Mock GetItemCommand to succeed (compact config found) + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + // Mock QueryCommand to fail (jurisdictions query fails) + mockDynamoDBClient.on(QueryCommand).rejects(new Error('DynamoDB error')); + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + + // Expect the function to throw or handle the error appropriately + await expect(lambda.handler( + SAMPLE_NIGHTLY_EVENT, + SAMPLE_CONTEXT + )).rejects.toThrow('DynamoDB error'); + }); + + it('should skip compact and continue processing when compact configuration is not found', async () => { + const mockDynamoDBClient = mockClient(DynamoDBClient); + + // Mock GetItemCommand to reject with "not found" error for compact configuration + mockDynamoDBClient.on(GetItemCommand).rejects(new Error('No configuration found for compact: cosm')); + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + + // Should not throw an error, should complete successfully + await expect(lambda.handler( + SAMPLE_NIGHTLY_EVENT, + SAMPLE_CONTEXT + )).resolves.toBeUndefined(); + + // Verify no emails were sent since we skipped all compacts + expect(mockSendReportEmail).not.toHaveBeenCalled(); + expect(mockSendAllsWellEmail).not.toHaveBeenCalled(); + expect(mockSendNoLicenseUpdatesEmail).not.toHaveBeenCalled(); + }); +}); + + +describe('Weekly runs', () => { + let mockSESClient: ReturnType; + let lambda: Lambda; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.COMPACTS = '["socw"]'; + process.env.DATA_EVENT_TABLE_NAME = 'data-table'; + process.env.COMPACT_CONFIGURATION_TABLE_NAME = 'compact-table'; + process.env.AWS_REGION = 'us-east-1'; + }); + + beforeEach(() => { + // Clear all instances and calls to constructor and all methods: + jest.clearAllMocks(); + + // Get the mocked client instances + mockSESClient = mockClient(SESv2Client); + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'foo-123' + }); + }); + + + it('should send an "All\'s Well" email if there were success events without failures', async () => { + const mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(QueryCommand).callsFake((input) => { + const tableName = input.TableName; + + switch (tableName) { + case 'data-table': + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.validation-error#TIME#' + )) { + return Promise.resolve({ + Items: [] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest-failure#TIME#' + )) { + return Promise.resolve({ + Items: [] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest#TIME#' + )) { + return Promise.resolve({ + Items: [SAMPLE_INGEST_SUCCESS_RECORD] + }); + } + throw Error(`Unexpected query ${JSON.stringify(input)}`); + case 'compact-table': + return Promise.resolve({ + Items: [SAMPLE_JURISDICTION_CONFIGURATION] + }); + default: + throw Error(`Table does not exist: ${tableName}`); + } + }); + + // Mock GetItemCommand for compact configuration + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + + await lambda.handler( + SAMPLE_WEEKLY_EVENT, + SAMPLE_CONTEXT + ); + + // Verify the DynamoDB client was called correctly + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'data-table', + } + ); + + // Verify an "All's Well" email was sent with correct parameters + expect(mockSendReportEmail).not.toHaveBeenCalled(); + expect(mockSendAllsWellEmail).toHaveBeenCalledWith( + 'Audiology and Speech Language Pathology', // compactName instead of abbreviation + 'Ohio', // jurisdictionName + ['justin@inspiringapps.com'] // jurisdiction operations emails + ); + expect(mockSendNoLicenseUpdatesEmail).not.toHaveBeenCalled(); + }); + + it('should send "no license updates" email if there were no events', async () => { + const mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(QueryCommand).callsFake((input) => { + const tableName = input.TableName; + + switch (tableName) { + case 'data-table': + return Promise.resolve({ + // No ingest events + Items: [] + }); + case 'compact-table': + return Promise.resolve({ + Items: [SAMPLE_JURISDICTION_CONFIGURATION] + }); + default: + throw Error(`Table does not exist: ${tableName}`); + } + }); + + // Mock GetItemCommand for compact configuration + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + + await lambda.handler( + SAMPLE_WEEKLY_EVENT, + SAMPLE_CONTEXT + ); + + // Verify the DynamoDB client was called correctly + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'data-table', + } + ); + + // Verify the compact configuration was fetched + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + GetItemCommand, + { + TableName: 'compact-table', + Key: { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#CONFIGURATION' } + } + } + ); + + // Verify no license updates email was sent with correct parameters + expect(mockSendReportEmail).not.toHaveBeenCalled(); + expect(mockSendAllsWellEmail).not.toHaveBeenCalled(); + expect(mockSendNoLicenseUpdatesEmail).toHaveBeenCalledWith( + 'Audiology and Speech Language Pathology', // compactName instead of abbreviation + 'Ohio', // jurisdictionName + ['justin@inspiringapps.com', 'compact-ops@example.com'] // combined jurisdiction and compact operations emails + ); + }); + + it('should send nothing, when there were errors', async () => { + const mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(QueryCommand).callsFake((input) => { + const tableName = input.TableName; + + switch (tableName) { + case 'data-table': + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.validation-error#TIME#' + )) { + return Promise.resolve({ + Items: [SAMPLE_VALIDATION_ERROR_RECORD] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest-failure#TIME#' + )) { + return Promise.resolve({ + Items: [SAMPLE_INGEST_FAILURE_ERROR_RECORD] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest#TIME#' + )) { + return Promise.resolve({ + Items: [SAMPLE_INGEST_SUCCESS_RECORD] + }); + } + throw Error(`Unexpected query ${JSON.stringify(input)}`); + case 'compact-table': + return Promise.resolve({ + Items: [SAMPLE_JURISDICTION_CONFIGURATION] + }); + default: + throw Error(`Table does not exist: ${tableName}`); + } + }); + + // Mock GetItemCommand for compact configuration + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + lambda = new Lambda({ + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient), + sesClient: asSESClient(mockSESClient) + }); + + await lambda.handler( + SAMPLE_WEEKLY_EVENT, + SAMPLE_CONTEXT + ); + + // Verify the DynamoDB client was called correctly + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'data-table', + } + ); + + // Verify an event report was not sent + expect(mockSendReportEmail).not.toHaveBeenCalled(); + expect(mockSendAllsWellEmail).not.toHaveBeenCalled(); + expect(mockSendNoLicenseUpdatesEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/jest.setup.ts b/backend/social-work-app/lambdas/nodejs/tests/jest.setup.ts new file mode 100644 index 0000000000..1c42717e88 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/jest.setup.ts @@ -0,0 +1,7 @@ +// Jest setup file for aws-sdk-client-mock-jest matchers +// This file registers the custom matchers like toHaveReceivedCommandWith globally + +// Triple-slash reference to include the Jest matcher type augmentations +/// + +import 'aws-sdk-client-mock-jest'; diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/compact-configuration-client.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/compact-configuration-client.test.ts new file mode 100644 index 0000000000..dacff4bd34 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/compact-configuration-client.test.ts @@ -0,0 +1,98 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { CompactConfigurationClient } from '../../lib/compact-configuration-client'; + +const SAMPLE_COMPACT_CONFIGURATION = { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#CONFIGURATION' }, + 'compactAdverseActionsNotificationEmails': { L: [{ S: 'adverse@example.com' }]}, + 'compactCommissionFee': { + M: { + 'feeAmount': { N: '3.5' }, + 'feeType': { S: 'FLAT_RATE' } + } + }, + 'compactAbbr': { S: 'socw' }, + 'compactName': { S: 'Audiology and Speech Language Pathology' }, + 'compactOperationsTeamEmails': { L: [{ S: 'operations@example.com' }]}, + 'dateOfUpdate': { S: '2024-12-10T19:27:28+00:00' }, + 'type': { S: 'compact' } +}; + +/* + * Double casting to allow us to pass a mock in for the real thing + */ +const asDynamoDBClient = (mock: ReturnType) => + mock as unknown as DynamoDBClient; + +describe('CompactConfigurationClient', () => { + let compactConfigurationClient: CompactConfigurationClient; + let mockDynamoDBClient: ReturnType; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.COMPACT_CONFIGURATION_TABLE_NAME = 'compact-table'; + process.env.AWS_REGION = 'us-east-1'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockDynamoDBClient = mockClient(DynamoDBClient); + }); + + it('should return compact configuration from DynamoDB', async () => { + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + compactConfigurationClient = new CompactConfigurationClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const config = await compactConfigurationClient.getCompactConfiguration('socw'); + + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + GetItemCommand, + { + TableName: 'compact-table', + Key: { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#CONFIGURATION' } + } + } + ); + + expect(config).toEqual({ + pk: 'socw#CONFIGURATION', + sk: 'socw#CONFIGURATION', + compactAdverseActionsNotificationEmails: ['adverse@example.com'], + compactCommissionFee: { + feeAmount: 3.5, + feeType: 'FLAT_RATE' + }, + compactAbbr: 'socw', + compactName: 'Audiology and Speech Language Pathology', + compactOperationsTeamEmails: ['operations@example.com'], + dateOfUpdate: '2024-12-10T19:27:28+00:00', + type: 'compact' + }); + }); + + it('should throw error when no configuration found', async () => { + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: undefined + }); + + compactConfigurationClient = new CompactConfigurationClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + await expect(compactConfigurationClient.getCompactConfiguration('invalid')) + .rejects + .toThrow('No configuration found for compact: invalid'); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/base-email-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/base-email-service.test.ts new file mode 100644 index 0000000000..d2e77f4f2a --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/base-email-service.test.ts @@ -0,0 +1,108 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { BaseEmailService } from '../../../lib/email/base-email-service'; +import { describe, it, beforeEach, jest } from '@jest/globals'; + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +// Create a concrete test implementation of BaseEmailService +class TestEmailService extends BaseEmailService { + public generateTestEmail(): string { + const template = this.getNewEmailTemplate(); + + this.insertHeader(template, 'Test Email'); + this.insertFooter(template); + + // Return the template for inspection + return JSON.stringify(template); + } +} + +describe('BaseEmailService Environment Banner', () => { + let emailService: TestEmailService; + let mockSESClient: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + mockSESClient = mockClient(SESv2Client); + + // Reset environment variables + delete process.env.ENVIRONMENT_NAME; + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + + emailService = new TestEmailService({ + logger: new Logger({ serviceName: 'test' }), + sesClient: asSESClient(mockSESClient), + compactConfigurationClient: {} as any, + jurisdictionClient: {} as any + }); + }); + + // Helper methods to reduce test duplication + const expectBannerPresent = (template: any) => { + const childrenIds = template.root.data.childrenIds; + + expect(childrenIds.length).toBeGreaterThan(0); + + // Find banner block (should be first element) + const bannerBlockId = childrenIds[0]; + const bannerBlock = template[bannerBlockId]; + + expect(bannerBlock).toBeDefined(); + expect(bannerBlock.type).toBe('Text'); + expect(bannerBlock.data.style.backgroundColor).toBe('#FFA726'); + expect(bannerBlock.data.style.color).toBe('#000000'); + expect(bannerBlock.data.props.text).toContain('⚠️ TEST: The info in this email is from a testing environment'); + }; + + const expectFooterPresent = (template: any) => { + const childrenIds = template.root.data.childrenIds; + + // Find footer warning block (should be last element) + const footerWarningBlockId = childrenIds[childrenIds.length - 1]; + const footerWarningBlock = template[footerWarningBlockId]; + + expect(footerWarningBlock).toBeDefined(); + expect(footerWarningBlock.type).toBe('Text'); + expect(footerWarningBlock.data.props.text).toBe('You\'re viewing a test email.'); + }; + + const expectNoBannerOrFooter = (templateJson: string) => { + // Simply check that the banner text doesn't appear anywhere in the email content + expect(templateJson).not.toContain('⚠️ TEST: The info in this email is from a testing environment'); + expect(templateJson).not.toContain('You\'re viewing a test email.'); + }; + + const testEnvironment = (environmentName: string | undefined, shouldShowBanner: boolean, description: string) => { + it(description, () => { + // Set environment + if (environmentName === undefined) { + delete process.env.ENVIRONMENT_NAME; + } else { + process.env.ENVIRONMENT_NAME = environmentName; + } + + const templateJson = emailService.generateTestEmail(); + const template = JSON.parse(templateJson); + + if (shouldShowBanner) { + expectBannerPresent(template); + expectFooterPresent(template); + } else { + expectNoBannerOrFooter(templateJson); + } + }); + }; + + describe('Environment Banner Behavior', () => { + // Test cases: [environmentName, shouldShowBanner, description] + testEnvironment('beta', true, 'should include environment banner and footer in beta environment'); + testEnvironment('test', true, 'should include environment banner and footer in test environment'); + testEnvironment('prod', false, 'should NOT include environment banner and footer in production environment'); + testEnvironment(undefined, false, 'should NOT include environment banner and footer when environment name is undefined'); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/cognito-email-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/cognito-email-service.test.ts new file mode 100644 index 0000000000..3f0f2534d7 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/cognito-email-service.test.ts @@ -0,0 +1,160 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SESv2Client } from '@aws-sdk/client-sesv2'; +import { CognitoEmailService } from '../../../lib/email'; +import { EmailTemplateCapture } from '../../utils/email-template-capture'; +import { TReaderDocument } from '@csg-org/email-builder'; +import { describe, it, beforeEach, beforeAll, afterAll, jest } from '@jest/globals'; + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +describe('CognitoEmailService', () => { + let emailService: CognitoEmailService; + let mockSESClient: ReturnType; + + beforeAll(() => { + // Mock the renderTemplate method if template capture is enabled + if (EmailTemplateCapture.isEnabled()) { + const original = (CognitoEmailService.prototype as any).renderTemplate; + + jest.spyOn(CognitoEmailService.prototype as any, 'renderTemplate').mockImplementation(function (this: any, ...args: any[]) { + const [template, options] = args as [TReaderDocument, any]; + + EmailTemplateCapture.captureTemplate(template); + const html = original.apply(this, args); + + EmailTemplateCapture.captureHtml(html, template, options); + return html; + }); + } + }); + + afterAll(() => { + if (EmailTemplateCapture.isEnabled()) { + jest.restoreAllMocks(); + } + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockSESClient = mockClient(SESv2Client); + + // Reset environment variables + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + process.env.USER_POOL_TYPE = 'staff'; // Set default for tests + + emailService = new CognitoEmailService({ + logger: new Logger({ serviceName: 'test' }), + sesClient: asSESClient(mockSESClient), + compactConfigurationClient: {} as any, + jurisdictionClient: {} as any + }); + }); + + describe('generateCognitoMessage', () => { + describe('AdminCreateUser template', () => { + it('should generate AdminCreateUser message for staff users with immediate login message', () => { + process.env.USER_POOL_TYPE = 'staff'; + + const { subject, htmlContent } = emailService.generateCognitoMessage( + 'CustomMessage_AdminCreateUser', + '{####}', + 'testuser' + ); + + expect(subject).toBe('Welcome to CompactConnect'); + expect(htmlContent).toContain('Your temporary password is:'); + expect(htmlContent).toContain('{####}'); + expect(htmlContent).toContain('Your username is:'); + expect(htmlContent).toContain('testuser'); + expect(htmlContent).toContain('Please immediately'); + expect(htmlContent).toContain('and change your password when prompted'); + expect(htmlContent).toContain('sign in'); + }); + + it('should generate AdminCreateUser message for unknown user pool type with immediate login message', () => { + process.env.USER_POOL_TYPE = 'unknown'; + + const { subject, htmlContent } = emailService.generateCognitoMessage( + 'CustomMessage_AdminCreateUser', + '{####}', + 'testuser' + ); + + expect(subject).toBe('Welcome to CompactConnect'); + expect(htmlContent).toContain('Your temporary password is:'); + expect(htmlContent).toContain('{####}'); + expect(htmlContent).toContain('Your username is:'); + expect(htmlContent).toContain('testuser'); + expect(htmlContent).toContain('Please immediately'); + expect(htmlContent).toContain('and change your password when prompted'); + expect(htmlContent).toContain('sign in'); + }); + }); + + it('should generate ForgotPassword message', () => { + const { subject, htmlContent } = emailService.generateCognitoMessage( + 'CustomMessage_ForgotPassword', + '{####}' + ); + + expect(subject).toBe('Reset your password'); + expect(htmlContent).toContain('You requested to reset your password'); + expect(htmlContent).toContain('{####}'); + expect(htmlContent).toContain('Important: If you have lost access to your multi-factor authentication (MFA), you will need to recover your account by visiting the following link instead:'); + expect(htmlContent).toContain('https://app.test.compactconnect.org/mfarecoverystart'); + }); + + it('should generate UpdateUserAttribute message', () => { + const { subject, htmlContent } = emailService.generateCognitoMessage( + 'CustomMessage_UpdateUserAttribute', + '{####}' + ); + + expect(subject).toBe('Verify your email'); + expect(htmlContent).toContain('Please verify your new email address by entering the following code:'); + expect(htmlContent).toContain('{####}'); + }); + + it('should generate VerifyUserAttribute message', () => { + const { subject, htmlContent } = emailService.generateCognitoMessage( + 'CustomMessage_VerifyUserAttribute', + '{####}' + ); + + expect(subject).toBe('Verify your email'); + expect(htmlContent).toContain('Please verify your email address by entering the following code:'); + expect(htmlContent).toContain('{####}'); + }); + + it('should generate ResendCode message', () => { + const { subject, htmlContent } = emailService.generateCognitoMessage( + 'CustomMessage_ResendCode', + '{####}' + ); + + expect(subject).toBe('New verification code for CompactConnect'); + expect(htmlContent).toContain('Your new verification code is:'); + expect(htmlContent).toContain('{####}'); + }); + + it('should generate SignUp message', () => { + const { subject, htmlContent } = emailService.generateCognitoMessage( + 'CustomMessage_SignUp', + '{####}' + ); + + expect(subject).toBe('Welcome to CompactConnect'); + expect(htmlContent).toContain('Please verify your email address by entering the following code:'); + expect(htmlContent).toContain('{####}'); + }); + + it('should throw error for unsupported trigger source', () => { + expect(() => emailService.generateCognitoMessage('UnsupportedTrigger')) + .toThrow('Unsupported Cognito trigger source: UnsupportedTrigger'); + }); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts new file mode 100644 index 0000000000..b61f4c6331 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts @@ -0,0 +1,204 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; +import * as nodemailer from 'nodemailer'; +import { EmailNotificationService } from '../../../lib/email'; +import { CompactConfigurationClient } from '../../../lib/compact-configuration-client'; +import { JurisdictionClient } from '../../../lib/jurisdiction-client'; +import { EmailTemplateCapture } from '../../utils/email-template-capture'; +import { TReaderDocument } from '@csg-org/email-builder'; +import { describe, it, beforeEach, beforeAll, afterAll, jest } from '@jest/globals'; + +jest.mock('nodemailer'); + +const SAMPLE_COMPACT_CONFIG = { + pk: 'aslp#CONFIGURATION', + sk: 'aslp#CONFIGURATION', + compactAdverseActionsNotificationEmails: ['adverse@example.com'], + compactCommissionFee: { + feeAmount: 3.5, + feeType: 'FLAT_RATE' + }, + compactAbbr: 'aslp', + compactName: 'Audiology and Speech Language Pathology', + compactOperationsTeamEmails: ['operations@example.com'], + compactSummaryReportNotificationEmails: ['summary@example.com'], + dateOfUpdate: '2024-12-10T19:27:28+00:00', + type: 'compact' +}; + +/** Ohio — jurisdiction receiving the home-state change notification */ +const JURISDICTION_CONFIG_OH = { + pk: 'aslp#CONFIGURATION', + sk: 'aslp#JURISDICTION#OH', + jurisdictionName: 'Ohio', + postalAbbreviation: 'OH', + compact: 'aslp', + jurisdictionOperationsTeamEmails: ['oh-ops@example.com'], + jurisdictionAdverseActionsNotificationEmails: ['oh-adverse@example.com'], + jurisdictionSummaryReportNotificationEmails: ['oh-summary@example.com'] +}; + +/** Tennessee — prior home jurisdiction in TN → OH scenarios (both valid ASLP jurisdictions) */ +const JURISDICTION_CONFIG_TN = { + pk: 'aslp#CONFIGURATION', + sk: 'aslp#JURISDICTION#TN', + jurisdictionName: 'Tennessee', + postalAbbreviation: 'TN', + compact: 'aslp', + jurisdictionOperationsTeamEmails: ['tn-ops@example.com'], + jurisdictionAdverseActionsNotificationEmails: ['tn-adverse@example.com'], + jurisdictionSummaryReportNotificationEmails: ['tn-summary@example.com'] +}; + +function mockGetJurisdictionConfiguration( + mock: jest.Mocked +): void { + mock.getJurisdictionConfiguration.mockImplementation(async (_compact, jurisdiction) => { + switch (jurisdiction.toLowerCase()) { + case 'oh': + return JURISDICTION_CONFIG_OH; + case 'tn': + return JURISDICTION_CONFIG_TN; + default: + throw new Error(`Unexpected jurisdiction in mock: ${jurisdiction}`); + } + }); +} + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +const MOCK_TRANSPORT = { + sendMail: jest.fn().mockImplementation(async () => ({ messageId: 'test-message-id' })) +}; + +describe('EmailNotificationService', () => { + let emailService: EmailNotificationService; + let mockSESClient: ReturnType; + let mockCompactConfigurationClient: jest.Mocked; + let mockJurisdictionClient: jest.Mocked; + + beforeAll(() => { + // Mock the renderTemplate method if template capture is enabled + if (EmailTemplateCapture.isEnabled()) { + const original = (EmailNotificationService.prototype as any).renderTemplate; + + jest.spyOn(EmailNotificationService.prototype as any, 'renderTemplate').mockImplementation(function (this: any, ...args: any[]) { + const [template, options] = args as [TReaderDocument, any]; + + EmailTemplateCapture.captureTemplate(template); + const html = original.apply(this, args); + + EmailTemplateCapture.captureHtml(html, template, options); + return html; + }); + } + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockSESClient = mockClient(SESv2Client); + mockCompactConfigurationClient = { + getCompactConfiguration: jest.fn() + } as any; + mockJurisdictionClient = { + getJurisdictionConfigurations: jest.fn(), + getJurisdictionConfiguration: jest.fn() + } as any; + + // Reset environment variables + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + process.env.TRANSACTION_REPORTS_BUCKET_NAME = 'test-transaction-reports-bucket'; + + // Set up default successful responses + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'message-id-123' + }); + + // Note: SESv2 with nodemailer 7.0.7 uses SendEmailCommand for all email sending + + (nodemailer.createTransport as jest.Mock).mockReturnValue(MOCK_TRANSPORT); + + emailService = new EmailNotificationService({ + logger: new Logger({ serviceName: 'test' }), + sesClient: asSESClient(mockSESClient), + compactConfigurationClient: mockCompactConfigurationClient, + jurisdictionClient: mockJurisdictionClient + }); + }); + + describe('Home Jurisdiction Change State Notification', () => { + it('should send home jurisdiction change state notification email with expected content', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + mockGetJurisdictionConfiguration(mockJurisdictionClient); + + await emailService.sendHomeJurisdictionChangeStateNotificationEmail( + 'aslp', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'TN', + 'OH' + ); + + expect(mockJurisdictionClient.getJurisdictionConfiguration).toHaveBeenCalledWith('aslp', 'oh'); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['oh-ops@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Practitioner Home State Change - Audiology and Speech Language Pathology' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + + // Get the actual HTML content for detailed validation + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain('This is to notify you that John Doe has changed their home state from TN to OH.'); + expect(htmlContent).toContain('https://app.test.compactconnect.org/aslp/Licensing/provider-123'); + }); + + it('should throw error when no recipients found for jurisdiction', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...JURISDICTION_CONFIG_OH, + jurisdictionOperationsTeamEmails: [] + }); + + await expect(emailService.sendHomeJurisdictionChangeStateNotificationEmail( + 'aslp', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'TN', + 'OH' + )).rejects.toThrow('No recipients found for jurisdiction oh in compact aslp'); + }); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/encumbrance-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/encumbrance-notification-service.test.ts new file mode 100644 index 0000000000..0c590f78d3 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/encumbrance-notification-service.test.ts @@ -0,0 +1,670 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; +import { EncumbranceNotificationService } from '../../../lib/email'; +import { CompactConfigurationClient } from '../../../lib/compact-configuration-client'; +import { JurisdictionClient } from '../../../lib/jurisdiction-client'; +import { EmailTemplateCapture } from '../../utils/email-template-capture'; +import { TReaderDocument } from '@csg-org/email-builder'; +import { describe, it, beforeEach, beforeAll, afterAll, jest } from '@jest/globals'; +import { Compact } from '../../../lib/models/compact'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; + +const SAMPLE_COMPACT_CONFIG: Compact = { + pk: 'socw#CONFIGURATION', + sk: 'socw#CONFIGURATION', + compactAdverseActionsNotificationEmails: ['adverse@example.com'], + compactCommissionFee: { + feeAmount: 3.5, + feeType: 'FLAT_RATE' + }, + compactAbbr: 'socw', + compactName: 'Audiology and Speech Language Pathology', + compactOperationsTeamEmails: ['operations@example.com'], + dateOfUpdate: '2024-12-10T19:27:28+00:00', + type: 'compact' +}; + +const SAMPLE_JURISDICTION_CONFIG = { + pk: 'socw#CONFIGURATION', + sk: 'socw#JURISDICTION#oh', + jurisdictionName: 'Ohio', + postalAbbreviation: 'oh', + compact: 'socw', + jurisdictionOperationsTeamEmails: ['oh-ops@example.com'], + jurisdictionAdverseActionsNotificationEmails: ['oh-adverse@example.com'] +}; + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +class MockCompactConfigurationClient extends CompactConfigurationClient { + constructor() { + super({ + logger: new Logger({ serviceName: 'test' }), + dynamoDBClient: {} as DynamoDBClient + }); + } + + public async getCompactConfiguration(_compact: string): Promise { + return SAMPLE_COMPACT_CONFIG; + } +} + +describe('EncumbranceNotificationService', () => { + let encumbranceService: EncumbranceNotificationService; + let mockSESClient: ReturnType; + let mockCompactConfigurationClient: MockCompactConfigurationClient; + let mockJurisdictionClient: jest.Mocked; + + beforeAll(() => { + // Mock the renderTemplate method if template capture is enabled + if (EmailTemplateCapture.isEnabled()) { + const original = (EncumbranceNotificationService.prototype as any).renderTemplate; + + jest.spyOn(EncumbranceNotificationService.prototype as any, 'renderTemplate').mockImplementation(function (this: any, ...args: any[]) { + const [template, options] = args as [TReaderDocument, any]; + + EmailTemplateCapture.captureTemplate(template); + const html = original.apply(this, args); + + EmailTemplateCapture.captureHtml(html, template, options); + return html; + }); + } + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockSESClient = mockClient(SESv2Client); + mockCompactConfigurationClient = new MockCompactConfigurationClient(); + mockJurisdictionClient = { + getJurisdictionConfigurations: jest.fn(), + getJurisdictionConfiguration: jest.fn() + } as any; + + // Reset environment variables + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + + // Set up default successful responses + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'message-id-123' + }); + + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue(SAMPLE_JURISDICTION_CONFIG); + + encumbranceService = new EncumbranceNotificationService({ + logger: new Logger({ serviceName: 'test' }), + sesClient: asSESClient(mockSESClient), + compactConfigurationClient: mockCompactConfigurationClient, + jurisdictionClient: mockJurisdictionClient + }); + }); + + describe('License Encumbrance Provider Notification', () => { + it('should send license encumbrance provider notification email', async () => { + await encumbranceService.sendLicenseEncumbranceProviderNotificationEmail( + 'socw', + ['provider@example.com'], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist license in Ohio is encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist license in Ohio is encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should throw error when no recipients provided', async () => { + await expect(encumbranceService.sendLicenseEncumbranceProviderNotificationEmail( + 'socw', + [], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-01-15' + )).rejects.toThrow('No recipients specified for provider license encumbrance notification email'); + }); + }); + + describe('License Encumbrance State Notification', () => { + it('should send license encumbrance state notification email', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue(SAMPLE_JURISDICTION_CONFIG); + + await encumbranceService.sendLicenseEncumbranceStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['oh-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('License Encumbrance Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Encumbrance Notification - John Doe' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should log warning and continue when no recipients found', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }); + + await encumbranceService.sendLicenseEncumbranceStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + // Should not send email when no recipients + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should log warning and continue when jurisdiction configuration is missing', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockRejectedValue( + new Error('Jurisdiction configuration not found for oh') + ); + + await encumbranceService.sendLicenseEncumbranceStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + // Should not send email when jurisdiction config is missing + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + }); + + describe('License Encumbrance Lifting Provider Notification', () => { + it('should send license encumbrance lifting provider notification email with correct content', async () => { + await encumbranceService.sendLicenseEncumbranceLiftingProviderNotificationEmail( + 'socw', + ['provider@example.com'], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist license in Ohio is no longer encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist license in Ohio is no longer encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should throw error when no recipients provided', async () => { + await expect(encumbranceService.sendLicenseEncumbranceLiftingProviderNotificationEmail( + 'socw', + [], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-02-15' + )).rejects.toThrow('No recipients specified for provider license encumbrance lifting notification email'); + }); + }); + + describe('License Encumbrance Lifting State Notification', () => { + it('should send license encumbrance lifting state notification email with correct content', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: ['state-adverse@example.com'] + }); + + await encumbranceService.sendLicenseEncumbranceLiftingStateNotificationEmail( + 'socw', + 'ca', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['state-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('License Encumbrance Lifted Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Encumbrance Lifted Notification - John Doe' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should log warning and continue when no recipients found for jurisdiction', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }); + + await encumbranceService.sendLicenseEncumbranceLiftingStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + // Should not send email when no recipients + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should log warning and continue when jurisdiction configuration is missing', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockRejectedValue( + new Error('Jurisdiction configuration not found for oh') + ); + + await encumbranceService.sendLicenseEncumbranceLiftingStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + // Should not send email when jurisdiction config is missing + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + }); + + describe('Privilege Encumbrance Provider Notification', () => { + it('should send privilege encumbrance provider notification email with correct content', async () => { + await encumbranceService.sendPrivilegeEncumbranceProviderNotificationEmail( + 'socw', + ['provider@example.com'], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist privilege in Ohio is encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist privilege in Ohio is encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should throw error when no recipients provided', async () => { + await expect(encumbranceService.sendPrivilegeEncumbranceProviderNotificationEmail( + 'socw', + [], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-01-15' + )).rejects.toThrow('No recipients specified for provider privilege encumbrance notification email'); + }); + }); + + describe('Privilege Encumbrance State Notification', () => { + it('should send privilege encumbrance state notification email with correct content', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: ['state-adverse@example.com'] + }); + + await encumbranceService.sendPrivilegeEncumbranceStateNotificationEmail( + 'socw', + 'ca', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['state-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Privilege Encumbrance Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Privilege Encumbrance Notification - John Doe' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + + const emailContent = mockSESClient.commandCalls(SendEmailCommand)[0] + .args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(emailContent).toContain('This encumbrance restricts the provider\'s ability to practice in Ohio under the Audiology and Speech Language Pathology compact'); + }); + + it('should include provider detail link in email content', async () => { + await encumbranceService.sendPrivilegeEncumbranceStateNotificationEmail( + 'socw', + 'ca', + 'John', + 'Doe', + 'provider-123', + 'oh', + 'Audiologist', + '2024-01-15' + ); + + const emailContent = mockSESClient.commandCalls(SendEmailCommand)[0].args[0] + .input.Content?.Simple?.Body?.Html?.Data; + + expect(emailContent).toContain('Provider Details: https://app.test.compactconnect.org/socw/Licensing/provider-123'); + expect(emailContent).toContain('This encumbrance restricts the provider\'s ability to practice in Ohio under the Audiology and Speech Language Pathology compact'); + }); + + it('should log warning and continue when no recipients found for jurisdiction', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }); + + await encumbranceService.sendPrivilegeEncumbranceStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + // Should not send email when no recipients + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should log warning and continue when jurisdiction configuration is missing', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockRejectedValue( + new Error('Jurisdiction configuration not found for oh') + ); + + await encumbranceService.sendPrivilegeEncumbranceStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-01-15' + ); + + // Should not send email when jurisdiction config is missing + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + }); + + describe('Privilege Encumbrance Lifting Provider Notification', () => { + it('should send privilege encumbrance lifting provider notification email with correct content', async () => { + await encumbranceService.sendPrivilegeEncumbranceLiftingProviderNotificationEmail( + 'socw', + ['provider@example.com'], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['provider@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Your Audiologist privilege in Ohio is no longer encumbered') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Your Audiologist privilege in Ohio is no longer encumbered' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should throw error when no recipients provided', async () => { + await expect(encumbranceService.sendPrivilegeEncumbranceLiftingProviderNotificationEmail( + 'socw', + [], + 'John', + 'Doe', + 'OH', + 'Audiologist', + '2024-02-15' + )).rejects.toThrow('No recipients specified for provider privilege encumbrance lifting notification email'); + }); + }); + + describe('Privilege Encumbrance Lifting State Notification', () => { + it('should send privilege encumbrance lifting state notification email with correct content', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: ['state-adverse@example.com'] + }); + + await encumbranceService.sendPrivilegeEncumbranceLiftingStateNotificationEmail( + 'socw', + 'ca', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['state-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('Privilege Encumbrance Lifted Notification - John Doe') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Privilege Encumbrance Lifted Notification - John Doe' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + + const emailContent = mockSESClient.commandCalls(SendEmailCommand)[0] + .args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(emailContent).toContain('Provider Details: https://app.test.compactconnect.org/socw/Licensing/provider-123'); + expect(emailContent).toContain('The encumbrance no longer restricts the provider\'s ability to practice in Ohio under the Audiology and Speech Language Pathology compact'); + }); + + it('should log warning and continue when no recipients found for jurisdiction', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }); + + await encumbranceService.sendPrivilegeEncumbranceLiftingStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + // Should not send email when no recipients + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should log warning and continue when jurisdiction configuration is missing', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockRejectedValue( + new Error('Jurisdiction configuration not found for oh') + ); + + await encumbranceService.sendPrivilegeEncumbranceLiftingStateNotificationEmail( + 'socw', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist', + '2024-02-15' + ); + + // Should not send email when jurisdiction config is missing + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/ingest-event-email-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/ingest-event-email-service.test.ts new file mode 100644 index 0000000000..edd731b36f --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/ingest-event-email-service.test.ts @@ -0,0 +1,197 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; +import { IngestEventEmailService } from '../../../lib/email'; +import { EmailTemplateCapture } from '../../utils/email-template-capture'; +import { TReaderDocument } from '@csg-org/email-builder'; +import { + SAMPLE_SORTABLE_VALIDATION_ERROR_RECORDS, + SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD, + SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD +} from '../../sample-records'; +import { describe, it, beforeEach, beforeAll, afterAll, jest } from '@jest/globals'; + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +describe('IngestEventEmailService', () => { + let emailService: IngestEventEmailService; + let mockSESClient: ReturnType; + + beforeAll(() => { + // Mock the renderTemplate method if template capture is enabled + if (EmailTemplateCapture.isEnabled()) { + const original = (IngestEventEmailService.prototype as any).renderTemplate; + + jest.spyOn(IngestEventEmailService.prototype as any, 'renderTemplate').mockImplementation(function (this: any, ...args: any[]) { + const [template, options] = args as [TReaderDocument, any]; + + EmailTemplateCapture.captureTemplate(template); + const html = original.apply(this, args); + + EmailTemplateCapture.captureHtml(html, template, options); + return html; + }); + } + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockSESClient = mockClient(SESv2Client); + + // Reset environment variables + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + + // Set up default successful responses + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'message-id-123' + }); + + emailService = new IngestEventEmailService({ + logger: new Logger({ serviceName: 'test' }), + sesClient: asSESClient(mockSESClient), + compactConfigurationClient: {} as any, + jurisdictionClient: {} as any + }); + }); + + it('should render an html document', async () => { + const template = emailService.generateReport( + { + ingestFailures: [ SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD ], + validationErrors: [ SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD ] + }, + 'Audiology and Speech Language Pathology', + 'Ohio' + ); + + // Any HTML document would start with a '<' and end with a '>' + expect(template.charAt(0)).toBe('<'); + expect(template.charAt(template.length - 1)).toBe('>'); + }); + + it('should send a report email', async () => { + const messageId = await emailService.sendReportEmail( + { + ingestFailures: [ SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD ], + validationErrors: [ SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD ] + }, + 'Audiology and Speech Language Pathology', + 'Ohio', + [ + 'operations@example.com' + ] + ); + + expect(messageId).toEqual('message-id-123'); + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Data Error Summary: Audiology and Speech Language Pathology / Ohio' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should sort validation errors by record number then time', async () => { + const sorted = emailService['sortValidationErrors']( + SAMPLE_SORTABLE_VALIDATION_ERROR_RECORDS + ); + + const flattenedErrors: string[] = sorted.flatMap((record) => record.errors.dateOfRenewal); + + expect(flattenedErrors).toEqual([ + 'Row 4, 5:47', + 'Row 5, 4:47', + 'Row 5, 5:47' + ]); + }); + + it('should send an alls well email', async () => { + const messageId = await emailService.sendAllsWellEmail( + 'Audiology and Speech Language Pathology', + 'Ohio', + [ 'operations@example.com' ] + ); + + expect(messageId).toEqual('message-id-123'); + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Data Summary: Audiology and Speech Language Pathology / Ohio' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should send a "no license updates" email with expected image url', async () => { + const messageId = await emailService.sendNoLicenseUpdatesEmail( + 'Audiology and Speech Language Pathology', + 'Ohio', + [ 'operations@example.com' ] + ); + + expect(messageId).toEqual('message-id-123'); + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('src=\"https://app.test.compactconnect.org/img/email/ico-noupdates@2x.png\"') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'No License Updates for Last 7 Days: Audiology and Speech Language Pathology / Ohio' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/investigation-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/investigation-notification-service.test.ts new file mode 100644 index 0000000000..cf1b30f6c3 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/investigation-notification-service.test.ts @@ -0,0 +1,402 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SendEmailCommand, SESv2Client } from '@aws-sdk/client-sesv2'; +import { InvestigationNotificationService } from '../../../lib/email'; +import { CompactConfigurationClient } from '../../../lib/compact-configuration-client'; +import { JurisdictionClient } from '../../../lib/jurisdiction-client'; +import { EmailTemplateCapture } from '../../utils/email-template-capture'; +import { TReaderDocument } from '@csg-org/email-builder'; +import { describe, it, beforeEach, beforeAll, afterAll, jest } from '@jest/globals'; +import { Compact } from '../../../lib/models/compact'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; + +const SAMPLE_COMPACT_CONFIG: Compact = { + pk: 'socw#CONFIGURATION', + sk: 'socw#CONFIGURATION', + compactAdverseActionsNotificationEmails: ['adverse@example.com'], + compactCommissionFee: { + feeAmount: 3.5, + feeType: 'FLAT_RATE' + }, + compactAbbr: 'socw', + compactName: 'Audiology and Speech Language Pathology', + compactOperationsTeamEmails: ['operations@example.com'], + dateOfUpdate: '2024-12-10T19:27:28+00:00', + type: 'compact' +}; + +const SAMPLE_JURISDICTION_CONFIG = { + pk: 'socw#CONFIGURATION', + sk: 'socw#JURISDICTION#oh', + jurisdictionName: 'Ohio', + postalAbbreviation: 'oh', + compact: 'socw', + jurisdictionOperationsTeamEmails: ['oh-ops@example.com'], + jurisdictionAdverseActionsNotificationEmails: ['oh-adverse@example.com'] +}; + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESv2Client; + +class MockCompactConfigurationClient extends CompactConfigurationClient { + constructor() { + super({ + logger: new Logger({ serviceName: 'test' }), + dynamoDBClient: {} as DynamoDBClient + }); + } + + public async getCompactConfiguration(_compact: string): Promise { + return SAMPLE_COMPACT_CONFIG; + } +} + +describe('InvestigationNotificationService', () => { + let investigationService: InvestigationNotificationService; + let mockSESClient: ReturnType; + let mockCompactConfigurationClient: MockCompactConfigurationClient; + let mockJurisdictionClient: jest.Mocked; + + beforeAll(() => { + // Mock the renderTemplate method if template capture is enabled + if (EmailTemplateCapture.isEnabled()) { + const original = (InvestigationNotificationService.prototype as any).renderTemplate; + + jest.spyOn(InvestigationNotificationService.prototype as any, 'renderTemplate').mockImplementation(function (this: any, ...args: any[]) { + const [template, options] = args as [TReaderDocument, any]; + + EmailTemplateCapture.captureTemplate(template); + const html = original.apply(this, args); + + EmailTemplateCapture.captureHtml(html, template, options); + return html; + }); + } + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockSESClient = mockClient(SESv2Client); + mockCompactConfigurationClient = new MockCompactConfigurationClient(); + mockJurisdictionClient = { + getJurisdictionConfigurations: jest.fn(), + getJurisdictionConfiguration: jest.fn() + } as any; + + // Reset environment variables + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + + // Set up default successful responses + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'message-id-123' + }); + + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue(SAMPLE_JURISDICTION_CONFIG); + + investigationService = new InvestigationNotificationService({ + logger: new Logger({ serviceName: 'test' }), + sesClient: asSESClient(mockSESClient), + compactConfigurationClient: mockCompactConfigurationClient, + jurisdictionClient: mockJurisdictionClient + }); + }); + + describe('License Investigation State Notification', () => { + it('should send license investigation state notification email', async () => { + await investigationService.sendLicenseInvestigationStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['oh-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('John Doe holding Audiologist license in Ohio is under investigation') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'John Doe holding Audiologist license in Ohio is under investigation' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + + it('should handle missing jurisdiction configuration gracefully', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockRejectedValue(new Error('Jurisdiction not found')); + + await investigationService.sendLicenseInvestigationStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + // Should not throw an error, but also should not send email + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + }); + + describe('License Investigation Closed State Notification', () => { + it('should send license investigation closed state notification email', async () => { + await investigationService.sendLicenseInvestigationClosedStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['oh-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('holding a Audiologist license in Ohio has been closed') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Investigation on John Doe\'s Audiologist license in Ohio has been closed' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + }); + + describe('Privilege Investigation State Notification', () => { + it('should send privilege investigation state notification email', async () => { + await investigationService.sendPrivilegeInvestigationStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['oh-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('John Doe holding Audiologist privilege in Ohio is under investigation') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'John Doe holding Audiologist privilege in Ohio is under investigation' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + }); + + describe('Privilege Investigation Closed State Notification', () => { + it('should send privilege investigation closed state notification email', async () => { + await investigationService.sendPrivilegeInvestigationClosedStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['oh-adverse@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('holding a Audiologist privilege in Ohio has been closed') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Investigation on John Doe\'s Audiologist privilege in Ohio has been closed' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + }); + }); + + describe('Error Handling', () => { + it('should handle SES client errors gracefully', async () => { + mockSESClient.on(SendEmailCommand).rejects(new Error('SES service error')); + + await expect( + investigationService.sendLicenseInvestigationStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ) + ).rejects.toThrow('SES service error'); + }); + + it('should handle missing adverse action recipients gracefully', async () => { + const jurisdictionConfigWithoutAdverseActions = { + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }; + + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue( + jurisdictionConfigWithoutAdverseActions + ); + + await investigationService.sendLicenseInvestigationStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + // Should not throw an error, but also should not send email + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should handle missing recipients for license investigation closed state notification', async () => { + const jurisdictionConfigWithoutAdverseActions = { + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }; + + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue( + jurisdictionConfigWithoutAdverseActions + ); + + await investigationService.sendLicenseInvestigationClosedStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + // Should not throw an error, but also should not send email + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should handle missing recipients for privilege investigation state notification', async () => { + const jurisdictionConfigWithoutAdverseActions = { + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }; + + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue( + jurisdictionConfigWithoutAdverseActions + ); + + await investigationService.sendPrivilegeInvestigationStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + // Should not throw an error, but also should not send email + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should handle missing recipients for privilege investigation closed state notification', async () => { + const jurisdictionConfigWithoutAdverseActions = { + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionAdverseActionsNotificationEmails: [] + }; + + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue( + jurisdictionConfigWithoutAdverseActions + ); + + await investigationService.sendPrivilegeInvestigationClosedStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', + 'Audiologist' + ); + + // Should not throw an error, but also should not send email + expect(mockSESClient).not.toHaveReceivedCommand(SendEmailCommand); + }); + + it('should handle same notifying and affected jurisdiction', async () => { + // Reset mocks to ensure clean state + jest.clearAllMocks(); + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue(SAMPLE_JURISDICTION_CONFIG); + + // When notifying jurisdiction equals affected jurisdiction, it should use the same config + await investigationService.sendLicenseInvestigationStateNotificationEmail( + 'socw', + 'OH', + 'John', + 'Doe', + 'provider-123', + 'OH', // Same as notifying jurisdiction + 'Audiologist' + ); + + // Should have been called at least once with the jurisdiction + expect(mockJurisdictionClient.getJurisdictionConfiguration).toHaveBeenCalledWith('socw', 'OH'); + // Should have sent the email successfully + expect(mockSESClient).toHaveReceivedCommand(SendEmailCommand); + }); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/environment-variables-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/environment-variables-service.test.ts new file mode 100644 index 0000000000..7d450985dc --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/environment-variables-service.test.ts @@ -0,0 +1,46 @@ +import { Logger } from '@aws-lambda-powertools/logger'; +import { EnvironmentVariablesService } from '../../lib/environment-variables-service'; + +describe('Environment variables service with debug', () => { + let environmentVariables: EnvironmentVariablesService; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.DATA_EVENT_TABLE_NAME = 'some-table'; + // Tells the logger to pretty print logs for easier manual reading + process.env.POWERTOOLS_DEV = 'true'; + + environmentVariables = new EnvironmentVariablesService(); + }); + + it('should produce a logger with debug log level', async () => { + const logger = new Logger({ logLevel: environmentVariables.getLogLevel() }); + + logger.debug('Test!'); + + expect(logger.getLevelName()).toBe('DEBUG'); + }); + + it('should produce the expected table name', async () => { + expect(environmentVariables.getDataEventTableName()).toBe('some-table'); + }); +}); + +describe('Environment variables service without debug', () => { + let environmentVariables: EnvironmentVariablesService; + + beforeAll(async () => { + delete process.env.DEBUG; + process.env.DATA_EVENT_TABLE_NAME = 'some-table'; + + environmentVariables = new EnvironmentVariablesService(); + }); + + it('should produce a logger with info log level', async () => { + const logger = new Logger({ logLevel: environmentVariables.getLogLevel() }); + + logger.debug('Test!'); + + expect(logger.getLevelName()).toBe('INFO'); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/event-client.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/event-client.test.ts new file mode 100644 index 0000000000..4a2f1bbf1a --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/event-client.test.ts @@ -0,0 +1,269 @@ +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; +import { mockClient } from 'aws-sdk-client-mock'; +import { EventClient } from '../../lib/event-client'; +import { describe, it, beforeAll, beforeEach, jest } from '@jest/globals'; + + +/* + * Double casting to allow us to pass a mock in for the real thing + */ +const asDynamoDBClient = (mock: ReturnType) => + mock as unknown as DynamoDBClient; + + + +describe('EventClient', () => { + let mockDynamoDBClient: ReturnType; + + const withErrorsInDynamoDB = () => { + mockDynamoDBClient.on(QueryCommand).callsFake((input) => { + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.validation-error#TIME#' + )) { + return Promise.resolve({ + Items: [{ 'eventType': { 'S': 'license.validation-error' }}] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest-failure#TIME#' + )) { + return Promise.resolve({ + Items: [{ 'eventType': { 'S': 'license.ingest-failure' }}] + }); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest#TIME#' + )) { + return Promise.resolve({ + Items: [] + }); + } + throw Error(`Unexpected query ${input}`); + }); + }; + + const withoutErrorsInDynamoDB = () => { + mockDynamoDBClient.on(QueryCommand).callsFake((input) => { + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.validation-error#TIME#' + )) { + return Promise.resolve({}); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest-failure#TIME#' + )) { + return Promise.resolve({}); + } + if (input?.ExpressionAttributeValues?.[':skBegin']['S']?.startsWith( + 'TYPE#license.ingest#TIME#' + )) { + return Promise.resolve({ + Items: [{ 'eventType': { 'S': 'license.ingest' }}] + }); + } + throw Error('Unexpected query'); + }); + }; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.DATA_EVENT_TABLE_NAME = 'some-table'; + + mockDynamoDBClient = mockClient(DynamoDBClient); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should produce 15-minute timestamps 900 seconds (15 minutes) apart', async () => { + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const [ startStamp, endStamp ] = eventClient.getLast15MinuteTimestamps(); + + expect(endStamp - startStamp).toEqual(900); + }); + + it('should produce 15-minute blocks', async () => { + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + // Test case 1: if 'now' is at 11:01, it should return timestamps at 10:45-11:00 + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T11:01:00.000Z')); + + const [ startStamp1, endStamp1 ] = eventClient.getLast15MinuteTimestamps(); + const expectedStart1 = Math.floor(new Date('2025-01-01T10:45:00.000Z').getTime() / 1000); + const expectedEnd1 = Math.floor(new Date('2025-01-01T11:00:00.000Z').getTime() / 1000); + + expect(startStamp1).toEqual(expectedStart1); + expect(endStamp1).toEqual(expectedEnd1); + expect(endStamp1 - startStamp1).toEqual(900); // 15 minutes (10:45 to 11:00) + + // Test case 2: if 'now' is at 2025-01-01T00:00:00.001Z, it should return timestamps for 2024-12-31T23:45:00.000Z-2025-01-01T00:00:00.000Z + jest.setSystemTime(new Date('2025-01-01T00:00:00.001Z')); + + const [ startStamp2, endStamp2 ] = eventClient.getLast15MinuteTimestamps(); + const expectedStart2 = Math.floor(new Date('2024-12-31T23:45:00.000Z').getTime() / 1000); + const expectedEnd2 = Math.floor(new Date('2025-01-01T00:00:00.000Z').getTime() / 1000); + + expect(startStamp2).toEqual(expectedStart2); + expect(endStamp2).toEqual(expectedEnd2); + expect(endStamp2 - startStamp2).toEqual(900); // 15 minutes (23:45 to 00:00) + + // Test case 3: if 'now' is at 12:35, it should return timestamps at 12:15-12:30 + jest.setSystemTime(new Date('2025-01-01T12:35:00.000Z')); + + const [ startStamp3, endStamp3 ] = eventClient.getLast15MinuteTimestamps(); + const expectedStart3 = Math.floor(new Date('2025-01-01T12:15:00.000Z').getTime() / 1000); + const expectedEnd3 = Math.floor(new Date('2025-01-01T12:30:00.000Z').getTime() / 1000); + + expect(startStamp3).toEqual(expectedStart3); + expect(endStamp3).toEqual(expectedEnd3); + expect(endStamp3 - startStamp3).toEqual(900); // 15 minutes (12:15 to 12:30) + + // Restore real timers + jest.useRealTimers(); + }); + + it('should produce nightly timestamps 86400 seconds (24 hours) apart', async () => { + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const [ startStamp, endStamp ] = eventClient.getYesterdayTimestamps(); + + expect(endStamp - startStamp).toEqual(86400); + }); + + it('should produce weekly timestamps 604800 seconds (7 days) apart', async () => { + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const [ startStamp, endStamp ] = eventClient.getLastWeekTimestamps(); + + expect(endStamp - startStamp).toEqual(604800); + }); + + it('should return validation errors from the getValidationErrors method', async () => { + withErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const validationErrors = await eventClient.getValidationErrors('socw', 'oh', 0, 1); + + expect(validationErrors).toEqual([{ 'eventType': 'license.validation-error' }]); + }); + + + it('should return an empty array if there are no validation errors', async () => { + withoutErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const validationErrors = await eventClient.getValidationErrors('socw', 'oh', 0, 1); + + expect(validationErrors).toEqual([]); + }); + + it('should return ingest failures from the getIngestFailures method', async () => { + withErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const validationErrors = await eventClient.getIngestFailures('socw', 'oh', 0, 1); + + expect(validationErrors).toEqual([{ 'eventType': 'license.ingest-failure' }]); + }); + + it('should return an empty array if there are no ingest failures', async () => { + withoutErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const validationErrors = await eventClient.getIngestFailures('socw', 'oh', 0, 1); + + expect(validationErrors).toEqual([]); + }); + + it('should return ingest successes', async () => { + withoutErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const ingestSuccesses = await eventClient.getIngestSuccesses('socw', 'oh', 0, 1); + + expect(ingestSuccesses).toEqual([{ 'eventType': 'license.ingest' }]); + }); + + it('should return empty array if no ingest successes', async () => { + withErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const ingestSuccesses = await eventClient.getIngestSuccesses('socw', 'oh', 0, 1); + + expect(ingestSuccesses).toEqual([]); + }); + + it('should return ingest failures, successes, and validation errors from the getEvents method when errors', async() => { + withErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const ingestEvents = await eventClient.getEvents('socw', 'oh', 0, 1); + + expect(ingestEvents).toEqual({ + ingestFailures: [{ 'eventType': 'license.ingest-failure' }], + validationErrors: [{ 'eventType': 'license.validation-error' }], + ingestSuccesses: [] + }); + }); + + it('should return ingest failures, successes, and validation errors from the getEvents method when no errors', async() => { + withoutErrorsInDynamoDB(); + + const eventClient = new EventClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const ingestEvents = await eventClient.getEvents('socw', 'oh', 0, 1); + + expect(ingestEvents).toEqual({ + ingestFailures: [], + validationErrors: [], + ingestSuccesses: [{ 'eventType': 'license.ingest' }] + }); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/jurisdiction-client.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/jurisdiction-client.test.ts new file mode 100644 index 0000000000..d25e8db8c9 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/jurisdiction-client.test.ts @@ -0,0 +1,237 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient, QueryCommand, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { JurisdictionClient } from '../../lib/jurisdiction-client'; +import { describe, it, beforeAll, beforeEach, jest } from '@jest/globals'; + + +const SAMPLE_JURISDICTION_ITEMS = [ + { + 'pk': { + 'S': 'socw#CONFIGURATION' + }, + 'sk': { + 'S': 'socw#JURISDICTION#oh' + }, + 'compact': { + 'S': 'socw' + }, + 'dateOfUpdate': { + 'S': '2024-11-14' + }, + 'jurisdictionAdverseActionsNotificationEmails': { + 'L': [] + }, + + 'jurisdictionName': { + 'S': 'Ohio' + }, + 'jurisdictionOperationsTeamEmails': { + 'L': [ + { + 'S': 'operations@example.com' + } + ] + }, + 'jurisprudenceRequirements': { + 'M': { + 'required': { + 'BOOL': true + } + } + }, + 'postalAbbreviation': { + 'S': 'oh' + }, + 'type': { + 'S': 'jurisdiction' + } + }, + { + 'pk': { + 'S': 'socw#CONFIGURATION' + }, + 'sk': { + 'S': 'socw#JURISDICTION#ne' + }, + 'compact': { + 'S': 'socw' + }, + 'dateOfUpdate': { + 'S': '2024-11-14' + }, + 'jurisdictionAdverseActionsNotificationEmails': { + 'L': [] + }, + 'jurisdictionName': { + 'S': 'Nebraska' + }, + 'jurisdictionOperationsTeamEmails': { + 'L': [ + { + 'S': 'justin@inspiringapps.com' + } + ] + }, + 'jurisprudenceRequirements': { + 'M': { + 'required': { + 'BOOL': true + } + } + }, + 'postalAbbreviation': { + 'S': 'ne' + }, + 'type': { + 'S': 'jurisdiction' + } + } +]; + + +/* + * Double casting to allow us to pass a mock in for the real thing + */ +const asDynamoDBClient = (mock: ReturnType) => + mock as unknown as DynamoDBClient; + + +describe('JurisdictionClient', () => { + let jurisdictionClient: JurisdictionClient; + let mockDynamoDBClient: ReturnType; + + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.COMPACTS = '["socw"]'; + process.env.DATA_EVENT_TABLE_NAME = 'data-table'; + process.env.COMPACT_CONFIGURATION_TABLE_NAME = 'compact-table'; + process.env.AWS_REGION = 'us-east-1'; + + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return jurisdiction data from the dynamo', async () => { + mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(QueryCommand).resolves({ + Items: SAMPLE_JURISDICTION_ITEMS + }); + + + jurisdictionClient = new JurisdictionClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const jurisdictions = await jurisdictionClient.getJurisdictionConfigurations('socw'); + + expect(jurisdictions).toHaveLength(2); + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + QueryCommand, + { + TableName: 'compact-table', + KeyConditionExpression: 'pk = :pk and begins_with (sk, :sk)', + ExpressionAttributeValues: { + ':pk': { 'S': 'socw#CONFIGURATION' }, + ':sk': { 'S': 'socw#JURISDICTION#' } + } + } + ); + + // Verify we got the expected jurisdictions back + expect(jurisdictions.map((j) => j.jurisdictionName)).toEqual(expect.arrayContaining(['Ohio', 'Nebraska'])); + }); + + it('should return an empty array if no records in dynamo', async () => { + mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(QueryCommand).resolves({}); + + jurisdictionClient = new JurisdictionClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const jurisdictions = await jurisdictionClient.getJurisdictionConfigurations('socw'); + + expect(jurisdictions).toEqual([]); + }); + + it('should get a specific jurisdiction configuration', async () => { + mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_JURISDICTION_ITEMS[0] + }); + + jurisdictionClient = new JurisdictionClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + const jurisdiction = await jurisdictionClient.getJurisdictionConfiguration('socw', 'oh'); + + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + GetItemCommand, + { + TableName: 'compact-table', + Key: { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#oh' } + } + } + ); + + expect(jurisdiction.jurisdictionName).toBe('Ohio'); + expect(jurisdiction.postalAbbreviation).toBe('oh'); + }); + + it('should throw error when jurisdiction not found', async () => { + mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: undefined + }); + + jurisdictionClient = new JurisdictionClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + await expect(jurisdictionClient.getJurisdictionConfiguration('socw', 'xx')) + .rejects + .toThrow('Jurisdiction configuration not found for xx'); + }); + + it('should convert jurisdiction postal code to lowercase', async () => { + mockDynamoDBClient = mockClient(DynamoDBClient); + + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_JURISDICTION_ITEMS[0] + }); + + jurisdictionClient = new JurisdictionClient({ + logger: new Logger(), + dynamoDBClient: asDynamoDBClient(mockDynamoDBClient) + }); + + await jurisdictionClient.getJurisdictionConfiguration('socw', 'OH'); + + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + GetItemCommand, + { + TableName: 'compact-table', + Key: { + 'pk': { S: 'socw#CONFIGURATION' }, + 'sk': { S: 'socw#JURISDICTION#oh' } + } + } + ); + }); +}); diff --git a/backend/social-work-app/lambdas/nodejs/tests/sample-records.ts b/backend/social-work-app/lambdas/nodejs/tests/sample-records.ts new file mode 100644 index 0000000000..6ddeb5d7b8 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/sample-records.ts @@ -0,0 +1,320 @@ +export const SAMPLE_INGEST_SUCCESS_RECORD = { + 'pk': { + 'S': 'COMPACT#socw#JURISDICTION#oh' + }, + 'sk': { + 'S': 'TYPE#license.ingest#TIME#1731618012#EVENT#08ff0b63-4492-89c6-4372-3e95f03e1234' + }, + 'compact': { + 'S': 'socw' + }, + 'jurisdiction': { + 'S': 'oh' + }, + 'licenseType': { + 'S': 'cosmetologist' + }, + 'status': { + 'S': 'active' + }, + 'dateOfIssuance': { + 'S': '2023-01-01' + }, + 'dateOfRenewal': { + 'S': '2024-01-01' + }, + 'dateOfExpiration': { + 'S': '2025-01-01' + }, + 'eventTime': { + 'S': '2024-11-14T21:00:12.382000+00:00' + } +}; + +export const SAMPLE_INGEST_FAILURE_ERROR_RECORD = { + 'pk': { + 'S': 'COMPACT#socw#JURISDICTION#oh' + }, + 'sk': { + 'S': 'TYPE#license.ingest-failure#TIME#1731618012#EVENT#08ff0b63-4492-89c6-4372-3e95f03ee984' + }, + 'compact': { + 'S': 'socw' + }, + 'errors': { + 'L': [ + { + 'S': '\'utf-8\' codec can\'t decode byte 0x83 in position 0: invalid start byte' + } + ] + }, + 'eventExpiry': { + 'N': '1739394328' + }, + 'eventTime': { + 'S': '2024-11-14T21:00:12.382000+00:00' + }, + 'eventType': { + 'S': 'license.ingest-failure' + }, + 'jurisdiction': { + 'S': 'oh' + } +}; + +export const SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD = { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.ingest-failure#TIME#1731618012#EVENT#08ff0b63-4492-89c6-4372-3e95f03ee984', + 'compact': 'socw', + 'errors': [ '\'utf-8\' codec can\'t decode byte 0x83 in position 0: invalid start byte' ], + 'eventExpiry': '1739394328', + 'eventTime': '2024-11-14T21:00:12.382000+00:00', + 'eventType': 'license.ingest-failure', + 'jurisdiction': 'oh' +}; + +export const SAMPLE_VALIDATION_ERROR_RECORD = { + 'pk': { + 'S': 'COMPACT#socw#JURISDICTION#oh' + }, + 'sk': { + 'S': 'TYPE#license.validation-error#TIME#1730263675#EVENT#182d8d8b-7fee-6e0c-2e3c-1189a47d5a0c' + }, + 'eventType': { + 'S': 'license.validation-error' + }, + 'eventTime': { + 'S': '2024-10-30T04:47:55.843000+00:00' + }, + 'compact': { + 'S': 'socw' + }, + 'jurisdiction': { + 'S': 'oh' + }, + 'errors': { + 'M': { + 'dateOfRenewal': { + 'L': [ + { + 'S': 'Not a valid date.' + } + ] + } + } + }, + 'recordNumber': { + 'N': '5' + }, + 'validData': { + 'M': { + 'dateOfExpiration': { + 'S': '2024-06-30' + }, + 'dateOfIssuance': { + 'S': '2024-06-30' + }, + 'familyName': { + 'S': 'Carreño Quiñones' + }, + 'givenName': { + 'S': 'María' + }, + 'licenseType': { + 'S': 'occupational therapist' + }, + 'middleName': { + 'S': 'José' + }, + 'status': { + 'S': 'active' + } + } + } +}; + +export const SAMPLE_SORTABLE_VALIDATION_ERROR_RECORDS = [ + { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.validation-error#TIME#1730263675#EVENT#182d8d8b-7fee-6e0c-2e3c-1189a47d5a0c', + 'eventType': 'license.validation-error', + 'eventTime': '2024-10-30T04:47:55.843000+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'errors': { + 'dateOfRenewal': [ + 'Row 5, 4:47' + ] + }, + 'recordNumber': 5, + 'validData': { + 'dateOfExpiration': '2024-06-30', + 'dateOfIssuance': '2024-06-30', + 'familyName': 'Carreño Quiñones', + 'givenName': 'María', + 'licenseType': 'occupational therapist', + 'middleName': 'José', + 'status': 'active' + } + }, + { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.validation-error#TIME#1730263675#EVENT#182d8d8b-7fee-6e0c-2e3c-1189a47d5a0c', + 'eventType': 'license.validation-error', + 'eventTime': '2024-10-30T05:47:55.843000+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'errors': { + 'dateOfRenewal': [ + 'Row 4, 5:47' + ] + }, + 'recordNumber': 4, + 'validData': { + 'dateOfExpiration': '2024-06-30', + 'dateOfIssuance': '2024-06-30', + 'familyName': 'Carreño Quiñones', + 'givenName': 'María', + 'licenseType': 'occupational therapist', + 'middleName': 'José', + 'status': 'active' + } + }, + { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.validation-error#TIME#1730263675#EVENT#182d8d8b-7fee-6e0c-2e3c-1189a47d5a0c', + 'eventType': 'license.validation-error', + 'eventTime': '2024-10-30T05:47:55.843000+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'errors': { + 'dateOfRenewal': [ + 'Row 5, 5:47' + ] + }, + 'recordNumber': 5, + 'validData': { + 'dateOfExpiration': '2024-06-30', + 'dateOfIssuance': '2024-06-30', + 'familyName': 'Carreño Quiñones', + 'givenName': 'María', + 'licenseType': 'occupational therapist', + 'middleName': 'José', + 'status': 'active' + } + } +]; + +export const SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD = { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.validation-error#TIME#1730263675#EVENT#182d8d8b-7fee-6e0c-2e3c-1189a47d5a0c', + 'eventType': 'license.validation-error', + 'eventTime': '2024-10-30T04:47:55.843000+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'errors': { + 'dateOfRenewal': [ + 'Not a valid date.' + ] + }, + 'recordNumber': 5, + 'validData': { + 'dateOfExpiration': '2024-06-30', + 'dateOfIssuance': '2024-06-30', + 'familyName': 'Carreño Quiñones', + 'givenName': 'María', + 'licenseType': 'occupational therapist', + 'middleName': 'José', + 'status': 'active' + } +}; + +export const SAMPLE_JURISDICTION_CONFIGURATION = { + 'pk': { + 'S': 'socw#CONFIGURATION' + }, + 'sk': { + 'S': 'socw#JURISDICTION#oh' + }, + 'compact': { + 'S': 'socw' + }, + 'dateOfUpdate': { + 'S': '2024-11-14' + }, + 'jurisdictionAdverseActionsNotificationEmails': { + 'L': [] + }, + + 'jurisdictionName': { + 'S': 'Ohio' + }, + 'jurisdictionOperationsTeamEmails': { + 'L': [ + { + 'S': 'justin@inspiringapps.com' + } + ] + }, + 'jurisprudenceRequirements': { + 'M': { + 'required': { + 'BOOL': true + } + } + }, + 'postalAbbreviation': { + 'S': 'oh' + }, + 'type': { + 'S': 'jurisdiction' + } +}; + +export const SAMPLE_UNMARSHALLED_JURISDICTION_CONFIGURATION = { + 'pk': 'socw#CONFIGURATION', + 'sk': 'socw#JURISDICTION#oh', + 'compact': 'socw', + 'dateOfUpdate': '2024-11-14', + 'jurisdictionAdverseActionsNotificationEmails': [], + + 'jurisdictionName': 'Ohio', + 'jurisdictionOperationsTeamEmails': [ 'justin@inspiringapps.com' ], + 'jurisprudenceRequirements': { + 'required': true + }, + 'postalAbbreviation': 'oh', + 'type': 'jurisdiction', +}; + +export const SAMPLE_COMPACT_CONFIGURATION = { + 'pk': { 'S': 'socw#CONFIGURATION' }, + 'sk': { 'S': 'socw#CONFIGURATION' }, + 'compactAdverseActionsNotificationEmails': { 'L': [{ 'S': 'adverse@example.com' }]}, + 'compactCommissionFee': { + 'M': { + 'feeAmount': { 'N': '3.5' }, + 'feeType': { 'S': 'FLAT_RATE' } + } + }, + 'compactAbbr': { 'S': 'socw' }, + 'compactName': { 'S': 'Audiology and Speech Language Pathology' }, + 'compactOperationsTeamEmails': { 'L': [{ 'S': 'compact-ops@example.com' }]}, + 'dateOfUpdate': { 'S': '2024-12-10T19:27:28+00:00' }, + 'type': { 'S': 'compact' } +}; + +export const SAMPLE_UNMARSHALLED_COMPACT_CONFIGURATION = { + 'pk': 'socw#CONFIGURATION', + 'sk': 'socw#CONFIGURATION', + 'compactAdverseActionsNotificationEmails': ['adverse@example.com'], + 'compactCommissionFee': { + 'feeAmount': 3.5, + 'feeType': 'FLAT_RATE' + }, + 'compactAbbr': 'socw', + 'compactName': 'Audiology and Speech Language Pathology', + 'compactOperationsTeamEmails': ['compact-ops@example.com'], + 'dateOfUpdate': '2024-12-10T19:27:28+00:00', + 'type': 'compact' +}; diff --git a/backend/social-work-app/lambdas/nodejs/tests/utils/email-template-capture.ts b/backend/social-work-app/lambdas/nodejs/tests/utils/email-template-capture.ts new file mode 100644 index 0000000000..2925914346 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tests/utils/email-template-capture.ts @@ -0,0 +1,141 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { TReaderDocument } from '@csg-org/email-builder'; + +/** + * Utility for capturing email templates during testing + * This can be toggled on/off via environment variable CAPTURE_EMAIL_TEMPLATES + */ +export class EmailTemplateCapture { + private static outputDir: string; + + /** + * Check if template capture is enabled via environment variable + */ + static isEnabled(): boolean { + return process.env.CAPTURE_EMAIL_TEMPLATES === 'true'; + } + + /** + * Get the current test name from Jest's test context + */ + private static getCurrentTestName(): string { + // Get the current test name from Jest's expect context + const expect = (global as any).expect; + + if (expect && expect.getState) { + const state = expect.getState(); + + if (state && state.currentTestName) { + return state.currentTestName; + } + } + + return 'unknown-test'; + } + + /** + * Generate a sanitized filename based on the current test name + */ + private static generateFilename(extension: string): string { + const testName = this.getCurrentTestName(); + + // Extract the most meaningful part of the test name + // For patterns like "CognitoEmailService generateCognitoMessage should generate SignUp message" + // We want to prioritize the part after "should" + let meaningfulPart = testName; + + // Try to extract the part after the last "should" + const shouldIndex = testName.lastIndexOf(' should '); + + if (shouldIndex !== -1) { + meaningfulPart = testName.substring(shouldIndex + 8); // +8 for " should " + } + + // If that's still too generic, try to get the last part after the last space + if (meaningfulPart.length < 10) { + const lastSpaceIndex = testName.lastIndexOf(' '); + + if (lastSpaceIndex !== -1) { + meaningfulPart = testName.substring(lastSpaceIndex + 1); + } + } + + // Sanitize test name for filename (remove special characters, spaces, etc.) + const sanitizedTestName = meaningfulPart + .replace(/[^a-zA-Z0-9\s-]/g, '') // Remove special characters except spaces and hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .toLowerCase() + .substring(0, 80); // Increased limit to 80 characters + + return `${sanitizedTestName}.${extension}`; + } + + /** + * Ensure the output directory exists and return its path + */ + private static ensureOutputDirectory(): string { + if (!this.outputDir) { + this.outputDir = path.join(__dirname, '..', '..', 'generated-email-templates'); + console.log('📧 Email template capture is ENABLED'); + } + + return this.outputDir; + } + + /** + * Capture a template if capture is enabled + */ + static captureTemplate(template: TReaderDocument) { + if (!this.isEnabled()) { + return; + } + + const outputDir = this.ensureOutputDirectory(); + const filename = this.generateFilename('json'); + const filepath = path.join(outputDir, filename); + + // Create template data with metadata + const templateData = { + metadata: { + testName: this.getCurrentTestName(), + generatedAt: new Date().toISOString(), + rootBlockId: 'root' + }, + // This is the raw TReaderDocument that can be used with EmailBuilderJS + emailBuilderTemplate: template + }; + + // Write to file + fs.writeFileSync(filepath, JSON.stringify(templateData, null, 2)); + console.log(`📧 Captured email template: ${filename}`); + } + + + /** + * Capture the rendered HTML output for debugging + */ + static captureHtml(html: string, template: TReaderDocument, options?: Record) { + if (!this.isEnabled()) { + return; + } + + const outputDir = this.ensureOutputDirectory(); + const filename = this.generateFilename('html'); + const filepath = path.join(outputDir, filename); + const metaFilepath = path.join(outputDir, filename.replace(/\.html$/, '.meta.json')); + + // Write metadata alongside the raw HTML + const meta = { + testName: this.getCurrentTestName(), + generatedAt: new Date().toISOString(), + rootBlockId: (options as any)?.rootBlockId ?? 'root', + note: 'Metadata for the final rendered HTML output from EmailBuilderJS' + }; + + fs.writeFileSync(metaFilepath, JSON.stringify(meta, null, 2)); + // Write raw HTML for easy preview + fs.writeFileSync(filepath, html); + console.log(`📧 Captured rendered HTML: ${filename} (and metadata)`); + } +} diff --git a/backend/social-work-app/lambdas/nodejs/tsconfig.json b/backend/social-work-app/lambdas/nodejs/tsconfig.json new file mode 100644 index 0000000000..d0832f50e7 --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "jsx": "preserve", + "importHelpers": true, + "moduleResolution": "bundler", + "experimentalDecorators": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "sourceMap": true, + + "noImplicitAny": false, + "lib": [ + "esnext", + "scripthost" + ], + "typeRoots": [ + "./node_modules/@types" + ], + "types": [ + "node", + "jest" + ] + }, + "include": [ + "**/handler.ts", + "lib/**/*.ts", + "lib/**/*.tsx" + ], + "exclude": [ + "cdk.out", + "node_modules" + ] +} diff --git a/backend/social-work-app/lambdas/nodejs/yarn.lock b/backend/social-work-app/lambdas/nodejs/yarn.lock new file mode 100644 index 0000000000..85817b5cfc --- /dev/null +++ b/backend/social-work-app/lambdas/nodejs/yarn.lock @@ -0,0 +1,5445 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@aws-crypto/crc32@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz#cfcc22570949c98c6689cfcbd2d693d36cdae2e1" + integrity sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/crc32c@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz#4e34aab7f419307821509a98b9b08e84e0c1917e" + integrity sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/sha1-browser@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz#b0ee2d2821d3861f017e965ef3b4cb38e3b6a0f4" + integrity sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg== + dependencies: + "@aws-crypto/supports-web-crypto" "^5.2.0" + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + "@aws-sdk/util-locate-window" "^3.0.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-crypto/sha256-browser@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz#153895ef1dba6f9fce38af550e0ef58988eb649e" + integrity sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw== + dependencies: + "@aws-crypto/sha256-js" "^5.2.0" + "@aws-crypto/supports-web-crypto" "^5.2.0" + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + "@aws-sdk/util-locate-window" "^3.0.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz#c4fdb773fdbed9a664fc1a95724e206cf3860042" + integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/supports-web-crypto@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz#a1e399af29269be08e695109aa15da0a07b5b5fb" + integrity sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg== + dependencies: + tslib "^2.6.2" + +"@aws-crypto/util@5.2.0", "@aws-crypto/util@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-5.2.0.tgz#71284c9cffe7927ddadac793c14f14886d3876da" + integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== + dependencies: + "@aws-sdk/types" "^3.222.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-lambda-powertools/commons@2.32.0": + version "2.32.0" + resolved "https://registry.yarnpkg.com/@aws-lambda-powertools/commons/-/commons-2.32.0.tgz#238be3395cad1567d1ecca02918f0121ea0ad548" + integrity sha512-vsdakJDZu/KkJ5+1WHkawQ5R06aCK0XYB3nc3tpcBfO1YmOHkn+QpuGiZtkmpzIykGPAO9lnBjK5joAeim2o4A== + dependencies: + "@aws/lambda-invoke-store" "0.2.4" + +"@aws-lambda-powertools/logger@^2.32.0": + version "2.32.0" + resolved "https://registry.yarnpkg.com/@aws-lambda-powertools/logger/-/logger-2.32.0.tgz#1f3a47fca38d1d403492199481dfcd0a75a2fbe6" + integrity sha512-ZfomsMv4FnxYkgUvU9S6BPrTzd+ntPiIBZcrvSNz+/aPvVwu2BGHSKDuVlXa7nr6rB1wjzaA5bmLVTESIdnsdQ== + dependencies: + "@aws-lambda-powertools/commons" "2.32.0" + "@aws/lambda-invoke-store" "0.2.4" + +"@aws-sdk/client-dynamodb@^3.1045.0": + version "3.1045.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1045.0.tgz#d497a132068b081f6189626a86bea9cdf3d71df0" + integrity sha512-TxZmhpziFxWD3pdXGbuwntKOX5OkW14yvCITYsRz+QDM5EUL4cIygu2xRGHiKDvKmb3QUOA4yKetAQcIMLe2zw== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-node" "^3.972.39" + "@aws-sdk/dynamodb-codec" "^3.973.8" + "@aws-sdk/middleware-endpoint-discovery" "^3.972.11" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" + "@smithy/util-waiter" "^4.3.0" + tslib "^2.6.2" + +"@aws-sdk/client-s3@^3.1045.0": + version "3.1045.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.1045.0.tgz#d41b6d49f0554d3f67a41866dfa6de0e6b8f1a94" + integrity sha512-fsuO3Y6t+3Ro9Bsg41DKj4Sfy53CGSrhnMldNplWmG8Tx0UbYk+YDa4RD1hVlJpERw4JBmPkl0+J9qlxMh1pcA== + dependencies: + "@aws-crypto/sha1-browser" "5.2.0" + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-node" "^3.972.39" + "@aws-sdk/middleware-bucket-endpoint" "^3.972.10" + "@aws-sdk/middleware-expect-continue" "^3.972.10" + "@aws-sdk/middleware-flexible-checksums" "^3.974.16" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-location-constraint" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-sdk-s3" "^3.972.37" + "@aws-sdk/middleware-ssec" "^3.972.10" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/signature-v4-multi-region" "^3.996.25" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/eventstream-serde-browser" "^4.2.14" + "@smithy/eventstream-serde-config-resolver" "^4.3.14" + "@smithy/eventstream-serde-node" "^4.2.14" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-blob-browser" "^4.2.15" + "@smithy/hash-node" "^4.2.14" + "@smithy/hash-stream-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/md5-js" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + "@smithy/util-waiter" "^4.3.0" + tslib "^2.6.2" + +"@aws-sdk/client-sesv2@^3.1045.0": + version "3.1045.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-sesv2/-/client-sesv2-3.1045.0.tgz#5793588298dd648cf0d5f3baddc8e9bea4e0c9dd" + integrity sha512-Ae6oKWTod06687mIdKPnHPG5tolx/g68tqIbySMoWCs56OmRqn+7JiS3pOlpQeqjhbJlukfrXbsTMgZcXO9cSQ== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-node" "^3.972.39" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/signature-v4-multi-region" "^3.996.25" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@aws-sdk/core@^3.974.8": + version "3.974.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.974.8.tgz#cdd51195a31322f1e429e66919eb18da8944c081" + integrity sha512-njR2qoG6ZuB0kvAS2FyICsFZJ6gmCcf2X/7JcD14sUvGDm26wiZ5BrA6LOiUxKFEF+IVe7kdroxyE00YlkiYsw== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/xml-builder" "^3.972.22" + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@aws-sdk/crc64-nvme@^3.972.7": + version "3.972.7" + resolved "https://registry.yarnpkg.com/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.7.tgz#0e56fb3ccc0242ed05ffd0bc993d724ce8b3dde2" + integrity sha512-QUagVVBbC8gODCF6e1aV0mE2TXWB9Opz4k8EJFdNrujUVQm5R4AjJa1mpOqzwOuROBzqJU9zawzig7M96L8Ejg== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@^3.972.34": + version "3.972.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz#9d420adf02e7604094a641ae613a353aa86e1b83" + integrity sha512-XT0jtf8Fw9JE6ppsQeoNnZRiG+jqRixMT1v1ZR17G60UvVdsQmTG8nbEyHuEPfMxDXEhfdARaM/XiEhca4lGHQ== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@^3.972.36": + version "3.972.36" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.36.tgz#842268559da2ffc5855cde1e90e7302d53639c08" + integrity sha512-DPoGWfy7J7RKxvbf5kOKIGQkD2ek3dbKgzKIGrnLuvZBz5myU+Im/H6pmc14QcnFbqHMqxvtWSgRDSJW3qXLQg== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-stream" "^4.5.25" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@^3.972.38": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.38.tgz#e20955fdfe4a88149b20dc7e25a517542e1dfd9f" + integrity sha512-oDzUBu2MGJFgoar05sPMCwSrhw44ASyccrHzj66vO69OZqi7I6hZZxXfuPLC8OCzW7C+sU+bI73XHij41yekgQ== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/credential-provider-env" "^3.972.34" + "@aws-sdk/credential-provider-http" "^3.972.36" + "@aws-sdk/credential-provider-login" "^3.972.38" + "@aws-sdk/credential-provider-process" "^3.972.34" + "@aws-sdk/credential-provider-sso" "^3.972.38" + "@aws-sdk/credential-provider-web-identity" "^3.972.38" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-login@^3.972.38": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.38.tgz#278437712c02a3ad1785f70c93b4f591cb3f6491" + integrity sha512-g1NosS8qe4OF++G2UFCM5ovSkgipC7YYor5KCWatG0UoMSO5YFj9C8muePlyVmOBV/WTI16Jo3/s1NUo/o1Bww== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@^3.972.39": + version "3.972.39" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.39.tgz#71f87848b7615dda4f31a57b113be9666e4bbd1a" + integrity sha512-HEswDQyxUtadoZ/bJsPPENHg7R0Lzym5LuMksJeHvqhCOpP+rtkDLKI4/ZChH4w3cf5kG8n6bZuI8PzajoiqMg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.34" + "@aws-sdk/credential-provider-http" "^3.972.36" + "@aws-sdk/credential-provider-ini" "^3.972.38" + "@aws-sdk/credential-provider-process" "^3.972.34" + "@aws-sdk/credential-provider-sso" "^3.972.38" + "@aws-sdk/credential-provider-web-identity" "^3.972.38" + "@aws-sdk/types" "^3.973.8" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@^3.972.34": + version "3.972.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.34.tgz#c964275be1a528ac73ade6d98c309fb6b7cdfb68" + integrity sha512-T3IFs4EVmVi1dVN5RciFnklCANSzvrQd/VuHY9ThHSQmYkTogjcGkoJEr+oNUPQZnso52183088NqysMPji1/Q== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@^3.972.38": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.38.tgz#ec754bfecb2426a3307e19ef7e6c6b6438a327c6" + integrity sha512-5ZxG+t0+3Q3QPh8KEjX6syskhgNf7I0MN7oGioTf6Lm1NTjfP7sIcYGNsthXC2qR8vcD3edNZwCr2ovfSSWuRA== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/token-providers" "3.1041.0" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@^3.972.38": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.38.tgz#149951ef6e12db5292118e8ed5d95133c24ad719" + integrity sha512-lYHFF30DGI20jZcYX8cm6Ns0V7f1dDN6g/MBDLTyD/5iw+bXs3yBr2iAiHDkx4RFU5JgsnZvCHYKiRVPRdmOgw== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/dynamodb-codec@^3.973.8": + version "3.973.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.8.tgz#e2f0a451eef83a0163e73625fdf7fd1e5b8c110d" + integrity sha512-dYQ/cQqHZd23hcl8oEGwPphTqyGnmvf2HrVmz4J90Q5Bv89oJjlwcBcifiiTvApqsVpx7Pr0IebMpkYwWJvZlQ== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@smithy/core" "^3.23.17" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + tslib "^2.6.2" + +"@aws-sdk/endpoint-cache@^3.972.5": + version "3.972.5" + resolved "https://registry.yarnpkg.com/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.5.tgz#42b8e8920e5460b4840c9866dcac7905d87d0dc5" + integrity sha512-itVdge0NozgtgmtbZ25FVwWU3vGlE7x7feE/aOEJNkQfEpbkrF8Rj1QmnK+2blFfYE1xWt/iU+6/jUp/pv1+MA== + dependencies: + mnemonist "0.38.3" + tslib "^2.6.2" + +"@aws-sdk/middleware-bucket-endpoint@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.10.tgz#d26aa88b441d6d1b6e9275ffdc61e0fbfb55a513" + integrity sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-arn-parser" "^3.972.3" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + tslib "^2.6.2" + +"@aws-sdk/middleware-endpoint-discovery@^3.972.11": + version "3.972.11" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.11.tgz#6f1e38f4638272e01b8a32cc91853e79a650db8a" + integrity sha512-vXARCZVFQHdsd6qPPZyC/hh+5x2XsCYKqUQDCqnUlpGpChMpDojOOacQWdLJ+FFXKN8X3cmLOGrtgx/zysCKqQ== + dependencies: + "@aws-sdk/endpoint-cache" "^3.972.5" + "@aws-sdk/types" "^3.973.8" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-expect-continue@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.10.tgz#b685287951156a5d093cfdd37364894c6a8c966c" + integrity sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-flexible-checksums@^3.974.16": + version "3.974.16" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.16.tgz#89b78cb0ad389aba7d12d060f46017e1fa3784a9" + integrity sha512-6ru8doI0/XzszqLIPXf0E/V7HhAw1Pu94010XCKYtBUfD0LxF0BuOzrUf8OQGR6j2o6wgKTHUniOmndQycHwCA== + dependencies: + "@aws-crypto/crc32" "5.2.0" + "@aws-crypto/crc32c" "5.2.0" + "@aws-crypto/util" "5.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/crc64-nvme" "^3.972.7" + "@aws-sdk/types" "^3.973.8" + "@smithy/is-array-buffer" "^4.2.2" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@aws-sdk/middleware-host-header@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz#e63b91959ce46948d789582351b2a44c4876e924" + integrity sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-location-constraint@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.10.tgz#5265ea472f735c50b016bb5d1b46c7a616653733" + integrity sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-logger@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz#d92b3374dcaddd523930bdff441207946343c270" + integrity sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-recursion-detection@^3.972.11": + version "3.972.11" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz#5659982a34fa58c69cbd358c2987c32aefd2bd91" + integrity sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@aws/lambda-invoke-store" "^0.2.2" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-sdk-s3@^3.972.37": + version "3.972.37" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.37.tgz#82ef4953cddd3373d2942d07a5d2baf443bbf3ea" + integrity sha512-Km7M+i8DrLArVzrid1gfxeGhYHBd3uxvE77g0s5a52zPSVosxzQBnJ0gwWb6NIp/DOk8gsBMhi7V+cpJG0ndTA== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-arn-parser" "^3.972.3" + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@aws-sdk/middleware-ssec@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.10.tgz#46b5c030c0116f51110e18042ad3cf863ab5c81c" + integrity sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/middleware-user-agent@^3.972.38": + version "3.972.38" + resolved "https://registry.yarnpkg.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.38.tgz#626d9a2499f5a6398a4db917abeeaac14b54c6cb" + integrity sha512-iz+B29TXcAZsJpwB+AwG/TTGA5l/VnmMZ2UxtiySOZjI6gCdmviXPwdgzcmuazMy16rXoPY4mYCGe7zdNKfx5A== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@smithy/core" "^3.23.17" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-retry" "^4.3.6" + tslib "^2.6.2" + +"@aws-sdk/nested-clients@^3.997.6": + version "3.997.6" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.6.tgz#17433cfac2160ec620a14cbff9d2b33675712cae" + integrity sha512-WBDnqatJl+kGObpfmfSxqnXeYTu3Me8wx8WCtvoxX3pfWrrTv8I4WTMSSs7PZqcRcVh8WeUKMgGFjMG+52SR1w== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/middleware-host-header" "^3.972.10" + "@aws-sdk/middleware-logger" "^3.972.10" + "@aws-sdk/middleware-recursion-detection" "^3.972.11" + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/region-config-resolver" "^3.972.13" + "@aws-sdk/signature-v4-multi-region" "^3.996.25" + "@aws-sdk/types" "^3.973.8" + "@aws-sdk/util-endpoints" "^3.996.8" + "@aws-sdk/util-user-agent-browser" "^3.972.10" + "@aws-sdk/util-user-agent-node" "^3.973.24" + "@smithy/config-resolver" "^4.4.17" + "@smithy/core" "^3.23.17" + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/hash-node" "^4.2.14" + "@smithy/invalid-dependency" "^4.2.14" + "@smithy/middleware-content-length" "^4.2.14" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-retry" "^4.5.7" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/protocol-http" "^5.3.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-body-length-node" "^4.2.3" + "@smithy/util-defaults-mode-browser" "^4.3.49" + "@smithy/util-defaults-mode-node" "^4.2.54" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@aws-sdk/region-config-resolver@^3.972.13": + version "3.972.13" + resolved "https://registry.yarnpkg.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.13.tgz#bd32748c2d41b62be838fec76c4b87d4370939c6" + integrity sha512-CvJ2ZIjK/jVD/lbOpowBVElJyC1YxLTIJ13yM0AEo0t2v7swOzGjSA6lJGH+DwZXQhcjUjoYwc8bVYCX5MDr1A== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/config-resolver" "^4.4.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.25": + version "3.996.25" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.25.tgz#b50651b7e4f9c82482416caa9953ad17645d4a2d" + integrity sha512-+CMIt3e1VzlklAECmG+DtP1sV8iKq25FuA0OKpnJ4KA0kxUtd7CgClY7/RU6VzJBQwbN4EJ9Ue6plvqx1qGadw== + dependencies: + "@aws-sdk/middleware-sdk-s3" "^3.972.37" + "@aws-sdk/types" "^3.973.8" + "@smithy/protocol-http" "^5.3.14" + "@smithy/signature-v4" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1041.0": + version "3.1041.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1041.0.tgz#f3f068010780fc85fc4a7faa6a080cfb8afd73a4" + integrity sha512-Th7kPI6YPtvJUcdznooXJMy+9rQWjmEF81LxaJssngBzuysK4a/x+l8kjm1zb7nYsUPbndnBdUnwng/3PLvtGw== + dependencies: + "@aws-sdk/core" "^3.974.8" + "@aws-sdk/nested-clients" "^3.997.6" + "@aws-sdk/types" "^3.973.8" + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.8": + version "3.973.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.8.tgz#7352cb74a5f8bae1218eee63e714cf94302911c5" + integrity sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@aws-sdk/util-arn-parser@^3.972.3": + version "3.972.3" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz#ed989862bbb172ce16d9e1cd5790e5fe367219c2" + integrity sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA== + dependencies: + tslib "^2.6.2" + +"@aws-sdk/util-dynamodb@^3.996.2": + version "3.996.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.2.tgz#9521dfe84c031809f8cf2e32f03c58fd8a4bb84f" + integrity sha512-ddpwaZmjBzcApYN7lgtAXjk+u+GO8fiPsxzuc59UqP+zqdxI1gsenPvkyiHiF9LnYnyRGijz6oN2JylnN561qQ== + dependencies: + tslib "^2.6.2" + +"@aws-sdk/util-endpoints@^3.996.8": + version "3.996.8" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz#ad5c4f09b93482c0861d49d8a025edc2b0d2f5ec" + integrity sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-endpoints" "^3.4.2" + tslib "^2.6.2" + +"@aws-sdk/util-locate-window@^3.0.0": + version "3.965.4" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz#f62d279e1905f6939b6dffb0f76ab925440f72bf" + integrity sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog== + dependencies: + tslib "^2.6.2" + +"@aws-sdk/util-user-agent-browser@^3.972.10": + version "3.972.10" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz#e29be10389db9db12b2d8246ad247a89038f4c60" + integrity sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g== + dependencies: + "@aws-sdk/types" "^3.973.8" + "@smithy/types" "^4.14.1" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/util-user-agent-node@^3.973.24": + version "3.973.24" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.24.tgz#cf44a63b92adfecaeb8cb9f948b390456310566a" + integrity sha512-ZWwlkjcIp7cEL8ZfTpTAPNkwx25p7xol0xlKoWVVf22+nsjwmLcHYtTPjIV1cSpmB/b6DaK4cb1fSkvCXHgRdw== + dependencies: + "@aws-sdk/middleware-user-agent" "^3.972.38" + "@aws-sdk/types" "^3.973.8" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + tslib "^2.6.2" + +"@aws-sdk/xml-builder@^3.972.22": + version "3.972.22" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.22.tgz#1e44ca9fd9c3fdc3d9af9540ced024f34cfc60b2" + integrity sha512-PMYKKtJd70IsSG0yHrdAbxBr+ZWBKLvzFZfD3/urxgf6hXVMzuU5M+3MJ5G67RpOmLBu1fAUN65SbWuKUCOlAA== + dependencies: + "@nodable/entities" "2.1.0" + "@smithy/types" "^4.14.1" + fast-xml-parser "5.7.2" + tslib "^2.6.2" + +"@aws/lambda-invoke-store@0.2.4", "@aws/lambda-invoke-store@^0.2.2": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz#802f6a50f6b6589063ef63ba8acdee86fcb9f395" + integrity sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ== + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== + dependencies: + "@babel/helper-validator-identifier" "^7.28.5" + js-tokens "^4.0.0" + picocolors "^1.1.1" + +"@babel/compat-data@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" + integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== + +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" + integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helpers" "^7.28.6" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/remapping" "^2.3.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.29.0", "@babel/generator@^7.7.2": + version "7.29.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== + dependencies: + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== + dependencies: + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + +"@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== + dependencies: + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== + dependencies: + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.28.6", "@babel/helper-plugin-utils@^7.8.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== + +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== + +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + +"@babel/helpers@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" + integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== + dependencies: + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" + integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== + dependencies: + "@babel/types" "^7.29.0" + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-class-static-block@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" + integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-import-attributes@^7.24.7": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz#b71d5914665f60124e133696f17cd7669062c503" + integrity sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-syntax-import-meta@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.7.2": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz#f8ca28bbd84883b5fea0e447c635b81ba73997ee" + integrity sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.10.4": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-private-property-in-object@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" + integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-top-level-await@^7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.7.2": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz#c7b2ddf1d0a811145b1de800d1abd146af92e3a2" + integrity sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + +"@babel/template@^7.28.6", "@babel/template@^7.3.3": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" + debug "^4.3.1" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.3.3": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + +"@csg-org/block-avatar@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-avatar/-/block-avatar-0.0.13.tgz#815b68d8771865b3c6cacb939a8472ead9675c07" + integrity sha512-v5/kKyGGZU6cZ1vKkE9E4YbDm4hTi4DDC9f4B9uLegyR+mr1r7LNeGwMRbAfX3z817Myby8cx6KbFE3+aK5wlQ== + +"@csg-org/block-button@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-button/-/block-button-0.0.13.tgz#480e21281593f98135a83f51708e8bcff0285086" + integrity sha512-g2FWzr5moAaI+s1fitlt0KxhMFs3rbLYGz4BoiZzFGVYqufit/sRIYaxLrvL37WgEgVMwoFcUN7wXUNb9ry5XQ== + +"@csg-org/block-columns-container@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-columns-container/-/block-columns-container-0.0.13.tgz#c0f5b5aaeef2b7169873df2b1904da496e1ea2be" + integrity sha512-0o4xO8IhghpGWsiCbL+BlvEo2Nn55lH2uWJsK3/waBr0Vz6rUk92VZM200RyNhStdyoLpZSSgilknTQyfixscA== + +"@csg-org/block-container@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-container/-/block-container-0.0.13.tgz#0b0ac0a68b8b5c389311edc9421018b5e2906a72" + integrity sha512-z2n/+feBRJOhPCsOrdBpz3U0wYSn0vOvuUgBut+b4ZBwJA5uX9v8Mu/2Y2OgHsdXr8AC3frDfU6V8GhWsLD2EQ== + +"@csg-org/block-divider@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-divider/-/block-divider-0.0.13.tgz#8d95709e2fc7641fff31dc23e9d8730e870bb123" + integrity sha512-b1ZqdRIA/kmpMGezfiwQmR22pvQlB+vwQju74G0h4j5YQFPwmJsF8nLJMGt9IrTQtmzQnDmaEEn+ura4lbbxuw== + +"@csg-org/block-heading@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-heading/-/block-heading-0.0.13.tgz#4d7ab222ac8606acfe696c49f6395aa2a81614ed" + integrity sha512-86qcSA8G8kTGc6ue6Hr99BDRKhZiDCVPsB2BzTNIVrXsxYzIUjo2sMW4er23jYOWFarPasGJRpUymKOZgd+P6w== + +"@csg-org/block-html@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-html/-/block-html-0.0.13.tgz#72471c34b0055adcef26aad06f8a80275c76d45b" + integrity sha512-T4yoQWScDQu/QJLpzLj7KJ/QuPtiPEh4Uvh8+riov6u6HNWGW+jCi5vDdXxbrJ1m5wV2SpXnZSdpCf/xylazVw== + +"@csg-org/block-image@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-image/-/block-image-0.0.13.tgz#80a01aceb529518600803645a019eeb570527738" + integrity sha512-oHnS+v6WAwAhgc7/KTqKLZAJzNh4E//9N57SfA/uYlM8TzCgjLXxXdpu7M+BzORdEDWdu1wQqFPcAuiu/P9BoQ== + +"@csg-org/block-spacer@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-spacer/-/block-spacer-0.0.13.tgz#fc05fb42e619931061697d85517ed659b9a918d7" + integrity sha512-QEm9dWs1bB8WXIX01YZQhG02K0DSY+N+ptPMtvyfe7eFQjlnw980Tvj6rHiHa7kfq6SfbehKXhyZ35byCvoQGA== + +"@csg-org/block-text@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/block-text/-/block-text-0.0.13.tgz#a68fe1e66e578e02236aaae0f280df6aacde0dfc" + integrity sha512-NYiWwJbnXfTOO3fVDZxRXp53ZAXrjsbepBmI/AyXvU9iNoaxXqKDBNTvAwIQet51XPC+2sm643Z+flNvVmfo/w== + dependencies: + marked "^12.0.2" + sanitize-html "^2.17.0" + +"@csg-org/document-core@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/document-core/-/document-core-0.0.13.tgz#99f1a43f524dff5cc68984762a79f2d21d33829e" + integrity sha512-O8gzvFj2X7CsknyKzZ2pA3WboENn/dWwv48QTX06kXB8ILmzMqtGaNh6SrcYNWcFMNdHVZpqS/hcdR6SC91Jkw== + +"@csg-org/email-builder@^0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@csg-org/email-builder/-/email-builder-0.0.13.tgz#eb22b08c06d8fb6541a5c8b920a0e962ad9d3d8f" + integrity sha512-TiGNL/zfdV0cYMXZzG8Lm8Lzvem2Sx6bnaa10b8VCgoont0RbNefiThyu6GaOATyA1ohxa2ouP0ZGq+0OE/8PA== + dependencies: + "@csg-org/block-avatar" "^0.0.13" + "@csg-org/block-button" "^0.0.13" + "@csg-org/block-columns-container" "^0.0.13" + "@csg-org/block-container" "^0.0.13" + "@csg-org/block-divider" "^0.0.13" + "@csg-org/block-heading" "^0.0.13" + "@csg-org/block-html" "^0.0.13" + "@csg-org/block-image" "^0.0.13" + "@csg-org/block-spacer" "^0.0.13" + "@csg-org/block-text" "^0.0.13" + "@csg-org/document-core" "^0.0.13" + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@dabh/diagnostics@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e" + integrity sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q== + dependencies: + "@so-ric/colorspace" "^1.1.6" + enabled "2.0.x" + kuler "^2.0.0" + +"@esbuild/aix-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" + integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== + +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== + +"@esbuild/android-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" + integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== + +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== + +"@esbuild/android-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" + integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== + +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== + +"@esbuild/android-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" + integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== + +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== + +"@esbuild/darwin-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" + integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== + +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== + +"@esbuild/darwin-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" + integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== + +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== + +"@esbuild/freebsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" + integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== + +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== + +"@esbuild/freebsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" + integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== + +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== + +"@esbuild/linux-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" + integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== + +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== + +"@esbuild/linux-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" + integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== + +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== + +"@esbuild/linux-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" + integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== + +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== + +"@esbuild/linux-loong64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" + integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== + +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== + +"@esbuild/linux-mips64el@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" + integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== + +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== + +"@esbuild/linux-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" + integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== + +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== + +"@esbuild/linux-riscv64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" + integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== + +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== + +"@esbuild/linux-s390x@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" + integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== + +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== + +"@esbuild/linux-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" + integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== + +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== + +"@esbuild/netbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" + integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== + +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== + +"@esbuild/netbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" + integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== + +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== + +"@esbuild/openbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" + integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== + +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== + +"@esbuild/openbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" + integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== + +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== + +"@esbuild/openharmony-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" + integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== + +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== + +"@esbuild/sunos-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" + integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== + +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== + +"@esbuild/win32-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" + integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== + +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== + +"@esbuild/win32-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" + integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== + +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== + +"@esbuild/win32-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" + integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== + +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== + dependencies: + "@eslint/object-schema" "^3.0.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.5.tgz#ae16134e4792ac5fbdc533548a24ac1ea9f7f3ae" + integrity sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w== + dependencies: + "@eslint/core" "^1.2.1" + +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== + +"@eslint/plugin-kit@^0.7.1": + version "0.7.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz#c4125fd015eceeb09b793109fdbcd4dd0a02d346" + integrity sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/console@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" + integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + slash "^3.0.0" + +"@jest/core@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" + integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== + dependencies: + "@jest/console" "^29.7.0" + "@jest/reporters" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + ci-info "^3.2.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-changed-files "^29.7.0" + jest-config "^29.7.0" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-resolve-dependencies "^29.7.0" + jest-runner "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + jest-watcher "^29.7.0" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + strip-ansi "^6.0.0" + +"@jest/diff-sequences@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz#0ededeae4d071f5c8ffe3678d15f3a1be09156be" + integrity sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw== + +"@jest/environment@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" + integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== + dependencies: + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + +"@jest/expect-utils@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-30.2.0.tgz#4f95413d4748454fdb17404bf1141827d15e6011" + integrity sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA== + dependencies: + "@jest/get-type" "30.1.0" + +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== + dependencies: + jest-get-type "^29.6.3" + +"@jest/expect@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" + integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== + dependencies: + expect "^29.7.0" + jest-snapshot "^29.7.0" + +"@jest/fake-timers@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" + integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== + dependencies: + "@jest/types" "^29.6.3" + "@sinonjs/fake-timers" "^10.0.2" + "@types/node" "*" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +"@jest/get-type@30.1.0": + version "30.1.0" + resolved "https://registry.yarnpkg.com/@jest/get-type/-/get-type-30.1.0.tgz#4fcb4dc2ebcf0811be1c04fd1cb79c2dba431cbc" + integrity sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA== + +"@jest/globals@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" + integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/types" "^29.6.3" + jest-mock "^29.7.0" + +"@jest/pattern@30.0.1": + version "30.0.1" + resolved "https://registry.yarnpkg.com/@jest/pattern/-/pattern-30.0.1.tgz#d5304147f49a052900b4b853dedb111d080e199f" + integrity sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA== + dependencies: + "@types/node" "*" + jest-regex-util "30.0.1" + +"@jest/reporters@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" + integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + "@types/node" "*" + chalk "^4.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^6.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.1.3" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + jest-worker "^29.7.0" + slash "^3.0.0" + string-length "^4.0.1" + strip-ansi "^6.0.0" + v8-to-istanbul "^9.0.1" + +"@jest/schemas@30.0.5": + version "30.0.5" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-30.0.5.tgz#7bdf69fc5a368a5abdb49fd91036c55225846473" + integrity sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA== + dependencies: + "@sinclair/typebox" "^0.34.0" + +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jest/source-map@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" + integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.18" + callsites "^3.0.0" + graceful-fs "^4.2.9" + +"@jest/test-result@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" + integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== + dependencies: + "@jest/console" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + collect-v8-coverage "^1.0.0" + +"@jest/test-sequencer@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" + integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== + dependencies: + "@jest/test-result" "^29.7.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + slash "^3.0.0" + +"@jest/transform@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" + integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== + dependencies: + "@babel/core" "^7.11.6" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + babel-plugin-istanbul "^6.1.1" + chalk "^4.0.0" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + micromatch "^4.0.4" + pirates "^4.0.4" + slash "^3.0.0" + write-file-atomic "^4.0.2" + +"@jest/types@30.2.0": + version "30.2.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-30.2.0.tgz#1c678a7924b8f59eafd4c77d56b6d0ba976d62b8" + integrity sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg== + dependencies: + "@jest/pattern" "30.0.1" + "@jest/schemas" "30.0.5" + "@types/istanbul-lib-coverage" "^2.0.6" + "@types/istanbul-reports" "^3.0.4" + "@types/node" "*" + "@types/yargs" "^17.0.33" + chalk "^4.1.2" + +"@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@nodable/entities@2.1.0", "@nodable/entities@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.0.tgz#f543e5c6446720d4cf9e498a83019dd159973bc2" + integrity sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@sinclair/typebox@^0.27.8": + version "0.27.10" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.10.tgz#beefe675f1853f73676aecc915b2bd2ac98c4fc6" + integrity sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA== + +"@sinclair/typebox@^0.34.0": + version "0.34.48" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.34.48.tgz#75b0ead87e59e1adbd6dccdc42bad4fddee73b59" + integrity sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA== + +"@sinonjs/commons@^3.0.0", "@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@11.2.2": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" + integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@sinonjs/fake-timers@^10.0.2": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@sinonjs/fake-timers@^13.0.1": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" + integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@sinonjs/samsam@^8.0.0": + version "8.0.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.3.tgz#eb6ffaef421e1e27783cc9b52567de20cb28072d" + integrity sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ== + dependencies: + "@sinonjs/commons" "^3.0.1" + type-detect "^4.1.0" + +"@sinonjs/text-encoding@^0.7.3": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz#282046f03e886e352b2d5f5da5eb755e01457f3f" + integrity sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA== + +"@smithy/chunked-blob-reader-native@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz#9e79a80d8d44798e7ce7a8f968cbbbaf5a40d950" + integrity sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw== + dependencies: + "@smithy/util-base64" "^4.3.2" + tslib "^2.6.2" + +"@smithy/chunked-blob-reader@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz#3af48e37b10e5afed478bb31d2b7bc03c81d196c" + integrity sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw== + dependencies: + tslib "^2.6.2" + +"@smithy/config-resolver@^4.4.17": + version "4.4.17" + resolved "https://registry.yarnpkg.com/@smithy/config-resolver/-/config-resolver-4.4.17.tgz#5bd7ccf461e126c79072ce84c6b0f3d00b3409bc" + integrity sha512-TzDZcAnhTyAHbXVxWZo7/tEcrIeFq20IBk8So3OLOetWpR8EwY/yEqBMBFaJMeyEiREDq4NfEl+qO3OAUD+vbQ== + dependencies: + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-config-provider" "^4.2.2" + "@smithy/util-endpoints" "^3.4.2" + "@smithy/util-middleware" "^4.2.14" + tslib "^2.6.2" + +"@smithy/core@^3.23.17": + version "3.23.17" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.23.17.tgz#23d02277c8d6d30a1605afd756696265e48ed67e" + integrity sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ== + dependencies: + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-body-length-browser" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-stream" "^4.5.25" + "@smithy/util-utf8" "^4.2.2" + "@smithy/uuid" "^1.1.2" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz#b5dcc198ee240eaf68069e7449bcec29ce279827" + integrity sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg== + dependencies: + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + tslib "^2.6.2" + +"@smithy/eventstream-codec@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz#4963ca27242b80c5b1d11dcd3ea1bee2a3c5f96d" + integrity sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw== + dependencies: + "@aws-crypto/crc32" "5.2.0" + "@smithy/types" "^4.14.1" + "@smithy/util-hex-encoding" "^4.2.2" + tslib "^2.6.2" + +"@smithy/eventstream-serde-browser@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz#b483667ea358975afb2170cd2618b9aa53a0fb29" + integrity sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ== + dependencies: + "@smithy/eventstream-serde-universal" "^4.2.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/eventstream-serde-config-resolver@^4.3.14": + version "4.3.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz#2eb23acad43414b9bc0b43f34ae9afbd5464e484" + integrity sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/eventstream-serde-node@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz#402c2a3b0437b7ac9747090a38a60d3642813490" + integrity sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw== + dependencies: + "@smithy/eventstream-serde-universal" "^4.2.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/eventstream-serde-universal@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz#1e1d29c111e580a93f3c197139c5ca8c976ec205" + integrity sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg== + dependencies: + "@smithy/eventstream-codec" "^4.2.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^5.3.17": + version "5.3.17" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz#bf13a4b03eb8afe101775fef59a1758f8fb5cd4b" + integrity sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw== + dependencies: + "@smithy/protocol-http" "^5.3.14" + "@smithy/querystring-builder" "^4.2.14" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + tslib "^2.6.2" + +"@smithy/hash-blob-browser@^4.2.15": + version "4.2.15" + resolved "https://registry.yarnpkg.com/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.15.tgz#1323f9717cad352b3e18065b738387bb9684f993" + integrity sha512-0PJ4Al3fg2nM4qKrAIxyNcApgqHAXcBkN8FeizOz69z0rb26uZ6lMESYtxegaTlXB5Hj84JfwMPavMrwDMjucA== + dependencies: + "@smithy/chunked-blob-reader" "^5.2.2" + "@smithy/chunked-blob-reader-native" "^4.2.3" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/hash-node@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.2.14.tgz#e3ed33dc614e26fff5f043e097750c6931b48592" + integrity sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g== + dependencies: + "@smithy/types" "^4.14.1" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@smithy/hash-stream-node@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/hash-stream-node/-/hash-stream-node-4.2.14.tgz#98bc14e79e2be852d04ff6cbfe4b0babe48fb10d" + integrity sha512-tw4GANWkZPb6+BdD4Fgucqzey2+r73Z/GRo9zklsCdwrnxxumUV83ZIaBDdudV4Ylazw3EPTiJZhpX42105ruQ== + dependencies: + "@smithy/types" "^4.14.1" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@smithy/invalid-dependency@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz#a52766f9d4299abcd9d6cd23b5a76f34fc59c7a0" + integrity sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/is-array-buffer@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" + integrity sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA== + dependencies: + tslib "^2.6.2" + +"@smithy/is-array-buffer@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz#c401ce54b12a16529eb1c938a0b6c2247cb763b8" + integrity sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow== + dependencies: + tslib "^2.6.2" + +"@smithy/md5-js@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/md5-js/-/md5-js-4.2.14.tgz#c066572ec84def147af24e55a402c44d0d7dcd7b" + integrity sha512-V2v0vx+h0iUSNG1Alt+GNBMSLGCrl9iVsdd+Ap67HPM9PN479x12V8LkuMoKImNZxn3MXeuyUjls+/7ZACZghA== + dependencies: + "@smithy/types" "^4.14.1" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@smithy/middleware-content-length@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz#d8b17f94c4d8f9c3b7992f1db84d3299c83efe78" + integrity sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw== + dependencies: + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/middleware-endpoint@^4.4.32": + version "4.4.32" + resolved "https://registry.yarnpkg.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.32.tgz#4c7dcf06b637b40dfcc53d3b18d1a784a747c530" + integrity sha512-ZZkgyjnJppiZbIm6Qbx92pbXYi1uzenIvGhBSCDlc7NwuAkiqSgS75j1czAD25ZLs2FjMjYy1q7gyRVWG6JA0Q== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/middleware-serde" "^4.2.20" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + "@smithy/url-parser" "^4.2.14" + "@smithy/util-middleware" "^4.2.14" + tslib "^2.6.2" + +"@smithy/middleware-retry@^4.5.7": + version "4.5.7" + resolved "https://registry.yarnpkg.com/@smithy/middleware-retry/-/middleware-retry-4.5.7.tgz#a2da0c472d631ee408ff566186c99571b3efb70b" + integrity sha512-bRt6ZImqVSeTk39Nm81K20ObIiAZ3WefY7G6+iz/0tZjs4dgRRjvRX2sgsH+zi6iDCRR/aQvQofLKxxz4rPBZg== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/service-error-classification" "^4.3.1" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-retry" "^4.3.6" + "@smithy/uuid" "^1.1.2" + tslib "^2.6.2" + +"@smithy/middleware-serde@^4.2.20": + version "4.2.20" + resolved "https://registry.yarnpkg.com/@smithy/middleware-serde/-/middleware-serde-4.2.20.tgz#76862c8f9b39b08501539440a2e6bca7a77de508" + integrity sha512-Lx9JMO9vArPtiChE3wbEZ5akMIDQpWQtlu90lhACQmNOXcGXRbaDywMHDzuDZ2OkZzP+9wQfZi3YJT9F67zTQQ== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/middleware-stack@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz#23a4cf643ccdbde52c8780fe5cc080611efef1c7" + integrity sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/node-config-provider@^4.3.14": + version "4.3.14" + resolved "https://registry.yarnpkg.com/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz#8ca13b86b6123cbb0425d669bd847fcd333ca4bd" + integrity sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg== + dependencies: + "@smithy/property-provider" "^4.2.14" + "@smithy/shared-ini-file-loader" "^4.4.9" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/node-http-handler@^4.6.1": + version "4.6.1" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.6.1.tgz#cb25b9445e46294a6f0dfb1566dbf2a1a19510af" + integrity sha512-iB+orM4x3xrr57X3YaXazfKnntl0LHlZB1kcXSGzMV1Tt0+YwEjGlbjk/44qEGtBzXAz6yFDzkYTKSV6Pj2HUg== + dependencies: + "@smithy/protocol-http" "^5.3.14" + "@smithy/querystring-builder" "^4.2.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/property-provider@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/property-provider/-/property-provider-4.2.14.tgz#8072418672d8c29d3f9ef35e452437ba2c59100a" + integrity sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/protocol-http@^5.3.14": + version "5.3.14" + resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.3.14.tgz#ed1e65cdb0fffb7fd00dce997c04baa236f180cc" + integrity sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/querystring-builder@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz#102429e0fb004108babf219edfcf6f111e66d782" + integrity sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A== + dependencies: + "@smithy/types" "^4.14.1" + "@smithy/util-uri-escape" "^4.2.2" + tslib "^2.6.2" + +"@smithy/querystring-parser@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz#c479ba1f346656b9f8ce46d9a91c229e4e50420f" + integrity sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/service-error-classification@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@smithy/service-error-classification/-/service-error-classification-4.3.1.tgz#5303d4fc3c3eea0f79c3b88cb4436498a31e9f12" + integrity sha512-aUQuDGh760ts/8MU+APjIZhlLPKhIIfqyzZaJikLEIMrdxFvxuLYD0WxWzaYWpmLbQlXDe9p7EWM3HsBe0K6Gw== + dependencies: + "@smithy/types" "^4.14.1" + +"@smithy/shared-ini-file-loader@^4.4.9": + version "4.4.9" + resolved "https://registry.yarnpkg.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz#fb3719b401d101a65a682380b40efd3a116162f0" + integrity sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/signature-v4@^5.3.14": + version "5.3.14" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.3.14.tgz#2b28c7d190301a67a520227a2343d1e7bb1c6d22" + integrity sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA== + dependencies: + "@smithy/is-array-buffer" "^4.2.2" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-middleware" "^4.2.14" + "@smithy/util-uri-escape" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@smithy/smithy-client@^4.12.13": + version "4.12.13" + resolved "https://registry.yarnpkg.com/@smithy/smithy-client/-/smithy-client-4.12.13.tgz#dec184a1d2d5027370ae1582bddbdbc068c97da5" + integrity sha512-y/Pcj1V9+qG98gyu1gvftHB7rDpdh+7kIBIggs55yGm3JdtBV8GT8IFF3a1qxZ79QnaJHX9GXzvBG6tAd+czJA== + dependencies: + "@smithy/core" "^3.23.17" + "@smithy/middleware-endpoint" "^4.4.32" + "@smithy/middleware-stack" "^4.2.14" + "@smithy/protocol-http" "^5.3.14" + "@smithy/types" "^4.14.1" + "@smithy/util-stream" "^4.5.25" + tslib "^2.6.2" + +"@smithy/types@^4.14.1": + version "4.14.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.14.1.tgz#aba92b4cdb406f2a2b062e82f1e3728d809a7c23" + integrity sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg== + dependencies: + tslib "^2.6.2" + +"@smithy/url-parser@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/url-parser/-/url-parser-4.2.14.tgz#349a442a62eb5907533f204b73a010618198b073" + integrity sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ== + dependencies: + "@smithy/querystring-parser" "^4.2.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/util-base64@^4.3.2": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@smithy/util-base64/-/util-base64-4.3.2.tgz#be02bcb29a87be744356467ea25ffa413e695cea" + integrity sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ== + dependencies: + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@smithy/util-body-length-browser@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz#c4404277d22039872abdb80e7800f9a63f263862" + integrity sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ== + dependencies: + tslib "^2.6.2" + +"@smithy/util-body-length-node@^4.2.3": + version "4.2.3" + resolved "https://registry.yarnpkg.com/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz#f923ca530defb86a9ac3ca2d3066bcca7b304fbc" + integrity sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g== + dependencies: + tslib "^2.6.2" + +"@smithy/util-buffer-from@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz#6fc88585165ec73f8681d426d96de5d402021e4b" + integrity sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA== + dependencies: + "@smithy/is-array-buffer" "^2.2.0" + tslib "^2.6.2" + +"@smithy/util-buffer-from@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz#2c6b7857757dfd88f6cd2d36016179a40ccc913b" + integrity sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q== + dependencies: + "@smithy/is-array-buffer" "^4.2.2" + tslib "^2.6.2" + +"@smithy/util-config-provider@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz#52ebf9d8942838d18bc5fb1520de1e8699d7aad6" + integrity sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ== + dependencies: + tslib "^2.6.2" + +"@smithy/util-defaults-mode-browser@^4.3.49": + version "4.3.49" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.49.tgz#926ce84bf65e56307f25cce7a13b427d33442939" + integrity sha512-a5bNrdiONYB/qE2BuKegvUMd/+ZDwdg4vsNuuSzYE8qs2EYAdK9CynL+Rzn29PbPiUqoz/cbpRbcLzD5lEevHw== + dependencies: + "@smithy/property-provider" "^4.2.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/util-defaults-mode-node@^4.2.54": + version "4.2.54" + resolved "https://registry.yarnpkg.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.54.tgz#32c4ea9f8a8c74ef9fe0ca5e3d6a10df0327f87e" + integrity sha512-g1cvrJvOnzeJgEdf7AE4luI7gp6L8weE0y9a9wQUSGtjb8QRHDbCJYuE4Sy0SD9N8RrnNPFsPltAz/OSoBR9Zw== + dependencies: + "@smithy/config-resolver" "^4.4.17" + "@smithy/credential-provider-imds" "^4.2.14" + "@smithy/node-config-provider" "^4.3.14" + "@smithy/property-provider" "^4.2.14" + "@smithy/smithy-client" "^4.12.13" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/util-endpoints@^3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@smithy/util-endpoints/-/util-endpoints-3.4.2.tgz#ee59c42d039a642b6c6eb2d38e0ae3db6fc48e97" + integrity sha512-a55Tr+3OKld4TTtnT+RhKOQHyPxm3j/xL4OR83WBUhLJaKDS9dnJ7arRMOp3t31dcLhApwG9bgvrRXBHlLdIkg== + dependencies: + "@smithy/node-config-provider" "^4.3.14" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/util-hex-encoding@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz#4abf3335dd1eb884041d8589ca7628d81a6fd1d3" + integrity sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg== + dependencies: + tslib "^2.6.2" + +"@smithy/util-middleware@^4.2.14": + version "4.2.14" + resolved "https://registry.yarnpkg.com/@smithy/util-middleware/-/util-middleware-4.2.14.tgz#9985dd82b4036db2d03835229b9b0c63d2bb85fa" + integrity sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/util-retry@^4.3.6": + version "4.3.8" + resolved "https://registry.yarnpkg.com/@smithy/util-retry/-/util-retry-4.3.8.tgz#7f904ed8e5bad2b5f2e6aa1e193db2b46b2c57df" + integrity sha512-LUIxbTBi+OpvXpg91poGA6BdyoleMDLnfXjVDqyi2RvZmTveY5loE/FgYUBCR5LU2BThW2SoZRh8dTIIy38IPw== + dependencies: + "@smithy/service-error-classification" "^4.3.1" + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/util-stream@^4.5.25": + version "4.5.25" + resolved "https://registry.yarnpkg.com/@smithy/util-stream/-/util-stream-4.5.25.tgz#f48385a284151c7e099395af4e5fb0978fffe4ff" + integrity sha512-/PFpG4k8Ze8Ei+mMKj3oiPICYekthuzePZMgZbCqMiXIHHf4n2aZ4Ps0aSRShycFTGuj/J6XldmC0x0DwednIA== + dependencies: + "@smithy/fetch-http-handler" "^5.3.17" + "@smithy/node-http-handler" "^4.6.1" + "@smithy/types" "^4.14.1" + "@smithy/util-base64" "^4.3.2" + "@smithy/util-buffer-from" "^4.2.2" + "@smithy/util-hex-encoding" "^4.2.2" + "@smithy/util-utf8" "^4.2.2" + tslib "^2.6.2" + +"@smithy/util-uri-escape@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz#48e40206e7fe9daefc8d44bb43a1ab17e76abf4a" + integrity sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw== + dependencies: + tslib "^2.6.2" + +"@smithy/util-utf8@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz#dd96d7640363259924a214313c3cf16e7dd329c5" + integrity sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A== + dependencies: + "@smithy/util-buffer-from" "^2.2.0" + tslib "^2.6.2" + +"@smithy/util-utf8@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-4.2.2.tgz#21db686982e6f3393ac262e49143b42370130f13" + integrity sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw== + dependencies: + "@smithy/util-buffer-from" "^4.2.2" + tslib "^2.6.2" + +"@smithy/util-waiter@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@smithy/util-waiter/-/util-waiter-4.3.0.tgz#6122ce27939edb5550d1d6c7c8d506323f3a17f7" + integrity sha512-JyjYmLAfS+pdxF92o4yLgEoy0zhayKTw73FU1aofLWwLcJw7iSqIY2exGmMTrl/lmZugP5p/zxdFSippJDfKWA== + dependencies: + "@smithy/types" "^4.14.1" + tslib "^2.6.2" + +"@smithy/uuid@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@smithy/uuid/-/uuid-1.1.2.tgz#b6e97c7158615e4a3c775e809c00d8c269b5a12e" + integrity sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g== + dependencies: + tslib "^2.6.2" + +"@so-ric/colorspace@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@so-ric/colorspace/-/colorspace-1.1.6.tgz#62515d8b9f27746b76950a83bde1af812d91923b" + integrity sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw== + dependencies: + color "^5.0.2" + text-hex "1.0.x" + +"@standard-schema/spec@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@tsconfig/node10@^1.0.7": + version "1.0.12" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.12.tgz#be57ceac1e4692b41be9de6be8c32a106636dba4" + integrity sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" + integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== + +"@types/aws-lambda@8.10.161": + version "8.10.161" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.161.tgz#36d95723ec46d3d555bf0684f83cf4d4369a28ad" + integrity sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ== + +"@types/babel__core@^7.1.14": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.27.0.tgz#b5819294c51179957afaec341442f9341e4108a9" + integrity sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz#07d713d6cce0d265c9849db0cbe62d3f61f36f74" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== + dependencies: + "@babel/types" "^7.28.2" + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/graceful-fs@^4.1.3": + version "4.1.9" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" + integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== + dependencies: + "@types/node" "*" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/istanbul-lib-report@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf" + integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.0", "@types/istanbul-reports@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" + integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jest@^29.5.12": + version "29.5.14" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" + integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@*": + version "25.2.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.1.tgz#378021f9e765bb65ba36de16f3c3a8622c1fa03d" + integrity sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg== + dependencies: + undici-types "~7.16.0" + +"@types/node@25.6.0": + version "25.6.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" + integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== + dependencies: + undici-types "~7.19.0" + +"@types/nodemailer@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-8.0.0.tgz#ea189a9c151c04cc65c8a2a4c668c65d952a24e2" + integrity sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA== + dependencies: + "@types/node" "*" + +"@types/prop-types@*": + version "15.7.15" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== + +"@types/react@^18.3.12": + version "18.3.28" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.28.tgz#0a85b1a7243b4258d9f626f43797ba18eb5f8781" + integrity sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw== + dependencies: + "@types/prop-types" "*" + csstype "^3.2.2" + +"@types/sinon@^17.0.3": + version "17.0.4" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.4.tgz#fd9a3e8e07eea1a3f4a6f82a972c899e5778f369" + integrity sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "15.0.1" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz#49f731d9453f52d64dd79f5a5626c1cf1b81bea4" + integrity sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w== + +"@types/stack-utils@^2.0.0", "@types/stack-utils@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" + integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== + +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + +"@types/yargs-parser@*": + version "21.0.3" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + +"@types/yargs@^17.0.33", "@types/yargs@^17.0.8": + version "17.0.35" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.35.tgz#07013e46aa4d7d7d50a49e15604c1c5340d4eb24" + integrity sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg== + dependencies: + "@types/yargs-parser" "*" + +"@typescript-eslint/eslint-plugin@^8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz#cb53038b83d165ca0ef96d67d875efbd56c50fa8" + integrity sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.58.1" + "@typescript-eslint/type-utils" "8.58.1" + "@typescript-eslint/utils" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@^8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.58.1.tgz#0943eca522ac408bcdd649882c3d95b10ff00f62" + integrity sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw== + dependencies: + "@typescript-eslint/scope-manager" "8.58.1" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/typescript-estree" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.58.1.tgz#c78781b1ca1ec1e7bc6522efba89318c6d249feb" + integrity sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.58.1" + "@typescript-eslint/types" "^8.58.1" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz#35168f561bab4e3fd10dd6b03e8b83c157479211" + integrity sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w== + dependencies: + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" + +"@typescript-eslint/tsconfig-utils@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz#eb16792c579300c7bfb3c74b0f5e1dfbb0a2454d" + integrity sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw== + +"@typescript-eslint/tsconfig-utils@^8.58.1": + version "8.58.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz#fa13f96432c9348bf87f6f44826def585fad7bca" + integrity sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A== + +"@typescript-eslint/type-utils@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz#b21085a233087bde94c92ba6f5b4dfb77ca56730" + integrity sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w== + dependencies: + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/typescript-estree" "8.58.1" + "@typescript-eslint/utils" "8.58.1" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.58.1.tgz#9dfb4723fcd2b13737d8b03d941354cf73190313" + integrity sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw== + +"@typescript-eslint/types@^8.58.1": + version "8.58.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.58.2.tgz#3ab8051de0f19a46ddefb0749d0f7d82974bd57c" + integrity sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ== + +"@typescript-eslint/typescript-estree@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz#8230cc9628d2cffef101e298c62807c4b9bf2fe9" + integrity sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg== + dependencies: + "@typescript-eslint/project-service" "8.58.1" + "@typescript-eslint/tsconfig-utils" "8.58.1" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/visitor-keys" "8.58.1" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.58.1.tgz#099a327b04ed921e6ee3988cde9ef34bc4b5435a" + integrity sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.58.1" + "@typescript-eslint/types" "8.58.1" + "@typescript-eslint/typescript-estree" "8.58.1" + +"@typescript-eslint/visitor-keys@8.58.1": + version "8.58.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz#7c197533177f1ba9b8249f55f7f685e32bb6f204" + integrity sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ== + dependencies: + "@typescript-eslint/types" "8.58.1" + eslint-visitor-keys "^5.0.0" + +"@vitest/expect@>1.6.0": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.0.18.tgz#361510d99fbf20eb814222e4afcb8539d79dc94d" + integrity sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ== + dependencies: + "@standard-schema/spec" "^1.0.0" + "@types/chai" "^5.2.2" + "@vitest/spy" "4.0.18" + "@vitest/utils" "4.0.18" + chai "^6.2.1" + tinyrainbow "^3.0.3" + +"@vitest/pretty-format@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.0.18.tgz#fbccd4d910774072ec15463553edb8ca5ce53218" + integrity sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw== + dependencies: + tinyrainbow "^3.0.3" + +"@vitest/spy@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.0.18.tgz#ba0f20503fb6d08baf3309d690b3efabdfa88762" + integrity sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw== + +"@vitest/utils@4.0.18": + version "4.0.18" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.0.18.tgz#9636b16d86a4152ec68a8d6859cff702896433d4" + integrity sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA== + dependencies: + "@vitest/pretty-format" "4.0.18" + tinyrainbow "^3.0.3" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn-walk@^8.1.1: + version "8.3.4" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.3.4.tgz#794dd169c3977edf4ba4ea47583587c5866236b7" + integrity sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g== + dependencies: + acorn "^8.11.0" + +acorn@^8.11.0, acorn@^8.4.1: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +acorn@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + +ajv@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0, ansi-styles@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + +anymatch@^3.0.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +assertion-error@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +async@^3.2.3: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + +aws-sdk-client-mock-jest@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/aws-sdk-client-mock-jest/-/aws-sdk-client-mock-jest-4.1.0.tgz#40a3bdedd8d551cf2a836b77239038c0ca10e25c" + integrity sha512-+g4a5Hp+MmPqqNnvwfLitByggrqf+xSbk1pm6fBYHNcon6+aQjL5iB+3YB6HuGPemY+/mUKN34iP62S14R61bA== + dependencies: + "@vitest/expect" ">1.6.0" + expect ">28.1.3" + tslib "^2.1.0" + +aws-sdk-client-mock@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz#ae1950b2277f8e65f9a039975d79ff9fffab39e3" + integrity sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw== + dependencies: + "@types/sinon" "^17.0.3" + sinon "^18.0.1" + tslib "^2.1.0" + +babel-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" + integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== + dependencies: + "@jest/transform" "^29.7.0" + "@types/babel__core" "^7.1.14" + babel-plugin-istanbul "^6.1.1" + babel-preset-jest "^29.6.3" + chalk "^4.0.0" + graceful-fs "^4.2.9" + slash "^3.0.0" + +babel-plugin-istanbul@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" + integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^5.0.4" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" + integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.1.14" + "@types/babel__traverse" "^7.0.6" + +babel-preset-current-node-syntax@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz#20730d6cdc7dda5d89401cab10ac6a32067acde6" + integrity sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.12.13" + "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/plugin-syntax-import-attributes" "^7.24.7" + "@babel/plugin-syntax-import-meta" "^7.10.4" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/plugin-syntax-top-level-await" "^7.14.5" + +babel-preset-jest@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" + integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== + dependencies: + babel-plugin-jest-hoist "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +baseline-browser-mapping@^2.9.0: + version "2.9.19" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" + integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== + +bowser@^2.11.0: + version "2.13.1" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.13.1.tgz#5a4c652de1d002f847dd011819f5fc729f308a7e" + integrity sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw== + +brace-expansion@^1.1.7: + version "1.1.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + +brace-expansion@^5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== + dependencies: + balanced-match "^4.0.2" + +braces@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + +browser-stdout@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +browserslist@^4.24.0: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== + dependencies: + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" + +bs-logger@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.0.0, camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001759: + version "1.0.30001769" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz#1ad91594fad7dc233777c2781879ab5409f7d9c2" + integrity sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg== + +chai-match-pattern@^1.1.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/chai-match-pattern/-/chai-match-pattern-1.3.0.tgz#cefd4437de465860f4f87922c31049eb9d979104" + integrity sha512-DflyfI8lZ56YuYAZMTBPWghjqFQfqY1IR0ZZXrjlGZJuRvtN0TjJMBpLsrMfc45kjivXJ06iayuP7lzG6ij1bQ== + dependencies: + lodash-match-pattern "^2.3.1" + +chai@^4.1.2: + version "4.5.0" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8" + integrity sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw== + dependencies: + assertion-error "^1.1.0" + check-error "^1.0.3" + deep-eql "^4.1.3" + get-func-name "^2.0.2" + loupe "^2.3.6" + pathval "^1.1.1" + type-detect "^4.1.0" + +chai@^6.2.1: + version "6.2.2" + resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== + +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +check-error@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" + integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== + dependencies: + get-func-name "^2.0.2" + +checkit@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/checkit/-/checkit-0.7.0.tgz#14979abc93018346bfcfdcbabc19ab54c0bfd74a" + integrity sha512-QgiWB8gMdF/CbmWyuxCk+f2MPQe0G1DfJfHCTbrfZlY3FnJWdnW+EGsRJctcYz/IrXxPYJmjRjdgmKUkyIZl/Q== + dependencies: + inherits "^2.0.1" + lodash "^4.0.0" + +chokidar@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +ci-info@^3.2.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + +ci-info@^4.2.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-4.4.0.tgz#7d54eff9f54b45b62401c26032696eb59c8bd18c" + integrity sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg== + +cjs-module-lexer@^1.0.0: + version "1.4.3" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz#0f79731eb8cfe1ec72acd4066efac9d61991b00d" + integrity sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + +collect-v8-coverage@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz#cc1f01eb8d02298cbc9a437c74c70ab4e5210b80" + integrity sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw== + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-convert@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-3.1.3.tgz#db6627b97181cb8facdfce755ae26f97ab0711f1" + integrity sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg== + dependencies: + color-name "^2.0.0" + +color-name@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693" + integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^2.1.3: + version "2.1.4" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.4.tgz#9dcf566ff976e23368c8bd673f5c35103ab41058" + integrity sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg== + dependencies: + color-name "^2.0.0" + +color@^5.0.2: + version "5.0.3" + resolved "https://registry.yarnpkg.com/color/-/color-5.0.3.tgz#f79390b1b778e222ffbb54304d3dbeaef633f97f" + integrity sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA== + dependencies: + color-convert "^3.1.3" + color-string "^2.1.3" + +commander@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +create-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" + integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-config "^29.7.0" + jest-util "^29.7.0" + prompts "^2.0.1" + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +cross-spawn@^7.0.3, cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + +debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.5, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +dedent@^1.0.0: + version "1.7.1" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.7.1.tgz#364661eea3d73f3faba7089214420ec2f8f13e15" + integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg== + +deep-eql@^4.1.3: + version "4.1.4" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.4.tgz#d0d3912865911bb8fac5afb4e3acfa6a28dc72b7" + integrity sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg== + dependencies: + type-detect "^4.0.0" + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +deepmerge@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +detect-newline@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + +diff@^4.0.1: + version "4.0.4" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.4.tgz#7a6dbfda325f25f07517e9b518f897c08332e07d" + integrity sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ== + +diff@^5.2.0: + version "5.2.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.2.tgz#0a4742797281d09cfa699b79ea32d27723623bad" + integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A== + +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +dotenv@^16.3.1: + version "16.6.1" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" + integrity sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow== + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +electron-to-chromium@^1.5.263: + version "1.5.286" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" + integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== + +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + +entities@^4.2.0, entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +error-ex@^1.3.1: + version "1.3.4" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.4.tgz#b3a8d8bb6f92eecc1629e3e27d3c8607a8a32414" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== + dependencies: + is-arrayish "^0.2.1" + +esbuild@0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" + +esbuild@~0.27.0: + version "0.27.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" + integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== + optionalDependencies: + "@esbuild/aix-ppc64" "0.27.3" + "@esbuild/android-arm" "0.27.3" + "@esbuild/android-arm64" "0.27.3" + "@esbuild/android-x64" "0.27.3" + "@esbuild/darwin-arm64" "0.27.3" + "@esbuild/darwin-x64" "0.27.3" + "@esbuild/freebsd-arm64" "0.27.3" + "@esbuild/freebsd-x64" "0.27.3" + "@esbuild/linux-arm" "0.27.3" + "@esbuild/linux-arm64" "0.27.3" + "@esbuild/linux-ia32" "0.27.3" + "@esbuild/linux-loong64" "0.27.3" + "@esbuild/linux-mips64el" "0.27.3" + "@esbuild/linux-ppc64" "0.27.3" + "@esbuild/linux-riscv64" "0.27.3" + "@esbuild/linux-s390x" "0.27.3" + "@esbuild/linux-x64" "0.27.3" + "@esbuild/netbsd-arm64" "0.27.3" + "@esbuild/netbsd-x64" "0.27.3" + "@esbuild/openbsd-arm64" "0.27.3" + "@esbuild/openbsd-x64" "0.27.3" + "@esbuild/openharmony-arm64" "0.27.3" + "@esbuild/sunos-x64" "0.27.3" + "@esbuild/win32-arm64" "0.27.3" + "@esbuild/win32-ia32" "0.27.3" + "@esbuild/win32-x64" "0.27.3" + +escalade@^3.1.1, escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@10.2.1: + version "10.2.1" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.2.1.tgz#224b2a6caeb34473eddcf918762363e2e063222a" + integrity sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.5.5" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.1" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.4" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== + +expect@>28.1.3: + version "30.2.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-30.2.0.tgz#d4013bed267013c14bc1199cec8aa57cee9b5869" + integrity sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw== + dependencies: + "@jest/expect-utils" "30.2.0" + "@jest/get-type" "30.1.0" + jest-matcher-utils "30.2.0" + jest-message-util "30.2.0" + jest-mock "30.2.0" + jest-util "30.2.0" + +expect@^29.0.0, expect@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== + dependencies: + "@jest/expect-utils" "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-xml-builder@1.2.0, fast-xml-builder@^1.1.7: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz#abd2363145a7625d9789ad96da375fabe3cff28c" + integrity sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q== + dependencies: + path-expression-matcher "^1.5.0" + xml-naming "^0.1.0" + +fast-xml-parser@5.7.2, fast-xml-parser@5.7.3: + version "5.7.3" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz#309b04b08d835defc62ab657a0bb340c0e0fbe6a" + integrity sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg== + dependencies: + "@nodable/entities" "^2.1.0" + fast-xml-builder "^1.1.7" + path-expression-matcher "^1.5.0" + strnum "^2.2.3" + +fb-watchman@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== + dependencies: + to-regex-range "^5.0.1" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@^2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-func-name@^2.0.1, get-func-name@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" + integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +get-tsconfig@^4.7.5: + version "4.13.6" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.13.6.tgz#2fbfda558a98a691a798f123afd95915badce876" + integrity sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw== + dependencies: + resolve-pkg-maps "^1.0.0" + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^10.4.5: + version "10.5.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +glob@^7.1.3, glob@^7.1.4: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +graceful-fs@^4.2.11, graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +handlebars@^4.7.9: + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== + dependencies: + minimist "^1.2.5" + neo-async "^2.6.2" + source-map "^0.6.1" + wordwrap "^1.0.0" + optionalDependencies: + uglify-js "^3.1.4" + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +hasown@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +he@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +htmlparser2@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +import-local@^3.0.2: + version "3.2.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.2.0.tgz#c3d5c745798c02a6f8b897726aba5100186ee260" + integrity sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@^2.0.1, inherits@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-path-inside@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" + integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-instrument@^5.0.4: + version "5.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" + integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== + dependencies: + "@babel/core" "^7.12.3" + "@babel/parser" "^7.14.7" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.2.0" + semver "^6.3.0" + +istanbul-lib-instrument@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz#fa15401df6c15874bcb2105f773325d78c666765" + integrity sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.1.3: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jest-changed-files@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" + integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== + dependencies: + execa "^5.0.0" + jest-util "^29.7.0" + p-limit "^3.1.0" + +jest-circus@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" + integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + co "^4.6.0" + dedent "^1.0.0" + is-generator-fn "^2.0.0" + jest-each "^29.7.0" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + p-limit "^3.1.0" + pretty-format "^29.7.0" + pure-rand "^6.0.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-cli@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" + integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== + dependencies: + "@jest/core" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + chalk "^4.0.0" + create-jest "^29.7.0" + exit "^0.1.2" + import-local "^3.0.2" + jest-config "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + yargs "^17.3.1" + +jest-config@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" + integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== + dependencies: + "@babel/core" "^7.11.6" + "@jest/test-sequencer" "^29.7.0" + "@jest/types" "^29.6.3" + babel-jest "^29.7.0" + chalk "^4.0.0" + ci-info "^3.2.0" + deepmerge "^4.2.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-circus "^29.7.0" + jest-environment-node "^29.7.0" + jest-get-type "^29.6.3" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-runner "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + micromatch "^4.0.4" + parse-json "^5.2.0" + pretty-format "^29.7.0" + slash "^3.0.0" + strip-json-comments "^3.1.1" + +jest-diff@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-30.2.0.tgz#e3ec3a6ea5c5747f605c9e874f83d756cba36825" + integrity sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A== + dependencies: + "@jest/diff-sequences" "30.0.1" + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + pretty-format "30.2.0" + +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-docblock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" + integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== + dependencies: + detect-newline "^3.0.0" + +jest-each@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" + integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + jest-get-type "^29.6.3" + jest-util "^29.7.0" + pretty-format "^29.7.0" + +jest-environment-node@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" + integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + +jest-haste-map@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" + integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== + dependencies: + "@jest/types" "^29.6.3" + "@types/graceful-fs" "^4.1.3" + "@types/node" "*" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.9" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + jest-worker "^29.7.0" + micromatch "^4.0.4" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.2" + +jest-leak-detector@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" + integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== + dependencies: + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-matcher-utils@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz#69a0d4c271066559ec8b0d8174829adc3f23a783" + integrity sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg== + dependencies: + "@jest/get-type" "30.1.0" + chalk "^4.1.2" + jest-diff "30.2.0" + pretty-format "30.2.0" + +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-message-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-30.2.0.tgz#fc97bf90d11f118b31e6131e2b67fc4f39f92152" + integrity sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@jest/types" "30.2.0" + "@types/stack-utils" "^2.0.3" + chalk "^4.1.2" + graceful-fs "^4.2.11" + micromatch "^4.0.8" + pretty-format "30.2.0" + slash "^3.0.0" + stack-utils "^2.0.6" + +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-mock@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-30.2.0.tgz#69f991614eeb4060189459d3584f710845bff45e" + integrity sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + jest-util "30.2.0" + +jest-mock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" + integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-util "^29.7.0" + +jest-pnp-resolver@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" + integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== + +jest-regex-util@30.0.1: + version "30.0.1" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-30.0.1.tgz#f17c1de3958b67dfe485354f5a10093298f2a49b" + integrity sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA== + +jest-regex-util@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" + integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== + +jest-resolve-dependencies@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" + integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== + dependencies: + jest-regex-util "^29.6.3" + jest-snapshot "^29.7.0" + +jest-resolve@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" + integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== + dependencies: + chalk "^4.0.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-pnp-resolver "^1.2.2" + jest-util "^29.7.0" + jest-validate "^29.7.0" + resolve "^1.20.0" + resolve.exports "^2.0.0" + slash "^3.0.0" + +jest-runner@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" + integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== + dependencies: + "@jest/console" "^29.7.0" + "@jest/environment" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + emittery "^0.13.1" + graceful-fs "^4.2.9" + jest-docblock "^29.7.0" + jest-environment-node "^29.7.0" + jest-haste-map "^29.7.0" + jest-leak-detector "^29.7.0" + jest-message-util "^29.7.0" + jest-resolve "^29.7.0" + jest-runtime "^29.7.0" + jest-util "^29.7.0" + jest-watcher "^29.7.0" + jest-worker "^29.7.0" + p-limit "^3.1.0" + source-map-support "0.5.13" + +jest-runtime@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" + integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/globals" "^29.7.0" + "@jest/source-map" "^29.6.3" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + cjs-module-lexer "^1.0.0" + collect-v8-coverage "^1.0.0" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + slash "^3.0.0" + strip-bom "^4.0.0" + +jest-snapshot@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" + integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== + dependencies: + "@babel/core" "^7.11.6" + "@babel/generator" "^7.7.2" + "@babel/plugin-syntax-jsx" "^7.7.2" + "@babel/plugin-syntax-typescript" "^7.7.2" + "@babel/types" "^7.3.3" + "@jest/expect-utils" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + chalk "^4.0.0" + expect "^29.7.0" + graceful-fs "^4.2.9" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + natural-compare "^1.4.0" + pretty-format "^29.7.0" + semver "^7.5.3" + +jest-util@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-30.2.0.tgz#5142adbcad6f4e53c2776c067a4db3c14f913705" + integrity sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA== + dependencies: + "@jest/types" "30.2.0" + "@types/node" "*" + chalk "^4.1.2" + ci-info "^4.2.0" + graceful-fs "^4.2.11" + picomatch "^4.0.2" + +jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + +jest-validate@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" + integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== + dependencies: + "@jest/types" "^29.6.3" + camelcase "^6.2.0" + chalk "^4.0.0" + jest-get-type "^29.6.3" + leven "^3.1.0" + pretty-format "^29.7.0" + +jest-watcher@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" + integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== + dependencies: + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + emittery "^0.13.1" + jest-util "^29.7.0" + string-length "^4.0.1" + +jest-worker@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" + integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== + dependencies: + "@types/node" "*" + jest-util "^29.7.0" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.7.0.tgz#994676fc24177f088f1c5e3737f5697204ff2613" + integrity sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw== + dependencies: + "@jest/core" "^29.7.0" + "@jest/types" "^29.6.3" + import-local "^3.0.2" + jest-cli "^29.7.0" + +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0" + integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +js-yaml@^4.1.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + +lambda-local@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/lambda-local/-/lambda-local-2.2.0.tgz#733d183a4c3f2b16c6499b9ea72cec2f13278eef" + integrity sha512-bPcgpIXbHnVGfI/omZIlgucDqlf4LrsunwoKue5JdZeGybt8L6KyJz2Zu19ffuZwIwLj2NAI2ZyaqNT6/cetcg== + dependencies: + commander "^10.0.1" + dotenv "^16.3.1" + winston "^3.10.0" + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash-checkit@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/lodash-checkit/-/lodash-checkit-2.4.1.tgz#8b09c6b359a5d4de86f752ff9c231f1db5d23fd4" + integrity sha512-OAg5CqY04/dnsO8izxXqlleuj7z/dOk6yV0pm0TVtRaUwG5v2PGw4XWSIG/dLK0UWYk7g0/TCk8OCf50oVwv6w== + dependencies: + checkit "^0.7.0" + lodash "^4.17.21" + +lodash-match-pattern@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/lodash-match-pattern/-/lodash-match-pattern-2.3.1.tgz#d38f455a8b310bd91f7b2b4378297102a9b473c8" + integrity sha512-dpltpxoTqs94gGFm24VwHDyFh3/eNtqNjKrlnifIBLtnzYq0nAlNM6BIeLdGAfCWC/BwNtiLL1eKZTQpLVnY6A== + dependencies: + chalk "^4.1.0" + he "^1.2.0" + lodash-checkit "^2.4.1" + +lodash.memoize@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + +lodash@^4.0.0, lodash@^4.17.21: + version "4.17.23" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" + integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== + +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +logform@^2.7.0: + version "2.7.0" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.7.0.tgz#cfca97528ef290f2e125a08396805002b2d060d1" + integrity sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ== + dependencies: + "@colors/colors" "1.6.0" + "@types/triple-beam" "^1.3.2" + fecha "^4.2.0" + ms "^2.1.1" + safe-stable-stringify "^2.3.1" + triple-beam "^1.3.0" + +loose-envify@^1.1.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + +loupe@^2.3.6: + version "2.3.7" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" + integrity sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA== + dependencies: + get-func-name "^2.0.1" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +make-error@^1.1.1, make-error@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +marked@^12.0.2: + version "12.0.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-12.0.2.tgz#b31578fe608b599944c69807b00f18edab84647e" + integrity sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +micromatch@^4.0.4, micromatch@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== + dependencies: + braces "^3.0.3" + picomatch "^2.3.1" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@^10.2.2, minimatch@^10.2.4: + version "10.2.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^9.0.4, minimatch@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.5: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +mnemonist@0.38.3: + version "0.38.3" + resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.38.3.tgz#35ec79c1c1f4357cfda2fe264659c2775ccd7d9d" + integrity sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw== + dependencies: + obliterator "^1.6.1" + +mocha@^11.0.0: + version "11.7.5" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.5.tgz#58f5bbfa5e0211ce7e5ee6128107cefc2515a627" + integrity sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig== + dependencies: + browser-stdout "^1.3.1" + chokidar "^4.0.1" + debug "^4.3.5" + diff "^7.0.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^10.4.5" + he "^1.2.0" + is-path-inside "^3.0.3" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^9.0.5" + ms "^2.1.3" + picocolors "^1.1.1" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^9.2.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + yargs-unparser "^2.0.0" + +ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +nise@^6.0.0: + version "6.1.1" + resolved "https://registry.yarnpkg.com/nise/-/nise-6.1.1.tgz#78ea93cc49be122e44cb7c8fdf597b0e8778b64a" + integrity sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^13.0.1" + "@sinonjs/text-encoding" "^0.7.3" + just-extend "^6.2.0" + path-to-regexp "^8.1.0" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== + +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== + +nodemailer@^8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-8.0.5.tgz#2076fb2b5c1ccfe1c88f6e1aa47c0229ea642e0c" + integrity sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w== + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +obliterator@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-1.6.1.tgz#dea03e8ab821f6c4d96a299e17aef6a3af994ef3" + integrity sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig== + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2, p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-expression-matcher@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a" + integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +path-to-regexp@^8.1.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" + integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== + +pathval@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" + integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + +pirates@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +postcss@8.5.10, postcss@^8.3.11: + version "8.5.10" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.10.tgz#8992d8c30acf3f12169e7c09514a12fed7e48356" + integrity sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +pretty-format@30.2.0: + version "30.2.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-30.2.0.tgz#2d44fe6134529aed18506f6d11509d8a62775ebe" + integrity sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA== + dependencies: + "@jest/schemas" "30.0.5" + ansi-styles "^5.2.0" + react-is "^18.3.1" + +pretty-format@^29.0.0, pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +prompts@^2.0.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +pure-rand@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" + integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +react-dom@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" + integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react-is@^18.0.0, react-is@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +react@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + +readable-stream@^3.4.0, readable-stream@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + +resolve.exports@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.3.tgz#41955e6f1b4013b7586f873749a635dea07ebe3f" + integrity sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A== + +resolve@^1.20.0: + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== + dependencies: + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +safe-buffer@^5.1.0, safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +safe-stable-stringify@^2.3.1: + version "2.5.0" + resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd" + integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA== + +sanitize-html@^2.17.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.17.0.tgz#a8f66420a6be981d8fe412e3397cc753782598e4" + integrity sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA== + dependencies: + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^8.0.0" + is-plain-object "^5.0.0" + parse-srcset "^1.0.2" + postcss "^8.3.11" + +scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + +semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.5.3, semver@^7.5.4, semver@^7.7.3, semver@^7.7.4: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.3, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +sinon@^18.0.1: + version "18.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-18.0.1.tgz#464334cdfea2cddc5eda9a4ea7e2e3f0c7a91c5e" + integrity sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.2.0" + nise "^6.0.0" + supports-color "^7" + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + +stack-utils@^2.0.3, stack-utils@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +string-length@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.0.0" + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +strnum@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.2.3.tgz#0119fce02749a11bb126a4d686ac5dbdf6e57586" + integrity sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg== + +supports-color@^7, supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.0.0, supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinyrainbow@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.0.3.tgz#984a5b1c1b25854a9b6bccbe77964d0593d1ea42" + integrity sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q== + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + +ts-jest@^29.4.9: + version "29.4.9" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.4.9.tgz#47dc33d0f5c36bddcedd16afefae285e0b049d2d" + integrity sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ== + dependencies: + bs-logger "^0.2.6" + fast-json-stable-stringify "^2.1.0" + handlebars "^4.7.9" + json5 "^2.2.3" + lodash.memoize "^4.1.2" + make-error "^1.3.6" + semver "^7.7.4" + type-fest "^4.41.0" + yargs-parser "^21.1.1" + +ts-node@^10.9.2: + version "10.9.2" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.2.tgz#70f021c9e185bccdca820e26dc413805c101c71f" + integrity sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +tslib@^2.1.0, tslib@^2.6.2, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tsx@^4.19.2: + version "4.21.0" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.21.0.tgz#32aa6cf17481e336f756195e6fe04dae3e6308b1" + integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== + dependencies: + esbuild "~0.27.0" + get-tsconfig "^4.7.5" + optionalDependencies: + fsevents "~2.3.3" + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-detect@^4.0.0, type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +type-fest@^4.41.0: + version "4.41.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" + integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA== + +typescript@6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +uglify-js@^3.1.4: + version "3.19.3" + resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.19.3.tgz#82315e9bbc6f2b25888858acd1fff8441035b77f" + integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== + +undici-types@~7.16.0: + version "7.16.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" + integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== + +undici-types@~7.19.0: + version "7.19.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.19.2.tgz#1b67fc26d0f157a0cba3a58a5b5c1e2276b8ba2a" + integrity sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg== + +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +v8-to-istanbul@^9.0.1: + version "9.3.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" + +walker@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +winston-transport@^4.9.0: + version "4.9.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.9.0.tgz#3bba345de10297654ea6f33519424560003b3bf9" + integrity sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A== + dependencies: + logform "^2.7.0" + readable-stream "^3.6.2" + triple-beam "^1.3.0" + +winston@^3.10.0: + version "3.19.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.19.0.tgz#cc1d1262f5f45946904085cfffe73efb4b7a581d" + integrity sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.8" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.7.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.9.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + +workerpool@^9.2.0: + version "9.3.4" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" + integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" + integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^3.0.7" + +xml-naming@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8" + integrity sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs-unparser@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@^17.3.1, yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.23.8: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== diff --git a/backend/social-work-app/lambdas/python/cognito-backup/handlers/cognito_backup.py b/backend/social-work-app/lambdas/python/cognito-backup/handlers/cognito_backup.py new file mode 100644 index 0000000000..6ee51d265e --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/handlers/cognito_backup.py @@ -0,0 +1,222 @@ +""" +Cognito user pool backup handlers. + +This module contains the CognitoBackupExporter class and related functionality +for exporting Cognito user pool data to S3 for backup purposes. +""" + +import json +from datetime import UTC, datetime +from typing import Any + +import boto3 +from aws_lambda_powertools.logging import Logger +from botocore.exceptions import ClientError + +# Configure logging +logger = Logger() + + +class CognitoBackupExporter: + """ + Exports Cognito user pool data to S3 for backup purposes. + + This class handles the export of all users from a single Cognito user pool, + storing each user as a separate JSON file with comprehensive user data + including attributes, status, and metadata. + """ + + def __init__(self, user_pool_id: str, backup_bucket_name: str): + """ + Initialize the Cognito backup exporter. + + :param user_pool_id: The ID of the Cognito user pool to export + :param backup_bucket_name: The name of the S3 bucket to store exports + """ + self.user_pool_id = user_pool_id + self.backup_bucket_name = backup_bucket_name + + # Initialize AWS clients + self.cognito_client = boto3.client('cognito-idp') + self.s3_client = boto3.client('s3') + + logger.info('Initialized Cognito backup exporter', user_pool_id=user_pool_id, backup_bucket=backup_bucket_name) + + def export_user_pool(self) -> dict[str, Any]: + """ + Export all users from the specified user pool to S3. + + :return: Dictionary containing export results and metadata + """ + logger.info('Starting user pool export', user_pool_id=self.user_pool_id) + + export_timestamp = datetime.now(tz=UTC).isoformat() + users_exported = 0 + + try: + # Export all users from the user pool + users_exported = self._export_user_pool(export_timestamp) + + logger.info( + 'Successfully exported users from user pool', + users_exported=users_exported, + user_pool_id=self.user_pool_id, + ) + + return { + 'user_pool_id': self.user_pool_id, + 'users_exported': users_exported, + 'export_timestamp': export_timestamp, + 'backup_bucket': self.backup_bucket_name, + 'status': 'success', + } + + except Exception as e: + logger.error('Failed to export user pool', user_pool_id=self.user_pool_id, error=str(e)) + raise + + def _export_user_pool(self, export_timestamp: str) -> int: + """ + Export all users from the user pool with pagination support. + + :param export_timestamp: ISO timestamp for the export + :return: Number of users exported + """ + users_exported = 0 + pagination_token = None + + while True: + # List users with pagination + list_params = { + 'UserPoolId': self.user_pool_id, + 'Limit': 60, # Maximum allowed by Cognito + } + + if pagination_token: + list_params['PaginationToken'] = pagination_token + + try: + response = self.cognito_client.list_users(**list_params) + users = response.get('Users', []) + + # Export each user + for user in users: + try: + self._export_single_user(user, export_timestamp) + users_exported += 1 + except (ClientError, ValueError) as e: + logger.error('Failed to export user', username=user.get('Username', 'unknown'), error=str(e)) + raise + + # Check for more pages + pagination_token = response.get('PaginationToken') + if not pagination_token: + break + + except ClientError as e: + logger.error('Cognito API error', error=str(e)) + raise + + return users_exported + + def _export_single_user(self, user_data: dict[str, Any], export_timestamp: str) -> None: + """ + Export a single user to S3 as a JSON file. + + :param user_data: User data from Cognito + :param export_timestamp: ISO timestamp for the export + """ + username = user_data.get('Username') + if not username: + logger.warning('Skipping user without username') + return + + # Create object key based on username + object_key = f'cognito-exports/{username}.json' + + # Prepare export data + export_data = { + 'export_metadata': { + 'export_timestamp': export_timestamp, + 'user_pool_id': self.user_pool_id, + 'export_version': '1.0', + }, + 'user_data': { + 'username': username, + 'user_status': user_data.get('UserStatus'), + 'enabled': user_data.get('Enabled', False), + 'user_create_date': user_data['UserCreateDate'].isoformat(), + 'user_last_modified_date': user_data['UserLastModifiedDate'].isoformat(), + 'attributes': self._extract_user_attributes(user_data.get('Attributes', [])), + }, + } + + # Upload to S3 + try: + self.s3_client.put_object( + Bucket=self.backup_bucket_name, + Key=object_key, + Body=json.dumps(export_data, indent=2, default=str), + ContentType='application/json', + Metadata={ + 'export-timestamp': export_timestamp, + 'user-pool-id': self.user_pool_id, + 'username': username, + }, + ) + + logger.debug('Exported user to S3', username=username, object_key=object_key) + + except ClientError as e: + logger.error('Failed to upload user to S3', username=username, error=str(e)) + raise + + def _extract_user_attributes(self, attributes: list[dict[str, str]]) -> dict[str, str]: + """ + Extract user attributes from Cognito format to a simple key-value dictionary. + + :param attributes: List of Cognito user attributes + :return: Dictionary of attribute names to values + """ + return {attr['Name']: attr['Value'] for attr in attributes} + + +def backup_handler(event: dict[str, Any], context: Any) -> dict[str, Any]: # noqa: ARG001 unused-argument + """ + Handler for Cognito user pool backup export. + + Expected event format: + { + "user_pool_id": "us-east-1_xxxxxxxxx", + "backup_bucket_name": "my-cognito-backup-bucket" + } + + :param event: Lambda event containing user pool and bucket information + :param context: Lambda context + :return: Response with export results + """ + logger.info('Received backup request', event=event) + + try: + # Extract parameters from event + user_pool_id = event.get('user_pool_id') + backup_bucket_name = event.get('backup_bucket_name') + + if not all([user_pool_id, backup_bucket_name]): + raise ValueError('Missing required parameters: user_pool_id, backup_bucket_name') + + # Create exporter and run export + exporter = CognitoBackupExporter(user_pool_id, backup_bucket_name) + results = exporter.export_user_pool() + + logger.info('Export completed successfully', results=results) + + return { + 'statusCode': 200, + 'message': 'Cognito backup export completed successfully', + 'results': results, + } + + except Exception as e: + logger.error('Export failed', error=str(e)) + raise diff --git a/backend/social-work-app/lambdas/python/cognito-backup/requirements-dev.in b/backend/social-work-app/lambdas/python/cognito-backup/requirements-dev.in new file mode 100644 index 0000000000..39256a5a9a --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/requirements-dev.in @@ -0,0 +1,5 @@ +boto3>=1.26.0 +botocore>=1.29.0 +aws-lambda-powertools>=2.0.0 +pytest +moto[cognitoidp,s3]>=5, <6 diff --git a/backend/social-work-app/lambdas/python/cognito-backup/requirements-dev.txt b/backend/social-work-app/lambdas/python/cognito-backup/requirements-dev.txt new file mode 100644 index 0000000000..1dc758bd4d --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/requirements-dev.txt @@ -0,0 +1,82 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/cognito-backup/requirements-dev.in +# +aws-lambda-powertools==3.29.0 + # via -r lambdas/python/cognito-backup/requirements-dev.in +boto3==1.43.7 + # via + # -r lambdas/python/cognito-backup/requirements-dev.in + # moto +botocore==1.43.7 + # via + # -r lambdas/python/cognito-backup/requirements-dev.in + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via + # joserfc + # moto +idna==3.15 + # via requests +iniconfig==2.3.0 + # via pytest +jmespath==1.1.0 + # via + # aws-lambda-powertools + # boto3 + # botocore +joserfc==1.6.5 + # via moto +markupsafe==3.0.3 + # via werkzeug +moto[cognitoidp,s3]==5.2.1 + # via -r lambdas/python/cognito-backup/requirements-dev.in +packaging==26.2 + # via pytest +pluggy==1.6.0 + # via pytest +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +pygments==2.20.0 + # via pytest +pytest==9.0.3 + # via -r lambdas/python/cognito-backup/requirements-dev.in +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +typing-extensions==4.15.0 + # via aws-lambda-powertools +urllib3==2.7.0 + # via + # botocore + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/cognito-backup/requirements.in b/backend/social-work-app/lambdas/python/cognito-backup/requirements.in new file mode 100644 index 0000000000..fba480ffa6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/requirements.in @@ -0,0 +1,3 @@ +boto3>=1.26.0 +botocore>=1.29.0 +aws-lambda-powertools>=2.0.0 diff --git a/backend/social-work-app/lambdas/python/cognito-backup/requirements.txt b/backend/social-work-app/lambdas/python/cognito-backup/requirements.txt new file mode 100644 index 0000000000..d568b1cf0f --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/requirements.txt @@ -0,0 +1,30 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/cognito-backup/requirements.in +# +aws-lambda-powertools==3.29.0 + # via -r lambdas/python/cognito-backup/requirements.in +boto3==1.43.7 + # via -r lambdas/python/cognito-backup/requirements.in +botocore==1.43.7 + # via + # -r lambdas/python/cognito-backup/requirements.in + # boto3 + # s3transfer +jmespath==1.1.0 + # via + # aws-lambda-powertools + # boto3 + # botocore +python-dateutil==2.9.0.post0 + # via botocore +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +typing-extensions==4.15.0 + # via aws-lambda-powertools +urllib3==2.7.0 + # via botocore diff --git a/backend/social-work-app/lambdas/python/cognito-backup/tests/__init__.py b/backend/social-work-app/lambdas/python/cognito-backup/tests/__init__.py new file mode 100644 index 0000000000..3fd90b7964 --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/tests/__init__.py @@ -0,0 +1,26 @@ +import logging +import os +from unittest import TestCase +from unittest.mock import MagicMock + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +class TstLambdas(TestCase): + """Base test class for Cognito backup lambda tests.""" + + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'true', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'ENVIRONMENT_NAME': 'test', + }, + ) + + cls.mock_context = MagicMock(name='MockLambdaContext') + cls.mock_context.aws_request_id = 'test-request-id' diff --git a/backend/social-work-app/lambdas/python/cognito-backup/tests/function/__init__.py b/backend/social-work-app/lambdas/python/cognito-backup/tests/function/__init__.py new file mode 100644 index 0000000000..c6a027c2ed --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/tests/function/__init__.py @@ -0,0 +1,96 @@ +import logging +import os + +import boto3 +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing.""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + + self.build_resources() + + self.addCleanup(self.delete_resources) + + def build_resources(self): + """Create mock AWS resources for testing.""" + self.create_cognito_user_pool() + self.create_s3_bucket() + + def create_cognito_user_pool(self): + """Create a mock Cognito user pool with test users.""" + self.cognito_client = boto3.client('cognito-idp', region_name='us-east-1') + + # Create user pool + user_pool_response = self.cognito_client.create_user_pool( + PoolName='test-user-pool', + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 12, + 'RequireUppercase': True, + 'RequireLowercase': True, + 'RequireNumbers': True, + 'RequireSymbols': False, + } + }, + ) + self.user_pool_id = user_pool_response['UserPool']['Id'] + + # Create test users + self.test_users = [ + { + 'Username': 'test-user-1', + 'UserAttributes': [ + {'Name': 'email', 'Value': 'user1@example.com'}, + {'Name': 'given_name', 'Value': 'Test'}, + {'Name': 'family_name', 'Value': 'User1'}, + ], + 'MessageAction': 'SUPPRESS', + 'TemporaryPassword': 'TempPass123!', + }, + { + 'Username': 'test-user-2', + 'UserAttributes': [ + {'Name': 'email', 'Value': 'user2@example.com'}, + {'Name': 'given_name', 'Value': 'Test'}, + {'Name': 'family_name', 'Value': 'User2'}, + {'Name': 'custom:providerId', 'Value': 'provider123'}, + ], + 'MessageAction': 'SUPPRESS', + 'TemporaryPassword': 'TempPass123!', + }, + ] + + for user in self.test_users: + self.cognito_client.admin_create_user(UserPoolId=self.user_pool_id, **user) + + def create_s3_bucket(self): + """Create a mock S3 bucket for backup storage.""" + self.s3_client = boto3.client('s3', region_name='us-east-1') + self.bucket_name = 'test-cognito-backup-bucket' + self.s3_client.create_bucket(Bucket=self.bucket_name) + + def delete_resources(self): + """Clean up mock AWS resources.""" + # Moto automatically cleans up resources when the mock context exits + + def get_test_event(self) -> dict: + """ + Generate a test event for the lambda handler. + + :return: Test event dictionary + """ + return { + 'user_pool_id': self.user_pool_id, + 'backup_bucket_name': self.bucket_name, + } diff --git a/backend/social-work-app/lambdas/python/cognito-backup/tests/function/test_cognito_backup.py b/backend/social-work-app/lambdas/python/cognito-backup/tests/function/test_cognito_backup.py new file mode 100644 index 0000000000..8aa4323318 --- /dev/null +++ b/backend/social-work-app/lambdas/python/cognito-backup/tests/function/test_cognito_backup.py @@ -0,0 +1,417 @@ +""" +Functional tests for the Cognito backup Lambda function. + +This module tests the complete backup functionality with mocked AWS services +using moto to verify the end-to-end behavior. +""" + +import json + +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestCognitoBackupFunctional(TstFunction): + """Functional tests for Cognito backup export functionality.""" + + def test_lambda_handler_success(self): + """Test successful lambda handler execution with real AWS service mocks.""" + from handlers.cognito_backup import backup_handler as lambda_handler + + event = self.get_test_event() + result = lambda_handler(event, self.mock_context) + + # Verify response structure + self.assertEqual(result['statusCode'], 200) + self.assertIn('message', result) + self.assertIn('results', result) + self.assertIn('Cognito backup export completed successfully', result['message']) + + # Verify results + results = result['results'] + self.assertEqual(results['user_pool_id'], self.user_pool_id) + self.assertEqual(results['users_exported'], 2) # We created 2 test users + self.assertEqual(results['backup_bucket'], self.bucket_name) + self.assertEqual(results['status'], 'success') + self.assertIn('export_timestamp', results) + + def test_cognito_backup_exports_all_users(self): + """Test that all users in the user pool are exported to S3.""" + from handlers.cognito_backup import CognitoBackupExporter + + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + results = exporter.export_user_pool() + + # Verify export results + self.assertEqual(results['users_exported'], 2) + + # Verify S3 objects were created + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + self.assertIn('Contents', s3_objects) + self.assertEqual(len(s3_objects['Contents']), 2) + + # Verify object keys + object_keys = [obj['Key'] for obj in s3_objects['Contents']] + expected_keys = [ + 'cognito-exports/test-user-1.json', + 'cognito-exports/test-user-2.json', + ] + self.assertEqual(sorted(object_keys), sorted(expected_keys)) + + def test_exported_user_data_structure(self): + """Test the structure and content of exported user data.""" + from handlers.cognito_backup import CognitoBackupExporter + + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + exporter.export_user_pool() + + # Get exported data from S3 + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + first_object_key = s3_objects['Contents'][0]['Key'] + + obj_response = self.s3_client.get_object(Bucket=self.bucket_name, Key=first_object_key) + export_data = json.loads(obj_response['Body'].read().decode('utf-8')) + + # Verify top-level structure + self.assertIn('export_metadata', export_data) + self.assertIn('user_data', export_data) + + # Verify export metadata + metadata = export_data['export_metadata'] + self.assertEqual(metadata['user_pool_id'], self.user_pool_id) + self.assertEqual(metadata['export_version'], '1.0') + self.assertIn('export_timestamp', metadata) + + # Verify user data structure + user_data = export_data['user_data'] + required_fields = [ + 'username', + 'user_status', + 'enabled', + 'user_create_date', + 'user_last_modified_date', + 'attributes', + ] + for field in required_fields: + self.assertIn(field, user_data) + + # Verify attributes were properly extracted + self.assertIsInstance(user_data['attributes'], dict) + self.assertIn('email', user_data['attributes']) + + def test_s3_object_metadata(self): + """Test that S3 objects have correct metadata.""" + from handlers.cognito_backup import CognitoBackupExporter + + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + exporter.export_user_pool() + + # Get object metadata + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + first_object_key = s3_objects['Contents'][0]['Key'] + + head_response = self.s3_client.head_object(Bucket=self.bucket_name, Key=first_object_key) + metadata = head_response['Metadata'] + + # Verify metadata fields + self.assertIn('export-timestamp', metadata) + self.assertEqual(metadata['user-pool-id'], self.user_pool_id) + self.assertIn('username', metadata) + + # Verify content type + self.assertEqual(head_response['ContentType'], 'application/json') + + def test_backup_export_basic_functionality(self): + """Test basic export functionality.""" + from handlers.cognito_backup import CognitoBackupExporter + + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + results = exporter.export_user_pool() + + # Verify results + self.assertEqual(results['users_exported'], 2) + self.assertEqual(results['status'], 'success') + + # Verify S3 object paths + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + object_keys = [obj['Key'] for obj in s3_objects['Contents']] + + for key in object_keys: + self.assertTrue(key.startswith('cognito-exports/')) + + def test_empty_user_pool(self): + """Test export of an empty user pool.""" + from handlers.cognito_backup import CognitoBackupExporter + + # Create an empty user pool + empty_pool_response = self.cognito_client.create_user_pool(PoolName='empty-test-pool') + empty_pool_id = empty_pool_response['UserPool']['Id'] + + exporter = CognitoBackupExporter(empty_pool_id, self.bucket_name) + results = exporter.export_user_pool() + + # Verify results + self.assertEqual(results['users_exported'], 0) + self.assertEqual(results['status'], 'success') + + # Verify no S3 objects were created + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + self.assertNotIn('Contents', s3_objects) + + def test_user_with_custom_attributes(self): + """Test export of user with custom attributes.""" + from handlers.cognito_backup import CognitoBackupExporter + + # The second test user has a custom attribute + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + exporter.export_user_pool() + + # Find and verify the user with custom attributes + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + + for obj in s3_objects['Contents']: + if 'test-user-2' in obj['Key']: + obj_response = self.s3_client.get_object(Bucket=self.bucket_name, Key=obj['Key']) + export_data = json.loads(obj_response['Body'].read().decode('utf-8')) + + attributes = export_data['user_data']['attributes'] + self.assertIn('custom:providerId', attributes) + self.assertEqual(attributes['custom:providerId'], 'provider123') + break + else: + self.fail('Could not find test-user-2 export') + + def test_pagination_handling(self): + """Test that pagination is handled correctly for large user pools.""" + from handlers.cognito_backup import CognitoBackupExporter + + # Create many users to test pagination (moto may not enforce the 60 limit, but we can test the logic) + for i in range(65): + self.cognito_client.admin_create_user( + UserPoolId=self.user_pool_id, + Username=f'pagination-user-{i}', + UserAttributes=[ + {'Name': 'email', 'Value': f'paguser{i}@example.com'}, + ], + MessageAction='SUPPRESS', + TemporaryPassword='TempPass123!', + ) + + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + results = exporter.export_user_pool() + + # Verify all users were exported (2 original + 65 new = 67 total) + self.assertEqual(results['users_exported'], 67) + + # Verify all users are in S3 + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + self.assertEqual(len(s3_objects['Contents']), 67) + + def test_lambda_handler_multiple_executions(self): + """Test lambda handler with multiple executions.""" + from handlers.cognito_backup import backup_handler as lambda_handler + + # Run export multiple times to ensure consistency + for i in range(3): + with self.subTest(execution=i): + # Clear bucket between tests + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + if 'Contents' in s3_objects: + for obj in s3_objects['Contents']: + self.s3_client.delete_object(Bucket=self.bucket_name, Key=obj['Key']) + + event = self.get_test_event() + result = lambda_handler(event, self.mock_context) + + # Verify response + self.assertEqual(result['statusCode'], 200) + self.assertEqual(result['results']['users_exported'], 2) + + # Verify S3 paths + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + if 'Contents' in s3_objects: + for obj in s3_objects['Contents']: + self.assertTrue(obj['Key'].startswith('cognito-exports/')) + + +@mock_aws +class TestCognitoBackupErrorHandling(TstFunction): + """Test error handling in Cognito backup functionality.""" + + def test_invalid_user_pool_id(self): + """Test handling of invalid user pool ID.""" + from botocore.exceptions import ClientError + from handlers.cognito_backup import CognitoBackupExporter + + exporter = CognitoBackupExporter('invalid-pool-id', self.bucket_name) + + with self.assertRaises(ClientError): + exporter.export_user_pool() + + def test_invalid_bucket_name(self): + """Test handling of invalid S3 bucket.""" + from botocore.exceptions import ClientError + from handlers.cognito_backup import CognitoBackupExporter + + exporter = CognitoBackupExporter(self.user_pool_id, 'invalid-bucket-name') + + # Should raise an exception like invalid user pool ID test + with self.assertRaises(ClientError): + exporter.export_user_pool() + + def test_lambda_handler_invalid_event(self): + """Test lambda handler with invalid event parameters.""" + from handlers.cognito_backup import backup_handler as lambda_handler + + invalid_events = [ + {}, # Empty event + {'user_pool_id': 'test'}, # Missing backup_bucket_name + {'backup_bucket_name': 'test'}, # Missing user_pool_id + ] + + for event in invalid_events: + with self.subTest(event=event): + with self.assertRaises(ValueError): + lambda_handler(event, self.mock_context) + + def test_extract_user_attributes_functionality(self): + """Test user attributes extraction with real Cognito user data.""" + from handlers.cognito_backup import CognitoBackupExporter + + # Create a user with various attribute types + self.cognito_client.admin_create_user( + UserPoolId=self.user_pool_id, + Username='attribute-test-user', + UserAttributes=[ + {'Name': 'email', 'Value': 'attr@example.com'}, + {'Name': 'given_name', 'Value': 'Attribute'}, + {'Name': 'family_name', 'Value': 'Tester'}, + {'Name': 'custom:providerId', 'Value': 'attr123'}, + {'Name': 'phone_number', 'Value': '+1234567890'}, + ], + MessageAction='SUPPRESS', + TemporaryPassword='TempPass123!', + ) + + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + exporter.export_user_pool() + + # Find and verify the attributes were extracted correctly + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + + for obj in s3_objects['Contents']: + if 'attribute-test-user' in obj['Key']: + obj_response = self.s3_client.get_object(Bucket=self.bucket_name, Key=obj['Key']) + export_data = json.loads(obj_response['Body'].read().decode('utf-8')) + + attributes = export_data['user_data']['attributes'] + + # Verify all attributes are properly extracted + self.assertEqual(attributes['email'], 'attr@example.com') + self.assertEqual(attributes['given_name'], 'Attribute') + self.assertEqual(attributes['family_name'], 'Tester') + self.assertEqual(attributes['custom:providerId'], 'attr123') + self.assertEqual(attributes['phone_number'], '+1234567890') + break + else: + self.fail('Could not find attribute-test-user export') + + def test_export_initialization_and_datetime_formatting(self): + """Test CognitoBackupExporter initialization and datetime handling with real data.""" + from handlers.cognito_backup import CognitoBackupExporter + + # Test initialization + exporter = CognitoBackupExporter(self.user_pool_id, self.bucket_name) + self.assertEqual(exporter.user_pool_id, self.user_pool_id) + self.assertEqual(exporter.backup_bucket_name, self.bucket_name) + + # Test datetime formatting by running export and checking timestamp formats + exporter.export_user_pool() + + # Get an exported file and verify datetime formats + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + first_object_key = s3_objects['Contents'][0]['Key'] + + obj_response = self.s3_client.get_object(Bucket=self.bucket_name, Key=first_object_key) + export_data = json.loads(obj_response['Body'].read().decode('utf-8')) + + # Verify export timestamp format (ISO format) + export_timestamp = export_data['export_metadata']['export_timestamp'] + self.assertRegex(export_timestamp, r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+(\+00:00|Z)') + + # Verify user datetime fields are properly formatted or None + user_data = export_data['user_data'] + + # These should be properly formatted ISO strings or None + create_date = user_data['user_create_date'] + modified_date = user_data['user_last_modified_date'] + + if create_date is not None: + # Should be a valid ISO timestamp + self.assertRegex(create_date, r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}') + + if modified_date is not None: + # Should be a valid ISO timestamp + self.assertRegex(modified_date, r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}') + + def test_malformed_user_handling(self): + """Test graceful handling of users with missing or malformed data.""" + from handlers.cognito_backup import CognitoBackupExporter + + # This test verifies the system handles edge cases gracefully + # We can't easily create a malformed user in Cognito, but we can test + # the edge case of no users and verify no exceptions are raised + + # Create an empty user pool + empty_pool_response = self.cognito_client.create_user_pool(PoolName='malformed-test-pool') + empty_pool_id = empty_pool_response['UserPool']['Id'] + + exporter = CognitoBackupExporter(empty_pool_id, self.bucket_name) + + # This should complete without errors even with no users + results = exporter.export_user_pool() + + self.assertEqual(results['users_exported'], 0) + self.assertEqual(results['status'], 'success') + + # Test with users that have minimal attributes + self.cognito_client.admin_create_user( + UserPoolId=empty_pool_id, + Username='minimal-user', + UserAttributes=[], # No attributes + MessageAction='SUPPRESS', + TemporaryPassword='TempPass123!', + ) + + # Clear bucket first + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + if 'Contents' in s3_objects: + for obj in s3_objects['Contents']: + self.s3_client.delete_object(Bucket=self.bucket_name, Key=obj['Key']) + + # Export should handle user with no attributes gracefully + results = exporter.export_user_pool() + + self.assertEqual(results['users_exported'], 1) + self.assertEqual(results['status'], 'success') + + # Verify the exported data structure is still valid + s3_objects = self.s3_client.list_objects_v2(Bucket=self.bucket_name) + self.assertEqual(len(s3_objects['Contents']), 1) + + obj_response = self.s3_client.get_object(Bucket=self.bucket_name, Key=s3_objects['Contents'][0]['Key']) + export_data = json.loads(obj_response['Body'].read().decode('utf-8')) + + # Should have proper structure even with minimal data + self.assertIn('export_metadata', export_data) + self.assertIn('user_data', export_data) + self.assertEqual(export_data['user_data']['username'], 'minimal-user') + + # Cognito automatically adds 'sub' attribute, so we expect at least that + attributes = export_data['user_data']['attributes'] + self.assertIsInstance(attributes, dict) + self.assertIn('sub', attributes) # Cognito always adds this + # Should not have any other user-defined attributes + self.assertLessEqual(len(attributes), 1) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/cc_common/config.py b/backend/social-work-app/lambdas/python/common/cc_common/config.py new file mode 100644 index 0000000000..8a7bd2fcd9 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/config.py @@ -0,0 +1,348 @@ +import json +import logging +import os +from datetime import UTC, datetime, timedelta, timezone +from functools import cached_property + +import boto3 +from aws_lambda_powertools import Metrics +from aws_lambda_powertools.logging import Logger +from botocore.config import Config as BotoConfig + +from cc_common.email_service_client import EmailServiceClient + +logging.basicConfig() +logger = Logger() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO) + +metrics = Metrics(namespace='compact-connect', service='common') + + +class _Config: + presigned_post_ttl_seconds = 3600 + default_page_size = 100 + + @property + def environment_region(self): + """ + Returns the region name of the region the lambda is running in. + """ + return os.environ['AWS_REGION'] + + @property + def opensearch_host_endpoint(self): + """ + Returns the OpenSearch host endpoint for the domain. + """ + return os.environ['OPENSEARCH_HOST_ENDPOINT'] + + @cached_property + def cognito_client(self): + return boto3.client('cognito-idp') + + @cached_property + def users_table(self): + return boto3.resource('dynamodb').Table(self.users_table_name) + + @cached_property + def s3_client(self): + return boto3.client('s3', config=BotoConfig(signature_version='s3v4')) + + @cached_property + def dynamodb_client(self): + return boto3.client('dynamodb') + + @cached_property + def data_client(self): + from cc_common.data_model.data_client import DataClient + + return DataClient(self) + + @cached_property + def compact_configuration_client(self): + from cc_common.data_model.compact_configuration_client import CompactConfigurationClient + + return CompactConfigurationClient(self) + + @cached_property + def live_compact_jurisdictions(self) -> dict[str, list[str]]: + """ + Cached mapping of compact -> list of member jurisdictions live in system (postal abbreviations). + + Values come from get_live_compact_jurisdictions. Fetched once per Lambda cold start; + subsequent accesses return the cached value. Used when generating privileges at runtime + to avoid repeated DynamoDB reads. + """ + result: dict[str, list[str]] = {} + for compact in self.compacts: + try: + result[compact] = self.compact_configuration_client.get_live_compact_jurisdictions(compact) + except Exception: # noqa: BLE001 + logger.error('Failed to load live jurisdictions', compact=compact) + raise + return result + + @cached_property + def user_client(self): + from cc_common.data_model.user_client import UserClient + + return UserClient(self) + + @cached_property + def compact_configuration_table(self): + return boto3.resource('dynamodb').Table(self.compact_configuration_table_name) + + @cached_property + def secrets_manager_client(self): + return boto3.client('secretsmanager') + + @cached_property + def events_client(self): + return boto3.client('events', config=BotoConfig(retries={'mode': 'standard'})) + + @cached_property + def license_preprocessing_queue(self): + """ + Returns the SQS Queue resource for the license preprocessing queue. + This allows for using the Queue's methods directly like send_messages. + """ + return boto3.resource('sqs').Queue(self.license_preprocessing_queue_url) + + @cached_property + def license_preprocessing_queue_url(self): + return os.environ['LICENSE_PREPROCESSING_QUEUE_URL'] + + @cached_property + def event_bus_name(self): + return os.environ['EVENT_BUS_NAME'] + + @cached_property + def provider_table(self): + return boto3.resource('dynamodb').Table(self.provider_table_name) + + @cached_property + def ssn_table(self): + return boto3.resource('dynamodb').Table(self.ssn_table_name) + + @property + def compact_configuration_table_name(self): + return os.environ['COMPACT_CONFIGURATION_TABLE_NAME'] + + @property + def environment_name(self): + return os.environ['ENVIRONMENT_NAME'] + + @property + def compacts(self): + return json.loads(os.environ['COMPACTS']) + + @property + def jurisdictions(self): + return json.loads(os.environ['JURISDICTIONS']) + + @property + def license_types(self): + """ + Reshapes the new LICENSE_TYPES format into the previous format for backward compatibility. + The new format is: + { + "socw": [ + {"abbreviation": "cos", "name": "cosmetologist"}, + {"abbreviation": "esth", "name": "esthetician"} + ] + } + The returned format is: + { + "socw": ["cosmetologist", "esthetician"] + } + """ + raw_license_types = json.loads(os.environ['LICENSE_TYPES']) + return {compact: [lt['name'] for lt in license_types] for compact, license_types in raw_license_types.items()} + + @property + def license_type_abbreviations(self): + """ + Creates a lookup dictionary for license type abbreviations based on compact and full name. + Returns a structure like: + { + "socw": { + "cosmetologist": "cos", + "esthetician": "esth" + } + } + """ + raw_license_types = json.loads(os.environ['LICENSE_TYPES']) + return { + compact: {lt['name']: lt['abbreviation'] for lt in license_types} + for compact, license_types in raw_license_types.items() + } + + def license_types_for_compact(self, compact): + return self.license_types[compact] + + def license_type_abbreviations_for_compact(self, compact): + return self.license_type_abbreviations[compact] + + @property + def provider_table_name(self): + return os.environ['PROVIDER_TABLE_NAME'] + + @property + def ssn_table_name(self): + return os.environ['SSN_TABLE_NAME'] + + @property + def fam_giv_mid_index_name(self): + return os.environ['PROV_FAM_GIV_MID_INDEX_NAME'] + + @property + def date_of_update_index_name(self): + return os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'] + + @property + def license_gsi_name(self): + return os.environ['LICENSE_GSI_NAME'] + + @property + def ssn_index_name(self): + return os.environ['SSN_INDEX_NAME'] + + @property + def bulk_bucket_name(self): + return os.environ['BULK_BUCKET_NAME'] + + @property + def disaster_recovery_results_bucket_name(self): + return os.environ['DISASTER_RECOVERY_RESULTS_BUCKET_NAME'] + + @property + def user_pool_id(self): + """ + Return the user pool id of the staff user pool + """ + return os.environ['USER_POOL_ID'] + + @property + def users_table_name(self): + """ + Get the staff users table name + """ + return os.environ['USERS_TABLE_NAME'] + + @property + def fam_giv_index_name(self): + return os.environ['FAM_GIV_INDEX_NAME'] + + @property + def license_upload_date_index_name(self): + return os.environ['LICENSE_UPLOAD_DATE_INDEX_NAME'] + + @property + def expiration_resolution_timezone(self): + return timezone(offset=timedelta(hours=-4)) + + @property + def expiration_resolution_date(self): + """ + This is the date used to determine if a license or privilege is expired. + This is currently set to use the UTC-4 timezone. We anticipate that this may change in the future, + so we have a configuration value for it. + """ + # the astimezone method returns a new datetime object adjusted to the new timezone + return self.current_standard_datetime.astimezone(self.expiration_resolution_timezone).date() + + @cached_property + def data_events_table(self): + return boto3.resource('dynamodb').Table(self.data_events_table_name) + + @property + def data_events_table_name(self): + return os.environ['DATA_EVENT_TABLE_NAME'] + + @property + def event_ttls(self): + """ + Event type-specific TTLs + """ + return {'license.validation-error': timedelta(days=90), 'license.ingest-failure': timedelta(days=90)} + + @property + def default_event_ttl(self): + """ + If we don't define a TTL specific for an event type, use this TTL + """ + return timedelta(days=366) + + @property + def current_standard_datetime(self): + """ + Standardized way to get the current datetime with the microseconds stripped off. + """ + return datetime.now(tz=UTC).replace(microsecond=0) + + @property + def export_results_bucket_name(self): + return os.environ['EXPORT_RESULTS_BUCKET_NAME'] + + @property + def rate_limiting_table_name(self): + return os.environ['RATE_LIMITING_TABLE_NAME'] + + @property + def rate_limiting_table(self): + return boto3.resource('dynamodb').Table(self.rate_limiting_table_name) + + @property + def event_state_table_name(self): + return os.environ['EVENT_STATE_TABLE_NAME'] + + @property + def event_state_table(self): + return boto3.resource('dynamodb').Table(self.event_state_table_name) + + @cached_property + def event_state_client(self): + from cc_common.event_state_client import EventStateClient + + return EventStateClient(self) + + @cached_property + def allowed_origins(self): + return json.loads(os.environ['ALLOWED_ORIGINS']) + + @cached_property + def lambda_client(self): + return boto3.client('lambda') + + @property + def email_notification_service_lambda_name(self): + return os.environ['EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME'] + + @cached_property + def email_service_client(self): + return EmailServiceClient( + lambda_client=self.lambda_client, + email_notification_service_lambda_name=self.email_notification_service_lambda_name, + logger=logger, + ) + + @cached_property + def event_bus_client(self): + from cc_common.event_bus_client import EventBusClient + + return EventBusClient() + + @property + def api_base_url(self): + """ + Return the API base URL for constructing provider UI URLs. + Used by the state API to generate providerUIUrl in responses. + """ + return os.environ['API_BASE_URL'] + + @property + def signature_max_clock_skew_seconds(self): + return 60 + + +config = _Config() diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/compact_configuration_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/compact_configuration_client.py new file mode 100644 index 0000000000..daec905781 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/compact_configuration_client.py @@ -0,0 +1,286 @@ +from cc_common.config import _Config, logger +from cc_common.data_model.schema.compact import CompactConfigurationData +from cc_common.data_model.schema.compact.record import CompactRecordSchema +from cc_common.data_model.schema.jurisdiction import JurisdictionConfigurationData +from cc_common.data_model.schema.jurisdiction.record import JurisdictionRecordSchema +from cc_common.exceptions import CCInternalException, CCNotFoundException + + +class CompactConfigurationClient: + """Client interface for compact configuration dynamodb queries""" + + def __init__(self, config: _Config): + self.config = config + self.compact_schema = CompactRecordSchema() + self.jurisdiction_schema = JurisdictionRecordSchema() + + def get_compact_configuration(self, compact: str) -> CompactConfigurationData: + """ + Get the configuration for a specific compact. + + :param compact: The compact abbreviation + :return: Compact configuration model + :raises CCNotFoundException: If the compact configuration is not found + """ + logger.info('Getting compact configuration', compact=compact) + + pk = f'{compact}#CONFIGURATION' + sk = f'{compact}#CONFIGURATION' + + response = self.config.compact_configuration_table.get_item(Key={'pk': pk, 'sk': sk}) + + item = response.get('Item') + if not item: + raise CCNotFoundException(f'No configuration found for compact "{compact}"') + + # Load through schema and convert to Compact model + return CompactConfigurationData.from_database_record(item) + + def save_compact_configuration(self, compact_configuration: CompactConfigurationData) -> None: + """ + Save the compact configuration. + If a record exists, it merges the new values with the existing record to preserve all fields. + + :param compact_configuration: The compact configuration data + """ + logger.info('Saving compact configuration', compactAbbr=compact_configuration.compactAbbr) + + try: + existing_compact_config = self.get_compact_configuration(compact_configuration.compactAbbr) + except CCNotFoundException: + logger.info('Existing compact configuration not found.', compact=compact_configuration.compactAbbr) + existing_compact_config = None + + if existing_compact_config: + # Record exists - merge with existing data to preserve all fields + logger.info('Updating existing compact configuration record', compactAbbr=compact_configuration.compactAbbr) + + # Load the existing record into a data class to get the existing data + existing_data = existing_compact_config.to_dict() + + # Get the new data + new_data = compact_configuration.to_dict() + + # Merge the data - new values override existing ones, but existing fields not in new_data are preserved + merged_data = existing_data.copy() + merged_data.update(new_data) + + # Create a new CompactConfigurationData with the merged data + merged_config = CompactConfigurationData.create_new(merged_data) + final_serialized = merged_config.serialize_to_database_record() + else: + # First time creation - use the new data directly + logger.info('Creating new compact configuration record', compactAbbr=compact_configuration.compactAbbr) + final_serialized = compact_configuration.serialize_to_database_record() + + # Use put_item to save the final record + self.config.compact_configuration_table.put_item(Item=final_serialized) + + def get_active_compact_jurisdictions(self, compact: str) -> list[dict]: + """ + Get the active member jurisdictions for a specific compact. + + Note this is not the list of jurisdiction configurations defined within a compact. This is specifically the list + of jurisdictions that are currently reported as active member jurisdictions within the compact. + + This configuration is defined in the 'active_compact_member_jurisdictions' field of the project's cdk.json file. + It is uploaded into the table by the compact configuration uploader custom resource which runs with every new + deployment. + + :param compact: The compact abbreviation. + :return: List of active member jurisdictions for the compact, each object including the name, + postal abbreviation, and abbreviation of the compact the jurisdiction is associated with. + """ + logger.info('Getting active member jurisdictions', compact=compact) + + pk = f'COMPACT#{compact}#ACTIVE_MEMBER_JURISDICTIONS' + sk = f'COMPACT#{compact}#ACTIVE_MEMBER_JURISDICTIONS' + + response = self.config.compact_configuration_table.get_item(Key={'pk': pk, 'sk': sk}) + + item = response.get('Item') + if not item or not item.get('active_member_jurisdictions'): + raise CCNotFoundException(f'No active member jurisdiction data found for compact "{compact}"') + + # Return the active_member_jurisdictions list from the item + return item['active_member_jurisdictions'] + + def is_jurisdiction_live_in_compact(self, compact: str, jurisdiction: str) -> bool: + """ + Check if a jurisdiction is live (enabled for operations) in a compact. + + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction postal abbreviation + :return: True if the jurisdiction is live in the compact, False otherwise + """ + logger.info('Checking if jurisdiction is live in compact', compact=compact, jurisdiction=jurisdiction) + + live_jurisdictions = self.get_live_compact_jurisdictions(compact) + is_live = jurisdiction in live_jurisdictions + logger.info( + 'Jurisdiction live status checked', + compact=compact, + jurisdiction=jurisdiction, + is_live=is_live, + ) + return is_live + + def get_jurisdiction_configuration(self, compact: str, jurisdiction: str) -> JurisdictionConfigurationData: + """ + Get the configuration for a specific jurisdiction within a compact. + + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction postal abbreviation + :return: Jurisdiction configuration model + :raises CCNotFoundException: If the jurisdiction configuration is not found + """ + logger.info('Getting jurisdiction configuration', compact=compact, jurisdiction=jurisdiction) + + pk = f'{compact}#CONFIGURATION' + sk = f'{compact}#JURISDICTION#{jurisdiction.lower()}' + + response = self.config.compact_configuration_table.get_item(Key={'pk': pk, 'sk': sk}) + + item = response.get('Item') + if not item: + raise CCNotFoundException( + f'No configuration found for jurisdiction "{jurisdiction}" in compact "{compact}"' + ) + + # Load through schema and convert to Jurisdiction model + return JurisdictionConfigurationData.from_database_record(item) + + def save_jurisdiction_configuration(self, jurisdiction_config: JurisdictionConfigurationData) -> None: + """ + Save the jurisdiction configuration and update related compact configuration if needed. + + :param jurisdiction_config: The jurisdiction configuration model + """ + logger.info('Saving jurisdiction configuration', jurisdiction=jurisdiction_config.postalAbbreviation) + + serialized_jurisdiction = jurisdiction_config.serialize_to_database_record() + self.config.compact_configuration_table.put_item(Item=serialized_jurisdiction) + + # Always check if jurisdiction should be in compact's configuredStates (idempotent) + self._ensure_jurisdiction_in_configured_states_if_registration_enabled(jurisdiction_config) + + def _ensure_jurisdiction_in_configured_states_if_registration_enabled( + self, jurisdiction_config: JurisdictionConfigurationData + ) -> None: + """ + Ensure that if a jurisdiction has licensee registration enabled, it appears in the compact's + configuredStates list. + + :param jurisdiction_config: The jurisdiction configuration to check + """ + if not jurisdiction_config.licenseeRegistrationEnabled: + logger.debug( + 'Jurisdiction does not have licensee registration enabled - no action needed', + compact=jurisdiction_config.compact, + jurisdiction=jurisdiction_config.postalAbbreviation, + ) + return + + try: + # Get current compact configuration + compact_config = self.get_compact_configuration(compact=jurisdiction_config.compact) + current_configured_states = compact_config.configuredStates.copy() + + # Check if jurisdiction is already in configuredStates + existing_postal_abbrs = {state['postalAbbreviation'].lower() for state in current_configured_states} + jurisdiction_postal = jurisdiction_config.postalAbbreviation.lower() + + if jurisdiction_postal not in existing_postal_abbrs: + # Add the jurisdiction with isLive: false + new_jurisdiction = {'postalAbbreviation': jurisdiction_postal, 'isLive': False} + current_configured_states.append(new_jurisdiction) + + logger.info( + 'Adding jurisdiction to compact configuredStates', + compact=jurisdiction_config.compact, + jurisdiction=jurisdiction_config.postalAbbreviation, + new_configured_states=current_configured_states, + ) + + # Update the compact configuration + self.update_compact_configured_states( + compact=jurisdiction_config.compact, configured_states=current_configured_states + ) + + logger.info( + 'Added jurisdiction to compact configuredStates', + compact=jurisdiction_config.compact, + jurisdiction=jurisdiction_config.postalAbbreviation, + new_configured_states=current_configured_states, + ) + else: + logger.debug( + 'Jurisdiction already exists in compact configuredStates - no action needed', + compact=jurisdiction_config.compact, + jurisdiction=jurisdiction_config.postalAbbreviation, + ) + + except CCNotFoundException as e: + # This is unlikely, but possible if jurisdiction admins submit state config before compact admins have + # submitted their own configurations for the first time + # After the initial onboarding phase, if this occurs it is more likely the result of an error that needs + # to be investigated, so we raise an exception here + message = 'Compact configuration not found when trying to ensure jurisdiction in configuredStates' + logger.error( + message, + compact=jurisdiction_config.compact, + jurisdiction=jurisdiction_config.postalAbbreviation, + ) + raise CCInternalException(message) from e + + def update_compact_configured_states(self, compact: str, configured_states: list[dict]) -> None: + """ + Update the configuredStates field for a compact configuration using DynamoDB UPDATE operation. + This is used to add states to configuredStates when they enable licensee registration. + + :param compact: The compact abbreviation + :param configured_states: The updated list of configured states + """ + logger.info('Updating configured states for compact', compact=compact, configured_states=configured_states) + + pk = f'{compact}#CONFIGURATION' + sk = f'{compact}#CONFIGURATION' + + # Use UPDATE with SET to update both configuredStates and dateOfUpdate + + self.config.compact_configuration_table.update_item( + Key={'pk': pk, 'sk': sk}, + UpdateExpression='SET configuredStates = :cs, dateOfUpdate = :dou', + ExpressionAttributeValues={ + ':cs': configured_states, + ':dou': self.config.current_standard_datetime.isoformat(), + }, + ) + + def get_live_compact_jurisdictions(self, compact: str) -> list[str]: + """ + Get all live (isLive: true) jurisdiction postal abbreviations for a specific compact. + + :param compact: The compact abbreviation + :return: List of jurisdiction postal abbreviations that are live in the compact + """ + logger.info('Getting live jurisdictions for compact', compact=compact) + + try: + compact_config = self.get_compact_configuration(compact) + except CCNotFoundException: + logger.info('Compact configuration not found, returning empty list', compact=compact) + return [] + + # Filter configuredStates for those with isLive: true and extract postal abbreviations + live_jurisdictions = [ + state['postalAbbreviation'] for state in compact_config.configuredStates if state.get('isLive', False) + ] + + logger.info( + 'Retrieved live jurisdictions for compact', + compact=compact, + live_jurisdictions_count=len(live_jurisdictions), + ) + + return live_jurisdictions diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/compact_configuration_utils.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/compact_configuration_utils.py new file mode 100644 index 0000000000..d55bcf95b8 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/compact_configuration_utils.py @@ -0,0 +1,88 @@ +class CompactConfigUtility: + """Utility class for compact and jurisdiction configuration mappings.""" + + # Mapping of compact abbreviations to full names + COMPACT_NAME_MAPPING: dict[str, str] = { + 'socw': 'Social Work', + } + + # Mapping of jurisdiction postal abbreviations to full state names + JURISDICTION_NAME_MAPPING: dict[str, str] = { + 'al': 'Alabama', + 'ak': 'Alaska', + 'az': 'Arizona', + 'ar': 'Arkansas', + 'ca': 'California', + 'co': 'Colorado', + 'ct': 'Connecticut', + 'de': 'Delaware', + 'fl': 'Florida', + 'ga': 'Georgia', + 'hi': 'Hawaii', + 'id': 'Idaho', + 'il': 'Illinois', + 'in': 'Indiana', + 'ia': 'Iowa', + 'ks': 'Kansas', + 'ky': 'Kentucky', + 'la': 'Louisiana', + 'me': 'Maine', + 'md': 'Maryland', + 'ma': 'Massachusetts', + 'mi': 'Michigan', + 'mn': 'Minnesota', + 'ms': 'Mississippi', + 'mo': 'Missouri', + 'mt': 'Montana', + 'ne': 'Nebraska', + 'nv': 'Nevada', + 'nh': 'New Hampshire', + 'nj': 'New Jersey', + 'nm': 'New Mexico', + 'ny': 'New York', + 'nc': 'North Carolina', + 'nd': 'North Dakota', + 'oh': 'Ohio', + 'ok': 'Oklahoma', + 'or': 'Oregon', + 'pa': 'Pennsylvania', + 'ri': 'Rhode Island', + 'sc': 'South Carolina', + 'sd': 'South Dakota', + 'tn': 'Tennessee', + 'tx': 'Texas', + 'ut': 'Utah', + 'vt': 'Vermont', + 'va': 'Virginia', + 'wa': 'Washington', + 'wv': 'West Virginia', + 'wi': 'Wisconsin', + 'wy': 'Wyoming', + 'dc': 'District of Columbia', + # U.S. Territories + 'as': 'American Samoa', + 'gu': 'Guam', + 'mp': 'Northern Mariana Islands', + 'pr': 'Puerto Rico', + 'vi': 'U.S. Virgin Islands', + } + + @classmethod + def get_compact_name(cls, compact_abbr: str) -> str | None: + """ + Get the full name of a compact from its abbreviation. + + :param compact_abbr: The compact abbreviation + :return: The compact name or None if not found + """ + return cls.COMPACT_NAME_MAPPING.get(compact_abbr.lower()) + + @classmethod + def get_jurisdiction_name(cls, postal_abbr: str) -> str | None: + """ + Get the full state name from its postal abbreviation. + + :param postal_abbr: The jurisdiction postal abbreviation + :return: The jurisdiction (state) name or None if not found + """ + return cls.JURISDICTION_NAME_MAPPING.get(postal_abbr.lower()) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/data_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/data_client.py new file mode 100644 index 0000000000..b9b0bde1e2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/data_client.py @@ -0,0 +1,1188 @@ +import time +from datetime import date, datetime +from datetime import time as dtime +from urllib.parse import quote +from uuid import UUID, uuid4 + +from boto3.dynamodb.conditions import Attr, Key +from boto3.dynamodb.types import TypeDeserializer, TypeSerializer +from botocore.exceptions import ClientError + +from cc_common.config import _Config, config, logger +from cc_common.data_model.provider_record_util import ( + ProviderRecordType, + ProviderUserRecords, +) +from cc_common.data_model.query_paginator import paginated_query +from cc_common.data_model.schema.adverse_action import AdverseActionData +from cc_common.data_model.schema.base_record import SSNIndexRecordSchema +from cc_common.data_model.schema.common import ( + CCDataClass, + InvestigationAgainstEnum, + InvestigationStatusEnum, + LicenseEncumberedStatusEnum, + UpdateCategory, +) +from cc_common.data_model.schema.investigation import InvestigationData +from cc_common.data_model.schema.license import LicenseData, LicenseUpdateData +from cc_common.data_model.schema.provider import ProviderData +from cc_common.data_model.update_tier_enum import UpdateTierEnum +from cc_common.exceptions import ( + CCInternalException, + CCInvalidRequestException, + CCNotFoundException, +) +from cc_common.license_util import LicenseUtility +from cc_common.utils import logger_inject_kwargs + + +class DataClient: + """Client interface for license data dynamodb queries""" + + def __init__(self, config: _Config): + self.config = config + self.ssn_index_record_schema = SSNIndexRecordSchema() + + @logger_inject_kwargs(logger, 'compact') + def get_or_create_provider_id(self, *, compact: str, ssn: str) -> str: + provider_id = str(uuid4()) + # This is an 'ask forgiveness' approach to provider id assignment: + # Try to create a new provider, conditional on it not already existing + try: + self.config.ssn_table.put_item( + Item=self.ssn_index_record_schema.dump( + { + 'compact': compact, + 'ssn': ssn, + 'providerId': provider_id, + } + ), + ConditionExpression=Attr('pk').not_exists(), + ReturnValuesOnConditionCheckFailure='ALL_OLD', + ) + logger.info('Creating new provider', provider_id=provider_id) + except ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + # The provider already exists, so grab their providerId + provider_id = TypeDeserializer().deserialize(e.response['Item']['providerId']) + logger.info('Found existing provider', provider_id=provider_id) + else: + raise + return provider_id + + @logger_inject_kwargs(logger, 'compact', 'provider_id') + def get_ssn_by_provider_id(self, *, compact: str, provider_id: str) -> str: + logger.info('Getting ssn by provider id', compact=compact, provider_id=provider_id) + resp = self.config.ssn_table.query( + KeyConditionExpression=Key('providerIdGSIpk').eq(f'{compact}#PROVIDER#{provider_id}'), + IndexName=self.config.ssn_index_name, + )['Items'] + if len(resp) == 0: + raise CCNotFoundException('Provider not found') + if len(resp) != 1: + raise CCInternalException(f'Expected 1 SSN index record, got {len(resp)}') + return resp[0]['ssn'] + + @logger_inject_kwargs(logger, 'compact', 'jurisdiction', 'family_name', 'given_name') + def find_matching_license_record( + self, + *, + compact: str, + jurisdiction: str, + family_name: str, + given_name: str, + partial_ssn: str, + dob: date, + license_type: str, + ) -> LicenseData | None: + """Query license records using the license GSI and find a matching record. + + :param compact: The compact name + :param jurisdiction: The jurisdiction postal code + :param family_name: Provider's family name + :param given_name: Provider's given name + :param partial_ssn: Last 4 digits of SSN + :param date dob: Date of birth + :param license_type: Type of license + :return: The matching license record if found, None otherwise + """ + logger.info('Querying license records', compact=compact, state=jurisdiction) + + resp = self.config.provider_table.query( + IndexName=self.config.license_gsi_name, + KeyConditionExpression=( + Key('licenseGSIPK').eq(f'C#{compact.lower()}#J#{jurisdiction.lower()}') + & Key('licenseGSISK').eq(f'FN#{quote(family_name.lower())}#GN#{quote(given_name.lower())}') + ), + FilterExpression=( + Attr('ssnLastFour').eq(partial_ssn) + & Attr('dateOfBirth').eq(dob.isoformat()) + & Attr('licenseType').eq(license_type) + ), + ) + + matching_records = resp.get('Items', []) + + if len(matching_records) > 1: + logger.error('Multiple matching license records found') + raise CCInternalException('Multiple matching license records found') + + return LicenseData.from_database_record(matching_records[0]) if matching_records else None + + @paginated_query(set_query_limit_to_match_page_size=True) + @logger_inject_kwargs(logger, 'compact', 'provider_id') + def get_provider( + self, + *, + compact: str, + provider_id: str, + dynamo_pagination: dict, + detail: bool = True, + consistent_read: bool = False, + ) -> list[dict]: + logger.info('Getting provider') + if detail: + sk_condition = Key('sk').begins_with(f'{compact}#PROVIDER') + else: + sk_condition = Key('sk').eq(f'{compact}#PROVIDER') + + resp = self.config.provider_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq(f'{compact}#PROVIDER#{provider_id}') & sk_condition, + ConsistentRead=consistent_read, + **dynamo_pagination, + ) + if not resp['Items']: + raise CCNotFoundException('Provider not found') + + return resp + + @logger_inject_kwargs(logger, 'compact', 'provider_id') + def get_provider_user_records( + self, + *, + compact: str, + provider_id: UUID, + consistent_read: bool = True, + include_update_tier: UpdateTierEnum | None = None, + ) -> ProviderUserRecords: + logger.info('Getting provider') + + # Determine SK condition based on include_update_tier parameter + # When include_update_tier=None, use begins_with to get only main records (provider, licenses) + # When include_update_tier is set, use lt (less than) to get main records plus updates up to that tier + if include_update_tier is None: + # Get only main records: {compact}#PROVIDER prefix + sk_condition = Key('sk').begins_with(f'{compact}#PROVIDER') + else: + # Get main records and updates up to specified tier using lt (less than) + # This fetches all SKs less than {compact}#UPDATE#{next_tier} + next_tier = int(include_update_tier) + 1 + sk_condition = Key('sk').lt(f'{compact}#UPDATE#{next_tier}') + + resp = {'Items': []} + last_evaluated_key = None + + while True: + pagination = {'ExclusiveStartKey': last_evaluated_key} if last_evaluated_key else {} + + query_resp = self.config.provider_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq(f'{compact}#PROVIDER#{provider_id}') & sk_condition, + ConsistentRead=consistent_read, + **pagination, + ) + + resp['Items'].extend(query_resp.get('Items', [])) + + last_evaluated_key = query_resp.get('LastEvaluatedKey') + if not last_evaluated_key: + break + if not resp['Items']: + raise CCNotFoundException('Provider not found') + + return ProviderUserRecords(resp['Items']) + + @paginated_query(set_query_limit_to_match_page_size=False) + @logger_inject_kwargs(logger, 'compact', 'provider_name', 'jurisdiction') + def get_providers_sorted_by_family_name( + self, + *, + compact: str, + dynamo_pagination: dict, + provider_name: tuple[str, str] | None = None, # (familyName, givenName) + jurisdiction: str | None = None, + scan_forward: bool = True, + ): + logger.info('Getting providers by family name') + + # Create a name value to use in key condition if name fields are provided + name_value = None + if provider_name is not None and provider_name[0] is not None: + # Make the name lower case for case-insensitive search + name_value = f'{quote(provider_name[0].lower())}#' + # We won't consider givenName if familyName is not provided + if provider_name[1] is not None: + # Make the name lower case for case-insensitive search + name_value += f'{quote(provider_name[1].lower())}#' + + # Set key condition to query by + key_condition = Key('sk').eq(f'{compact}#PROVIDER') + if name_value is not None: + key_condition = key_condition & Key('providerFamGivMid').begins_with(name_value) + + # Create a jurisdiction filter expression if a jurisdiction is provided + if jurisdiction is not None: + filter_expression = Attr('licenseJurisdiction').eq(jurisdiction) + else: + filter_expression = None + + return config.provider_table.query( + IndexName=config.fam_giv_mid_index_name, + Select='ALL_ATTRIBUTES', + KeyConditionExpression=key_condition, + ScanIndexForward=scan_forward, + **({'FilterExpression': filter_expression} if filter_expression is not None else {}), + **dynamo_pagination, + ) + + @paginated_query(set_query_limit_to_match_page_size=False) + @logger_inject_kwargs(logger, 'compact', 'jurisdiction') + def get_providers_sorted_by_updated( + self, + *, + compact: str, + dynamo_pagination: dict, + jurisdiction: str | None = None, + scan_forward: bool = True, + start_date_time: str | None = None, + end_date_time: str | None = None, + ): + logger.info('Getting providers by date updated') + + filter_expression = Attr('licenseJurisdiction').eq(jurisdiction) if jurisdiction is not None else None + + # Build key condition expression with optional date range + key_condition = Key('sk').eq(f'{compact}#PROVIDER') + + # Add date range conditions if provided + if start_date_time is not None and end_date_time is not None: + key_condition = key_condition & Key('providerDateOfUpdate').between(start_date_time, end_date_time) + elif start_date_time is not None: + key_condition = key_condition & Key('providerDateOfUpdate').gte(start_date_time) + elif end_date_time is not None: + key_condition = key_condition & Key('providerDateOfUpdate').lte(end_date_time) + + return config.provider_table.query( + IndexName=config.date_of_update_index_name, + Select='ALL_ATTRIBUTES', + KeyConditionExpression=key_condition, + ScanIndexForward=scan_forward, + **({'FilterExpression': filter_expression} if filter_expression is not None else {}), + **dynamo_pagination, + ) + + @logger_inject_kwargs(logger, 'compact', 'provider_ids') + def batch_get_providers_by_id(self, compact: str, provider_ids: list[str]) -> list[dict]: + """ + Get provider records by their IDs in batches. + + :param compact: The compact name + :param provider_ids: List of provider IDs to fetch + :return: List of provider records + """ + providers = [] + # DynamoDB batch_get_item has a limit of 100 items per request + batch_size = 100 + + # Process provider IDs in batches + for i in range(0, len(provider_ids), batch_size): + batch_ids = provider_ids[i : i + batch_size] + request_items = { + self.config.provider_table.table_name: { + 'Keys': [ + {'pk': f'{compact}#PROVIDER#{provider_id}', 'sk': f'{compact}#PROVIDER'} + for provider_id in batch_ids + ], + 'ConsistentRead': True, + } + } + + response = self.config.provider_table.meta.client.batch_get_item(RequestItems=request_items) + + # Add the returned items to our results + if response['Responses']: + providers.extend(response['Responses'][self.config.provider_table.table_name]) + + # Handle any unprocessed keys by retrying with exponential backoff + retry_attempts = 0 + max_retries = 3 + base_sleep_time = 0.5 # 50ms initial sleep + + while response.get('UnprocessedKeys') and retry_attempts <= max_retries: + # Calculate exponential backoff sleep time + sleep_time = min(base_sleep_time * (2**retry_attempts), 5) # Cap at 5 seconds + time.sleep(sleep_time) + + response = self.config.provider_table.meta.client.batch_get_item( + RequestItems=response['UnprocessedKeys'] + ) + if response['Responses']: + providers.extend(response['Responses'][self.config.provider_table.table_name]) + + retry_attempts += 1 + + if response.get('UnprocessedKeys'): + # this is unlikely to happen, but if it does, we log it and continue + logger.error('Failed to fetch all provider records', unprocessed_keys=response['UnprocessedKeys']) + + return providers + + @logger_inject_kwargs(logger, 'compact', 'provider_id') + def get_provider_top_level_record(self, *, compact: str, provider_id: str) -> ProviderData: + """Get the top level provider record for a provider. + + :param compact: The compact name + :param provider_id: The provider ID + :return: The top level provider record + """ + logger.info('Getting top level provider record') + provider = self.config.provider_table.get_item( + Key={ + 'pk': f'{compact}#PROVIDER#{provider_id}', + 'sk': f'{compact}#PROVIDER', + }, + ConsistentRead=True, + ).get('Item') + if provider is None: + logger.info( + 'Provider not found for compact {compact} and provider id {provider_id}', + compact=compact, + provider_id=provider_id, + ) + raise CCNotFoundException(f'Provider not found for compact {compact} and provider id {provider_id}') + + return ProviderData.from_database_record(provider) + + def _generate_encumbered_status_update_item( + self, + data: CCDataClass, + encumbered_status: LicenseEncumberedStatusEnum, + ): + data_record = data.serialize_to_database_record() + + return { + 'Update': { + 'TableName': self.config.provider_table.name, + 'Key': {'pk': {'S': data_record['pk']}, 'sk': {'S': data_record['sk']}}, + 'UpdateExpression': 'SET encumberedStatus = :status, dateOfUpdate = :dateOfUpdate', + 'ExpressionAttributeValues': { + ':status': {'S': encumbered_status}, + ':dateOfUpdate': {'S': self.config.current_standard_datetime.isoformat()}, + }, + }, + } + + def _generate_set_license_encumbered_status_item( + self, + license_data: LicenseData, + license_encumbered_status: LicenseEncumberedStatusEnum, + ): + return self._generate_encumbered_status_update_item( + data=license_data, + encumbered_status=license_encumbered_status, + ) + + def _generate_set_provider_encumbered_status_item( + self, + provider_data: ProviderData, + # licenses and providers share the same encumbered status enum + provider_encumbered_status: LicenseEncumberedStatusEnum, + ): + return self._generate_encumbered_status_update_item( + data=provider_data, + encumbered_status=provider_encumbered_status, + ) + + def _generate_put_transaction_item(self, item: dict): + return {'Put': {'TableName': self.config.provider_table.name, 'Item': TypeSerializer().serialize(item)['M']}} + + def _generate_adverse_action_lift_update_item( + self, target_adverse_action: AdverseActionData, effective_lift_date: date, lifting_user: str + ) -> dict: + """ + Generate a transaction item to update an adverse action record with lift information. + + :param AdverseActionData target_adverse_action: The adverse action to update + :param date effective_lift_date: The effective date when the encumbrance is lifted + :param str lifting_user: The cognito sub of the user lifting the encumbrance + :return: DynamoDB transaction item for updating the adverse action + """ + serialized_target_adverse_action = target_adverse_action.serialize_to_database_record() + return { + 'Update': { + 'TableName': self.config.provider_table.name, + 'Key': { + 'pk': {'S': serialized_target_adverse_action['pk']}, + 'sk': {'S': serialized_target_adverse_action['sk']}, + }, + 'ConditionExpression': 'attribute_not_exists(effectiveLiftDate)', + 'UpdateExpression': 'SET effectiveLiftDate = :lift_date, ' + 'liftingUser = :lifting_user, ' + 'dateOfUpdate = :date_of_update', + 'ExpressionAttributeValues': { + ':lift_date': {'S': effective_lift_date.isoformat()}, + ':lifting_user': {'S': lifting_user}, + ':date_of_update': {'S': self.config.current_standard_datetime.isoformat()}, + }, + }, + } + + def _validate_license_type_abbreviation(self, compact: str, license_type_abbreviation: str) -> str: + """ + Validate license type abbreviation and return the full license type name. + + :param str compact: The compact name + :param str license_type_abbreviation: The license type abbreviation to validate + :return: The full license type name + :raises CCInvalidRequestException: If the license type abbreviation is invalid + """ + return LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name + + def _find_and_validate_adverse_action( + self, adverse_action_records: list[AdverseActionData], adverse_action_id: UUID + ) -> AdverseActionData: + """ + Find and validate an adverse action record from a list of records. + + :param list[AdverseActionData] adverse_action_records: List of adverse action records to search + :param UUID adverse_action_id: The ID of the adverse action to find + :return: The found adverse action record + :raises CCNotFoundException: If the adverse action record is not found + :raises CCInvalidRequestException: If the encumbrance has already been lifted + """ + # Find the specific adverse action record to lift + target_adverse_action: AdverseActionData | None = None + for adverse_action in adverse_action_records: + if adverse_action.adverseActionId == adverse_action_id: + target_adverse_action = adverse_action + break + + if target_adverse_action is None: + raise CCNotFoundException('Encumbrance record not found') + + # Check if the adverse action has already been lifted + if target_adverse_action.effectiveLiftDate is not None: + raise CCInvalidRequestException('Encumbrance has already been lifted') + + return target_adverse_action + + def _get_unlifted_adverse_actions( + self, adverse_action_records: list[AdverseActionData], target_adverse_action_id: UUID + ) -> list[AdverseActionData]: + """ + Get all unlifted adverse actions excluding the target adverse action. + + :param list[AdverseActionData] adverse_action_records: List of adverse action records + :param UUID target_adverse_action_id: The ID of the target adverse action being lifted + :return: List of unlifted adverse actions excluding the target one + """ + return [ + aa + for aa in adverse_action_records + if aa.effectiveLiftDate is None and aa.adverseActionId != target_adverse_action_id + ] + + def _generate_provider_encumbered_status_update_item_if_not_already_encumbered( + self, adverse_action: AdverseActionData, transaction_items: list[dict] + ) -> list[dict]: + """ + Adds a transaction item to the provided list which updates the provider encumberedStatus to encumbered if the + provider is not already encumbered. + + If the provider is already encumbered, we do not add a transaction item to the list and return + it unchanged. + + We set this status at the provider level to show they are not able to purchase privileges within the compact. + + :param AdverseActionData adverse_action: The adverse action data + :param list[dict] transaction_items: The list of transaction items to update + :return: The list of transaction items + """ + try: + provider_record = self.config.provider_table.get_item( + Key={ + 'pk': f'{adverse_action.compact}#PROVIDER#{adverse_action.providerId}', + 'sk': f'{adverse_action.compact}#PROVIDER', + }, + )['Item'] + except KeyError as e: + message = 'Provider not found' + logger.info(message) + raise CCNotFoundException(message) from e + + provider_data = ProviderData.from_database_record(provider_record) + + need_to_set_provider_to_encumbered = True + if provider_data.encumberedStatus == LicenseEncumberedStatusEnum.ENCUMBERED: + logger.info('Provider already encumbered. Not updating provider encumbered status') + need_to_set_provider_to_encumbered = False + else: + logger.info( + 'Provider is currently unencumbered. Setting provider into an encumbered state as part of update.' + ) + + if need_to_set_provider_to_encumbered: + # Set the provider record's encumberedStatus to encumbered + transaction_items.append( + self._generate_set_provider_encumbered_status_item( + provider_data=provider_data, + provider_encumbered_status=LicenseEncumberedStatusEnum.ENCUMBERED, + ) + ) + + return transaction_items + + def _generate_provider_encumbered_status_transaction_items_if_no_encumbrances( + self, + provider_user_records: ProviderUserRecords, + excluded_adverse_action_id: UUID | None = None, + ) -> list[dict]: + """ + Check if any adverse action records are still active (no effectiveLiftDate). + If none are active (optionally excluding one being lifted), return transaction items + to set the provider record to unencumbered. + + :param ProviderUserRecords provider_user_records: All provider records + :param excluded_adverse_action_id: When lifting an encumbrance, the adverse action ID being + lifted so it is excluded from the "still active" check + :return: List of transaction items (empty if other encumbrances are still active) + """ + # Get adverse action records that are still active (no effectiveLiftDate set) + active_adverse_actions = provider_user_records.get_adverse_action_records( + filter_condition=lambda aa: aa.effectiveLiftDate is None + ) + # Exclude the one we're lifting from the count, if provided + if excluded_adverse_action_id is not None: + active_adverse_actions = [ + aa for aa in active_adverse_actions if aa.adverseActionId != excluded_adverse_action_id + ] + if active_adverse_actions: + logger.info( + 'Adverse action(s) still active (no effectiveLiftDate), provider record will not be updated', + active_count=len(active_adverse_actions), + ) + return [] + + # No other encumbrances are active, so we can set the provider to unencumbered + logger.info('No other adverse actions are active, setting provider to unencumbered') + + provider_record = provider_user_records.get_provider_record() + provider_update_item = self._generate_set_provider_encumbered_status_item( + provider_data=provider_record, + provider_encumbered_status=LicenseEncumberedStatusEnum.UNENCUMBERED, + ) + + return [provider_update_item] + + def encumber_privilege(self, adverse_action: AdverseActionData) -> None: + """ + Adds an adverse action record for a privilege (jurisdiction) for a provider. + + We only store the adverse action and update the provider encumbered status. + No privilege or privilege-update records are written. + + :param AdverseActionData adverse_action: The details of the adverse action to be added to the records + """ + with logger.append_context_keys( + compact=adverse_action.compact, + provider_id=adverse_action.providerId, + jurisdiction=adverse_action.jurisdiction, + license_type_abbreviation=adverse_action.licenseTypeAbbreviation, + ): + logger.info('Adding encumbrance for jurisdiction') + transact_items = [ + self._generate_put_transaction_item(adverse_action.serialize_to_database_record()), + ] + + # If the provider is not already encumbered, we need to update the provider record to encumbered + transact_items = self._generate_provider_encumbered_status_update_item_if_not_already_encumbered( + adverse_action=adverse_action, + transaction_items=transact_items, + ) + + self.config.dynamodb_client.transact_write_items( + TransactItems=transact_items, + ) + + logger.info('Set encumbrance for privilege jurisdiction') + + def encumber_license(self, adverse_action: AdverseActionData) -> None: + """ + Adds an adverse action record for a license for a provider in a jurisdiction. + + This will also update the license record to have a encumberedStatus of 'encumbered', add a license update + record to show the encumbrance event, and update the provider record to have a encumberedStatus of 'encumbered'. + + :param AdverseActionData adverse_action: The details of the adverse action to be added to the records + :raises CCNotFoundException: If the license record is not found + """ + with logger.append_context_keys( + compact=adverse_action.compact, + provider_id=adverse_action.providerId, + jurisdiction=adverse_action.jurisdiction, + license_type_abbreviation=adverse_action.licenseTypeAbbreviation, + ): + # Get the license record + try: + license_record = self.config.provider_table.get_item( + Key={ + 'pk': f'{adverse_action.compact}#PROVIDER#{adverse_action.providerId}', + 'sk': f'{adverse_action.compact}#PROVIDER#license/' + f'{adverse_action.jurisdiction}/{adverse_action.licenseTypeAbbreviation}#', + }, + )['Item'] + except KeyError as e: + message = 'License not found for jurisdiction' + logger.info(message) + raise CCNotFoundException(f'{message} {adverse_action.jurisdiction}') from e + + license_data = LicenseData.from_database_record(license_record) + + need_to_set_license_to_encumbered = True + # If already encumbered, do nothing + if license_data.encumberedStatus == LicenseEncumberedStatusEnum.ENCUMBERED: + logger.info('License already encumbered. Not updating license compact eligibility status') + need_to_set_license_to_encumbered = False + else: + logger.info( + 'License is currently unencumbered. Setting license into an encumbered state as part of update.' + ) + + now = config.current_standard_datetime + + # The time selected here is somewhat arbitrary; however, we want this selection to not alter the date + # displayed for a user when it is transformed back to their timezone. We selected noon UTC-4:00 so that + # users across the entire US will see the same date + effective_date_time = datetime.combine( + adverse_action.effectiveStartDate, dtime(12, 0, 0), tzinfo=config.expiration_resolution_timezone + ) + + # Create the update record + # Use the schema to generate the update record with proper pk/sk + license_update_record = LicenseUpdateData.create_new( + { + 'type': ProviderRecordType.LICENSE_UPDATE, + 'updateType': UpdateCategory.ENCUMBRANCE, + 'providerId': adverse_action.providerId, + 'compact': adverse_action.compact, + 'jurisdiction': adverse_action.jurisdiction, + 'licenseType': license_data.licenseType, + 'createDate': now, + 'effectiveDate': effective_date_time, + 'previous': { + # We're relying on the schema to trim out unneeded fields + **license_data.to_dict(), + }, + 'updatedValues': { + 'encumberedStatus': LicenseEncumberedStatusEnum.ENCUMBERED, + } + if need_to_set_license_to_encumbered + else {}, + } + ).serialize_to_database_record() + # Update the privilege record and create history record + logger.info('Encumbering license') + # we add the adverse action record for the license, + # the license update record, and update the license record to ineligible if it is not already ineligible + transact_items = [ + # Create a history record, reflecting this change + self._generate_put_transaction_item(license_update_record), + # Add the adverse action record for the license + self._generate_put_transaction_item(adverse_action.serialize_to_database_record()), + ] + + if need_to_set_license_to_encumbered: + # Set the license record's encumberedStatus to encumbered + transact_items.append( + self._generate_set_license_encumbered_status_item( + license_data=license_data, + license_encumbered_status=LicenseEncumberedStatusEnum.ENCUMBERED, + ) + ) + + transact_items = self._generate_provider_encumbered_status_update_item_if_not_already_encumbered( + adverse_action=adverse_action, + transaction_items=transact_items, + ) + + self.config.dynamodb_client.transact_write_items( + TransactItems=transact_items, + ) + + logger.info('Set encumbrance for license record') + + def create_investigation(self, investigation: InvestigationData) -> None: + """ + Creates an investigation record for a provider in a jurisdiction. + + If the investigation is against a license, this will also update the license record to have + an investigationStatus of 'underInvestigation', and add an update record to show the investigation event. + + :param InvestigationData investigation: The details of the investigation to be added to the records + :raises CCNotFoundException: If the record is not found + """ + with logger.append_context_keys( + compact=investigation.compact, + provider_id=investigation.providerId, + jurisdiction=investigation.jurisdiction, + license_type_abbreviation=investigation.licenseTypeAbbreviation, + ): + # Get the record (privilege or license) + record_type = investigation.investigationAgainst + + # Query for the record (privilege or license) and all its investigations in a single query + provider_records = self.get_provider_user_records( + compact=investigation.compact, provider_id=investigation.providerId, consistent_read=True + ) + + # Privilege investigations: only store the investigation record (no privilege/privilege-update records). + # License investigations: require license record + # put investigation + license update record + update license. + if investigation.investigationAgainst == InvestigationAgainstEnum.LICENSE: + record = provider_records.get_specific_license_record( + investigation.jurisdiction, investigation.licenseTypeAbbreviation + ) + if not record: + message = f'{record_type.title()} not found for jurisdiction' + logger.info(message) + raise CCNotFoundException( + f'{record_type.title()} not found for jurisdiction {investigation.jurisdiction}' + ) + + update_data_type = LicenseUpdateData + update_type = ProviderRecordType.LICENSE_UPDATE + investigation_details = {'investigationId': investigation.investigationId} + update_record = update_data_type.create_new( + { + 'type': update_type, + 'updateType': UpdateCategory.INVESTIGATION, + 'providerId': investigation.providerId, + 'compact': investigation.compact, + 'jurisdiction': investigation.jurisdiction, + 'createDate': investigation.creationDate, + 'effectiveDate': investigation.creationDate, + 'licenseType': investigation.licenseType, + 'previous': record.to_dict(), + 'updatedValues': { + 'investigationStatus': InvestigationStatusEnum.UNDER_INVESTIGATION, + }, + 'investigationDetails': investigation_details, + } + ) + serialized_record = record.serialize_to_database_record() + transaction_items = [ + self._generate_put_transaction_item(investigation.serialize_to_database_record()), + self._generate_put_transaction_item(update_record.serialize_to_database_record()), + { + 'Update': { + 'TableName': self.config.provider_table.table_name, + 'Key': { + 'pk': {'S': serialized_record['pk']}, + 'sk': {'S': serialized_record['sk']}, + }, + 'UpdateExpression': ( + 'SET investigationStatus = :investigationStatus, dateOfUpdate = :dateOfUpdate' + ), + 'ConditionExpression': 'attribute_exists(pk)', + 'ExpressionAttributeValues': { + ':investigationStatus': {'S': InvestigationStatusEnum.UNDER_INVESTIGATION}, + ':dateOfUpdate': {'S': investigation.creationDate.isoformat()}, + }, + } + }, + ] + else: + # Privilege: store only the investigation record. + transaction_items = [ + self._generate_put_transaction_item(investigation.serialize_to_database_record()), + ] + + # Execute the transaction + self.config.dynamodb_client.transact_write_items(TransactItems=transaction_items) + + logger.info(f'Set investigation for {record_type} record') + + def close_investigation( + self, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + investigation_id: UUID, + closing_user: str, + close_date: datetime, + investigation_against: InvestigationAgainstEnum, + resulting_encumbrance_id: UUID = None, + ) -> None: + """ + Closes an investigation by updating the investigation record. + + Only removes the investigation status and creates an update record if this is the last open investigation. + + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction + :param license_type_abbreviation: The license type abbreviation + :param investigation_id: The investigation ID + :param closing_user: The user who closed the investigation + :param close_date: The date that the investigation was closed + :param investigation_against: Whether investigating a privilege or license + :param resulting_encumbrance_id: Optional encumbrance ID to reference in the investigation closure + """ + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + investigation_id=investigation_id, + ): + record_type = investigation_against.value + + # Query for the record (privilege or license) and all its investigations in a single query + provider_records = self.get_provider_user_records( + compact=compact, provider_id=provider_id, consistent_read=True + ) + + # Find the investigation to close and count other open investigations + if investigation_against == InvestigationAgainstEnum.LICENSE: + record = provider_records.get_specific_license_record(jurisdiction, license_type_abbreviation) + if not record: + message = f'{record_type.title()} not found for jurisdiction' + logger.info(message) + raise CCNotFoundException(f'{record_type.title()} not found for jurisdiction {jurisdiction}') + + open_investigations = provider_records.get_investigation_records_for_license( + jurisdiction, + license_type_abbreviation, + filter_condition=lambda inv: inv.investigationId != investigation_id, + ) + investigation = next( + ( + inv + for inv in provider_records.get_investigation_records_for_license( + jurisdiction, + license_type_abbreviation, + filter_condition=lambda inv: inv.investigationId == investigation_id, + ) + ), + None, + ) + else: + # Privilege: no stored privilege record; find investigation by jurisdiction/license type only. + open_investigations = provider_records.get_investigation_records_for_privilege( + jurisdiction, + license_type_abbreviation, + filter_condition=lambda inv: inv.closeDate is None and inv.investigationId != investigation_id, + ) + investigation = next( + ( + inv + for inv in provider_records.get_investigation_records_for_privilege( + jurisdiction, + license_type_abbreviation, + filter_condition=lambda inv: inv.investigationId == investigation_id, + ) + ), + None, + ) + + if investigation is None: + raise CCNotFoundException('Investigation not found') + + is_last_open_investigation_against_license = ( + investigation_against == InvestigationAgainstEnum.LICENSE and len(open_investigations) == 0 + ) + + # Build the investigation update expression and values + investigation_update_expression = ( + 'SET closeDate = :closeDate, closingUser = :closingUser, dateOfUpdate = :dateOfUpdate' + ) + investigation_expression_values = { + ':closeDate': {'S': close_date.isoformat()}, + ':closingUser': {'S': closing_user}, + ':dateOfUpdate': {'S': close_date.isoformat()}, + } + + # Add resultingEncumbranceId if an encumbrance was created + if resulting_encumbrance_id: + investigation_update_expression += ', resultingEncumbranceId = :resultingEncumbranceId' + investigation_expression_values[':resultingEncumbranceId'] = {'S': str(resulting_encumbrance_id)} + + # Always update the investigation record itself + transaction_items = [ + { + 'Update': { + 'TableName': self.config.provider_table.table_name, + 'Key': { + 'pk': {'S': investigation.pk}, + 'sk': {'S': investigation.sk}, + }, + 'UpdateExpression': investigation_update_expression, + 'ConditionExpression': 'attribute_exists(pk) AND attribute_not_exists(closeDate)', + 'ExpressionAttributeValues': investigation_expression_values, + } + }, + ] + + # License only: when last open investigation, create license update record and remove status from license + if is_last_open_investigation_against_license: + update_record = LicenseUpdateData.create_new( + { + 'type': ProviderRecordType.LICENSE_UPDATE, + 'updateType': UpdateCategory.CLOSING_INVESTIGATION, + 'providerId': provider_id, + 'compact': compact, + 'jurisdiction': jurisdiction, + 'createDate': close_date, + 'effectiveDate': close_date, + 'licenseType': record.licenseType, + 'previous': record.to_dict(), + 'updatedValues': {}, + 'removedValues': ['investigationStatus'], + } + ) + serialized_record = record.serialize_to_database_record() + transaction_items.extend( + [ + self._generate_put_transaction_item(update_record.serialize_to_database_record()), + { + 'Update': { + 'TableName': self.config.provider_table.table_name, + 'Key': { + 'pk': {'S': serialized_record['pk']}, + 'sk': {'S': serialized_record['sk']}, + }, + 'UpdateExpression': 'REMOVE investigationStatus SET dateOfUpdate = :dateOfUpdate', + 'ConditionExpression': 'attribute_exists(pk)', + 'ExpressionAttributeValues': { + ':dateOfUpdate': {'S': close_date.isoformat()}, + }, + } + }, + ] + ) + + # Execute the transaction + try: + self.config.dynamodb_client.transact_write_items(TransactItems=transaction_items) + except Exception as e: + # Check if this is a TransactionCanceledException with ConditionalCheckFailed + if hasattr(e, 'response') and e.response.get('CancellationReasons'): + for reason in e.response['CancellationReasons']: + if reason.get('Code') == 'ConditionalCheckFailed': + logger.info('Investigation not found or already closed') + raise CCNotFoundException(f'Investigation not found: {investigation_id}') from e + # Re-raise if it's not a conditional check failure + raise + + logger.info(f'Closed investigation for {record_type} record') + + def lift_privilege_encumbrance( + self, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + adverse_action_id: UUID, + effective_lift_date: date, + lifting_user: str, + ) -> None: + """ + Lift an encumbrance for a privilege (jurisdiction) by updating the adverse action record + and, if applicable, the provider's encumbered status. + + :param str compact: The compact name + :param str provider_id: The provider ID + :param str jurisdiction: The jurisdiction + :param str license_type_abbreviation: The license type abbreviation + :param str adverse_action_id: The adverse action ID to lift + :param date effective_lift_date: The effective date when the encumbrance is lifted + :param str lifting_user: The cognito sub of the user lifting the encumbrance + :raises CCNotFoundException: If the adverse action record is not found + :raises CCInvalidRequestException: If the encumbrance has already been lifted + """ + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + adverse_action_id=adverse_action_id, + ): + logger.info('Lifting privilege encumbrance') + + # Get all provider records + provider_user_records = self.get_provider_user_records( + compact=compact, + provider_id=provider_id, + consistent_read=True, + ) + + # Get adverse action records for this privilege + adverse_action_records = provider_user_records.get_adverse_action_records_for_privilege( + privilege_jurisdiction=jurisdiction, + privilege_license_type_abbreviation=license_type_abbreviation, + ) + + # Find the specific adverse action record to lift + target_adverse_action = self._find_and_validate_adverse_action(adverse_action_records, adverse_action_id) + + # Build transaction items + # Always update the adverse action record with lift information + transact_items = [ + self._generate_adverse_action_lift_update_item( + target_adverse_action=target_adverse_action, + effective_lift_date=effective_lift_date, + lifting_user=lifting_user, + ) + ] + + # Check if provider should be set to unencumbered + provider_status_items = self._generate_provider_encumbered_status_transaction_items_if_no_encumbrances( + provider_user_records=provider_user_records, + excluded_adverse_action_id=adverse_action_id, + ) + transact_items.extend(provider_status_items) + + # Execute the transaction + self.config.dynamodb_client.transact_write_items(TransactItems=transact_items) + + logger.info('Successfully lifted privilege encumbrance') + + def lift_license_encumbrance( + self, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + adverse_action_id: UUID, + effective_lift_date: date, + lifting_user: str, + ) -> None: + """ + Lift an encumbrance from a license record by updating the adverse action record + and potentially updating the license record's encumbered status. + + :param str compact: The compact name + :param UUID provider_id: The provider ID + :param str jurisdiction: The jurisdiction + :param str license_type_abbreviation: The license type abbreviation + :param UUID adverse_action_id: The adverse action ID to lift + :param date effective_lift_date: The effective date when the encumbrance is lifted + :param str lifting_user: The cognito sub of the user lifting the encumbrance + :raises CCNotFoundException: If the adverse action record is not found + :raises CCInvalidRequestException: If the encumbrance has already been lifted + """ + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + adverse_action_id=adverse_action_id, + ): + license_type_name = self._validate_license_type_abbreviation(compact, license_type_abbreviation) + + logger.info('Lifting license encumbrance') + + # Get all provider records + provider_user_records = self.get_provider_user_records( + compact=compact, + provider_id=provider_id, + consistent_read=True, + ) + + # Get adverse action records for this license + adverse_action_records = provider_user_records.get_adverse_action_records_for_license( + license_jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + ) + + # Find the specific adverse action record to lift + target_adverse_action = self._find_and_validate_adverse_action(adverse_action_records, adverse_action_id) + + # Get the license record + license_records = provider_user_records.get_license_records( + filter_condition=lambda record: ( + record.jurisdiction == jurisdiction and record.licenseType == license_type_name + ) + ) + + if not license_records: + message = 'License record not found for adverse action record.' + logger.error(message, license_type_name=license_type_name) + raise CCInternalException(message) + + license_data = license_records[0] + + # Build transaction items + transact_items = [] + + # Always update the adverse action record with lift information + transact_items.append( + self._generate_adverse_action_lift_update_item( + target_adverse_action=target_adverse_action, + effective_lift_date=effective_lift_date, + lifting_user=lifting_user, + ) + ) + + # If this was the last unlifted adverse action, update license status and create update record + unlifted_adverse_actions = self._get_unlifted_adverse_actions(adverse_action_records, adverse_action_id) + if not unlifted_adverse_actions: + # Update license record to unencumbered status + license_update_item = self._generate_set_license_encumbered_status_item( + license_data=license_data, + license_encumbered_status=LicenseEncumberedStatusEnum.UNENCUMBERED, + ) + transact_items.append(license_update_item) + + now = config.current_standard_datetime + + # The time selected here is somewhat arbitrary; however, we want this selection to not alter the date + # displayed for a user when it is transformed back to their timezone. We selected noon UTC-4:00 so that + # users across the entire US will see the same date + effective_date_time = datetime.combine( + effective_lift_date, dtime(12, 0, 0), tzinfo=config.expiration_resolution_timezone + ) + + # Create license update record + license_update_record = LicenseUpdateData.create_new( + { + 'type': ProviderRecordType.LICENSE_UPDATE, + 'updateType': UpdateCategory.LIFTING_ENCUMBRANCE, + 'providerId': provider_id, + 'compact': compact, + 'jurisdiction': jurisdiction, + 'licenseType': license_data.licenseType, + 'createDate': now, + 'effectiveDate': effective_date_time, + 'previous': license_data.to_dict(), + 'updatedValues': { + 'encumberedStatus': LicenseEncumberedStatusEnum.UNENCUMBERED, + }, + } + ).serialize_to_database_record() + + transact_items.append(self._generate_put_transaction_item(license_update_record)) + + # Check if provider should be set to unencumbered + provider_status_items = self._generate_provider_encumbered_status_transaction_items_if_no_encumbrances( + provider_user_records=provider_user_records, + excluded_adverse_action_id=adverse_action_id, + ) + transact_items.extend(provider_status_items) + + # Execute the transaction + self.config.dynamodb_client.transact_write_items(TransactItems=transact_items) + + logger.info('Successfully lifted license encumbrance') diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/provider_record_util.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/provider_record_util.py new file mode 100644 index 0000000000..f7e70260f0 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/provider_record_util.py @@ -0,0 +1,679 @@ +from collections.abc import Callable, Iterable +from datetime import date +from enum import StrEnum +from uuid import UUID + +from cc_common.config import config, logger +from cc_common.data_model.schema.adverse_action import AdverseActionData +from cc_common.data_model.schema.common import ( + ActiveInactiveStatus, + AdverseActionAgainstEnum, + CompactEligibilityStatus, + InvestigationStatusEnum, + UpdateCategory, +) +from cc_common.data_model.schema.investigation import InvestigationData +from cc_common.data_model.schema.license import LicenseData, LicenseUpdateData +from cc_common.data_model.schema.provider import ProviderData, ProviderUpdateData +from cc_common.exceptions import CCInternalException, CCNotFoundException + + +class ProviderRecordType(StrEnum): + """ + The type of provider record. + """ + + PROVIDER = 'provider' + PROVIDER_UPDATE = 'providerUpdate' + LICENSE = 'license' + LICENSE_UPDATE = 'licenseUpdate' + ADVERSE_ACTION = 'adverseAction' + INVESTIGATION = 'investigation' + + +# The following update event types are used during events which caused +# licenses/privileges to become inactive +DEACTIVATION_EVENT_TYPES: list[UpdateCategory] = [ + UpdateCategory.EXPIRATION, + UpdateCategory.DEACTIVATION, + UpdateCategory.ENCUMBRANCE, + UpdateCategory.LICENSE_DEACTIVATION, +] + + +def _license_sort_key(license_record: dict | LicenseData) -> tuple: + """ + Sort key for license records: by date of renewal if present, else date of issuance; + use date of issuance as tiebreaker. Works with both dict and LicenseData so the same + ordering is used in find_best_license (dicts) and find_best_license_in_current_known_licenses (LicenseData). + """ + if isinstance(license_record, dict): + effective_date = license_record.get('dateOfRenewal') or license_record['dateOfIssuance'] + date_of_issuance = license_record['dateOfIssuance'] + else: + effective_date = license_record.dateOfRenewal or license_record.dateOfIssuance + date_of_issuance = license_record.dateOfIssuance + return effective_date, date_of_issuance + + +class ProviderRecordUtility: + """ + A class for housing official logic for how to handle provider records without making database queries. + """ + + @staticmethod + def get_records_of_type( + provider_records: Iterable[dict], + record_type: ProviderRecordType, + _filter: Callable | None = None, + ) -> list[dict]: + """ + Get all records of a given type from a list of provider records. + + :param provider_records: The list of provider records to search through + :param record_type: The type of record to search for + :param _filter: An optional filter to apply to the records + :return: A list of records of the given type + """ + return [ + record + for record in provider_records + if record['type'] == record_type and (_filter is None or _filter(record)) + ] + + @staticmethod + def get_provider_record(provider_records: Iterable[dict]) -> dict | None: + """ + Get the provider record from a list of records associated with a provider. + """ + provider_records = ProviderRecordUtility.get_records_of_type(provider_records, ProviderRecordType.PROVIDER) + return provider_records[0] if provider_records else None + + @classmethod + def find_most_recently_issued_or_renewed_license(cls, license_records: Iterable[dict]) -> dict: + """ + This selects the license renewed or issued most recently. Sort by date of renewal + if present, otherwise date of issuance; use date of issuance as tiebreaker. Compact + eligibility and active status are not considered. + + :param license_records: An iterable of license records + :return: The best license record + """ + latest_licenses = sorted(license_records, key=_license_sort_key, reverse=True) + if not latest_licenses: + raise CCInternalException('No licenses found') + + return latest_licenses[0] + + @staticmethod + def populate_provider_record(current_provider_record: ProviderData | None, license_record: dict) -> ProviderData: + """ + Create a provider record from a license record. + + :param current_provider_record: The current provider record to update if it currently exists. + :param license_record: The license record to use as a basis for the provider record + :return: A provider record ready to be persisted + """ + if current_provider_record is None: + return ProviderData.create_new( + { + 'providerId': license_record['providerId'], + 'compact': license_record['compact'], + 'licenseJurisdiction': license_record['jurisdiction'], + **license_record, + } + ) + # else populate the current fields of the provider record first before updating with + # new values + return ProviderData.create_new( + { + # keep existing values from the current provider record + **current_provider_record.to_dict(), + # update the license jurisdiction to match the new license + 'licenseJurisdiction': license_record['jurisdiction'], + # now override the key values on the current provider record with the new license record + **license_record, + } + ) + + +class ProviderUserRecords: + """ + A collection of provider records for a single provider. + This class is used to get all records for a single provider and provide utilities for getting specific records + """ + + def __init__(self, provider_records: Iterable[dict]): + # list of all records for this provider in dict format, which can be used for parts of the system that + # have not been updated to use the data class pattern + self.provider_records = provider_records + + # Pre-convert and categorize records by type for efficiency + self._license_records: list[LicenseData] = [] + self._adverse_action_records: list[AdverseActionData] = [] + self._investigation_records: list[InvestigationData] = [] + self._provider_records: list[ProviderData] = [] + self._provider_update_records: list[ProviderUpdateData] = [] + self._license_update_records: list[LicenseUpdateData] = [] + + # Convert records once during initialization (skip privilege/privilegeUpdate; no longer stored) + for record in provider_records: + record_type = record.get('type') + if record_type == ProviderRecordType.LICENSE: + self._license_records.append(LicenseData.from_database_record(record)) + elif record_type == ProviderRecordType.ADVERSE_ACTION: + self._adverse_action_records.append(AdverseActionData.from_database_record(record)) + elif record_type == ProviderRecordType.INVESTIGATION: + self._investigation_records.append(InvestigationData.from_database_record(record)) + elif record_type == ProviderRecordType.PROVIDER: + self._provider_records.append(ProviderData.from_database_record(record)) + elif record_type == ProviderRecordType.PROVIDER_UPDATE: + self._provider_update_records.append(ProviderUpdateData.from_database_record(record)) + elif record_type == ProviderRecordType.LICENSE_UPDATE: + self._license_update_records.append(LicenseUpdateData.from_database_record(record)) + else: + # log the warning, but continue with initialization + logger.warning('Unrecognized record type found.', record_type=record_type) + + def get_specific_license_record(self, jurisdiction: str, license_abbreviation: str) -> LicenseData | None: + """ + Get a specific license record from a list of provider records. + + :param jurisdiction: The jurisdiction of the license. + :param license_abbreviation: The abbreviation of the license type. + :return: The license record if found, else None. + """ + return next( + ( + record + for record in self._license_records + if record.jurisdiction == jurisdiction and record.licenseTypeAbbreviation == license_abbreviation + ), + None, + ) + + def get_license_records( + self, + filter_condition: Callable[[LicenseData], bool] | None = None, + ) -> list[LicenseData]: + """ + Get all license records from a list of provider records. + """ + return [record for record in self._license_records if filter_condition is None or filter_condition(record)] + + def get_adverse_action_records( + self, filter_condition: Callable[[AdverseActionData], bool] | None = None + ) -> list[AdverseActionData]: + return [ + record for record in self._adverse_action_records if filter_condition is None or filter_condition(record) + ] + + def get_adverse_action_records_for_license( + self, + license_jurisdiction: str, + license_type_abbreviation: str, + filter_condition: Callable[[AdverseActionData], bool] | None = None, + ) -> list[AdverseActionData]: + """ + Get all adverse action records for a given license. + """ + return [ + record + for record in self._adverse_action_records + if record.actionAgainst == AdverseActionAgainstEnum.LICENSE + and record.jurisdiction == license_jurisdiction + and record.licenseTypeAbbreviation == license_type_abbreviation + and (filter_condition is None or filter_condition(record)) + ] + + def get_adverse_action_by_id(self, adverse_action_id: UUID) -> AdverseActionData | None: + """ + Get an adverse action record by its ID. + + :param UUID adverse_action_id: The ID of the adverse action to find + :return: The found adverse action record if found, else None + """ + return next( + (record for record in self._adverse_action_records if record.adverseActionId == adverse_action_id), + None, + ) + + def _get_latest_effective_lift_date_for_adverse_actions( + self, adverse_actions: list[AdverseActionData] + ) -> date | None: + if not adverse_actions: + logger.info('No adverse actions found. Returning None') + return None + + # Find the latest effective lift date among all lifted adverse actions + latest_effective_lift_date = None + for adverse_action in adverse_actions: + if adverse_action.effectiveLiftDate is None: + logger.info('found adverse action without effective lift date. Returning None') + return None + if latest_effective_lift_date is None or adverse_action.effectiveLiftDate > latest_effective_lift_date: + latest_effective_lift_date = adverse_action.effectiveLiftDate + + return latest_effective_lift_date + + def get_latest_effective_lift_date_for_license_adverse_actions( + self, license_jurisdiction: str, license_type_abbreviation: str + ) -> date | None: + """ + Get the latest effective lift date for a license if all adverse actions have been lifted. + + If any of the adverse actions have not been lifted, or there are no adverse actions, None is returned. + """ + # Get all adverse action records for this license to determine the correct effective date + # for privilege lifting (should be the maximum effective lift date among all lifted encumbrances) + license_adverse_actions = self.get_adverse_action_records_for_license( + license_jurisdiction=license_jurisdiction, + license_type_abbreviation=license_type_abbreviation, + ) + return self._get_latest_effective_lift_date_for_adverse_actions(license_adverse_actions) + + def get_latest_effective_lift_date_for_privilege_adverse_actions( + self, privilege_jurisdiction: str, license_type_abbreviation: str + ) -> date | None: + """ + Get the latest effective lift date for a privilege if all adverse actions have been lifted. + + If any of the adverse actions have not been lifted, or there are no adverse actions, None is returned. + """ + # Get all adverse action records for this privilege to determine the correct effective date + # for privilege lifting (should be the maximum effective lift date among all lifted encumbrances) + privilege_adverse_actions = self.get_adverse_action_records_for_privilege( + privilege_jurisdiction=privilege_jurisdiction, + privilege_license_type_abbreviation=license_type_abbreviation, + ) + return self._get_latest_effective_lift_date_for_adverse_actions(privilege_adverse_actions) + + def get_adverse_action_records_for_privilege( + self, + privilege_jurisdiction: str, + privilege_license_type_abbreviation: str, + filter_condition: Callable[[AdverseActionData], bool] | None = None, + ) -> list[AdverseActionData]: + """ + Get all adverse action records for a given privilege. + """ + return [ + record + for record in self._adverse_action_records + if record.actionAgainst == AdverseActionAgainstEnum.PRIVILEGE + and record.jurisdiction == privilege_jurisdiction + and record.licenseTypeAbbreviation == privilege_license_type_abbreviation + and (filter_condition is None or filter_condition(record)) + ] + + def get_investigation_records_for_privilege( + self, + privilege_jurisdiction: str, + privilege_license_type_abbreviation: str, + filter_condition: Callable[[InvestigationData], bool] | None = None, + include_closed: bool = False, + ) -> list[InvestigationData]: + """ + Get all investigation records for a given privilege. + + :param privilege_jurisdiction: The jurisdiction of the privilege + :param privilege_license_type_abbreviation: The license type abbreviation + :param filter_condition: Optional filter function to apply to records + :param include_closed: If True, include closed investigations; otherwise only return active ones + :returns: List of investigation records matching the criteria + """ + return [ + record + for record in self._investigation_records + if record.investigationAgainst == 'privilege' + and record.jurisdiction == privilege_jurisdiction + and record.licenseTypeAbbreviation == privilege_license_type_abbreviation + and ( + include_closed or record.closeDate is None + ) # Only return active investigations unless include_closed is True + and (filter_condition is None or filter_condition(record)) + ] + + def get_investigation_records_for_license( + self, + license_jurisdiction: str, + license_type_abbreviation: str, + filter_condition: Callable[[InvestigationData], bool] | None = None, + include_closed: bool = False, + ) -> list[InvestigationData]: + """ + Get all investigation records for a given license. + + :param license_jurisdiction: The jurisdiction of the license + :param license_type_abbreviation: The license type abbreviation + :param filter_condition: Optional filter function to apply to records + :param include_closed: If True, include closed investigations; otherwise only return active ones + :returns: List of investigation records matching the criteria + """ + return [ + record + for record in self._investigation_records + if record.investigationAgainst == 'license' + and record.jurisdiction == license_jurisdiction + and record.licenseTypeAbbreviation == license_type_abbreviation + and ( + include_closed or record.closeDate is None + ) # Only return active investigations unless include_closed is True + and (filter_condition is None or filter_condition(record)) + ] + + def get_provider_record(self) -> ProviderData: + """ + Get the provider record from a list of records associated with a provider. + """ + if len(self._provider_records) > 1: + logger.error('Multiple provider records found', provider_id=self._provider_records[0].providerId) + raise CCInternalException('Multiple top-level provider records found for user.') + if not self._provider_records: + raise CCInternalException('No provider record found for user.') + return self._provider_records[0] + + @staticmethod + def _sort_licenses_by_most_recent(licenses: list[LicenseData]) -> list[LicenseData]: + return sorted( + licenses, + key=_license_sort_key, + reverse=True, + ) + + def find_most_recent_licenses_for_each_license_type(self) -> list[LicenseData]: + """ + For each license type, find the most recent license for the provider. + + :return: A list of LicenseData objects, one for each license type the provider holds. + """ + most_recent_licenses: list[LicenseData] = [] + by_type: dict[str, list] = {} + for lic in self._license_records: + by_type.setdefault(lic.licenseType, []).append(lic) + for _lt, licenses in by_type.items(): + sorted_licenses = self._sort_licenses_by_most_recent(licenses) + most_recent_licenses.append(sorted_licenses[0]) + + return most_recent_licenses + + def find_best_license_in_current_known_licenses( + self, + jurisdiction: str | None = None, + license_type_abbreviation: str | None = None, + ) -> LicenseData: + """ + Find the best license from this provider's known licenses. Uses the same ordering as + ProviderRecordUtility.find_best_license (most recently renewed/issued; status and eligibility not considered). + Sorts LicenseData directly using the shared sort key—no conversion to or from dicts. + :param jurisdiction: Optional jurisdiction filter + :param license_type_abbreviation: Optional license type abbreviation filter (e.g. 'cos', 'est') + :return: The best license record + """ + if jurisdiction: + license_records = self.get_license_records( + filter_condition=lambda license_data: license_data.jurisdiction == jurisdiction + ) + else: + license_records = self.get_license_records() + + if license_type_abbreviation: + license_records = [ + lic for lic in license_records if lic.licenseTypeAbbreviation == license_type_abbreviation + ] + + if not license_records: + raise CCNotFoundException('No licenses found') + + sorted_licenses = self._sort_licenses_by_most_recent(license_records) + return sorted_licenses[0] + + def generate_privileges_for_provider(self, include_inactive_privileges: bool = False) -> list[dict]: + """ + Generate privilege dicts at runtime for each license type this provider holds. + + For each license type, the home license is chosen from all licenses of that type: the license renewed + most recently (when dateOfRenewal is present), otherwise the license with the most recent date of issuance. + When the chosen home license is compact-eligible, one privilege is generated per active compact jurisdiction + (excluding the home jurisdiction). When the home license is not compact-eligible, a privilege is still + generated for a jurisdiction if there is a matching privilege adverse action or an open privilege + investigation for that jurisdiction and license type, so admins can see and resolve those records. + + When include_inactive_privileges is True, privileges in all jurisdictions are generated for ineligible home + licenses and are marked inactive. This is primarily used when indexing to OpenSearch so that adverse + actions and investigations remain searchable even when a license is ineligible. + + :param include_inactive_privileges: When True, generate privileges for ineligible home licenses + and mark them inactive instead of omitting them entirely. + """ + if not self._license_records: + return [] + provider = self.get_provider_record() + compact = provider.compact + # live_compact_jurisdictions is a cached property, so it will only be fetched once per Lambda lifecycle. + live_jurisdictions_for_compact = config.live_compact_jurisdictions.get(compact, []) + + if not live_jurisdictions_for_compact: + logger.debug('no active jurisdictions found in environment.', compact=compact) + return [] + + # Group licenses by licenseType; for each type pick home license by most recent renewal, then issuance + by_type: dict[str, list[LicenseData]] = {} + for lic in self._license_records: + by_type.setdefault(lic.licenseType, []).append(lic) + + most_recent_licenses_for_each_type: list[LicenseData] = [] + for _lt, licenses in by_type.items(): + sorted_licenses = sorted( + licenses, + key=_license_sort_key, + reverse=True, + ) + most_recent_license = sorted_licenses[0] + most_recent_licenses_for_each_type.append(most_recent_license) + + result: list[dict] = [] + for most_recent_license in most_recent_licenses_for_each_type: + is_eligible = most_recent_license.compactEligibility == CompactEligibilityStatus.ELIGIBLE + home_jurisdiction = most_recent_license.jurisdiction.lower() + license_type_abbr = most_recent_license.licenseTypeAbbreviation + + for jurisdiction in live_jurisdictions_for_compact: + if jurisdiction == home_jurisdiction: + continue + privilege_aa = self.get_adverse_action_records_for_privilege(jurisdiction, license_type_abbr) + privilege_unlifted = any(aa.effectiveLiftDate is None for aa in privilege_aa) + inv_records = self.get_investigation_records_for_privilege( + jurisdiction, license_type_abbr, include_closed=False + ) + if not is_eligible and not include_inactive_privileges and not privilege_aa and not inv_records: + logger.debug( + 'Not returning a privilege for this jurisdiction because the home ' + 'license is not compact eligible and there are no matching privilege adverse ' + 'actions or open investigations.', + jurisdiction=jurisdiction, + home_jurisdiction=home_jurisdiction, + license_type_abbr=license_type_abbr, + ) + continue + privilege_dict = { + 'type': 'privilege', + 'administratorSetStatus': ActiveInactiveStatus.ACTIVE.value, + 'providerId': str(provider.providerId), + 'compact': compact, + 'jurisdiction': jurisdiction, + 'licenseJurisdiction': home_jurisdiction, + 'licenseType': most_recent_license.licenseType, + 'dateOfExpiration': most_recent_license.dateOfExpiration, + # the only way a privilege under this model shows inactive is if + # there has been an encumbrance set by a state admin that has not been + # lifted. Ineligible home licenses still get privilege rows when there are matching + # privilege adverse actions or open investigations. + 'status': ActiveInactiveStatus.ACTIVE.value + if is_eligible and not privilege_unlifted + else ActiveInactiveStatus.INACTIVE.value, + 'adverseActions': [aa.to_dict() for aa in privilege_aa], + 'investigations': [inv.to_dict() for inv in inv_records], + } + # We only include open investigations here, so the privilege will only be under investigation if there + # are any investigation records. + if privilege_dict.get('investigations'): + privilege_dict.update({'investigationStatus': InvestigationStatusEnum.UNDER_INVESTIGATION.value}) + + result.append(privilege_dict) + return result + + def get_all_license_update_records( + self, + filter_condition: Callable[[LicenseUpdateData], bool] | None = None, + ) -> list[LicenseUpdateData]: + """ + Get all license update records for this provider. + :param filter_condition: An optional filter to apply to the update records + :return: List of LicenseUpdateData records + """ + return [ + record for record in self._license_update_records if filter_condition is None or filter_condition(record) + ] + + def get_all_provider_update_records( + self, + filter_condition: Callable[[ProviderUpdateData], bool] | None = None, + ) -> list[ProviderUpdateData]: + """ + Get all provider update records for this provider. + :param filter_condition: An optional filter to apply to the update records + :return: List of ProviderUpdateData records + """ + return [ + record for record in self._provider_update_records if filter_condition is None or filter_condition(record) + ] + + def get_update_records_for_license( + self, + jurisdiction: str, + license_type: str, + filter_condition: Callable[[LicenseUpdateData], bool] | None = None, + ) -> list[LicenseUpdateData]: + """ + Get all license update records for a specific license. + :param jurisdiction: The jurisdiction of the license. + :param license_type: The license type. + :param filter_condition: An optional filter to apply to the update records + :return: List of LicenseUpdateData records + """ + return [ + record + for record in self._license_update_records + if record.jurisdiction == jurisdiction + and record.licenseType == license_type + and (filter_condition is None or filter_condition(record)) + ] + + def generate_api_response_object(self, is_public_response: bool = False) -> dict: + """ + Assemble a list of provider records into a single object used by the provider details api. + + :param is_public_response: If True, licenses that are not the most recent license for a type + will not be included in the response. + :return: A single provider record matching our provider details api schema. + """ + provider = self.get_provider_record().to_dict() + licenses = [] + privileges = [] + + if is_public_response: + # only include the most recent license for each license type in the public response + license_records = self.find_most_recent_licenses_for_each_license_type() + else: + license_records = self.get_license_records() + + # Build licenses dict with investigations and adverseActions + for license_record in license_records: + license_dict = license_record.to_dict() + license_dict['adverseActions'] = [ + rec.to_dict() + for rec in self.get_adverse_action_records_for_license( + license_record.jurisdiction, license_record.licenseTypeAbbreviation + ) + ] + license_dict['investigations'] = [ + rec.to_dict() + for rec in self.get_investigation_records_for_license( + license_record.jurisdiction, license_record.licenseTypeAbbreviation + ) + ] + licenses.append(license_dict) + + # Build privileges at runtime from eligible licenses (one privilege per license type per compact jurisdiction) + privileges = self.generate_privileges_for_provider() + + provider['licenses'] = licenses + provider['privileges'] = privileges + provider['adverseActions'] = [rec.to_dict() for rec in self.get_adverse_action_records()] + + return provider + + def generate_opensearch_documents(self) -> list[dict]: + """ + Generate one OpenSearch document per license for this provider. + + Each document contains the full provider-level fields (including top-level `adverseActions` + for the provider), a single license in the `licenses` array, and privileges only if that license + is the home license for its type. This enables 1:1 mapping between OpenSearch documents and license + records for native pagination. + + Privileges are always included for home license documents — including when the license is + ineligible — so that adverse actions and investigations remain linked to privilege records. + Privileges for ineligible home licenses carry status 'inactive'. + + :return: A list of dicts, each representing a single-license OpenSearch document. + Empty list if the provider has no licenses. + """ + if not self._license_records: + return [] + + provider_dict = self.get_provider_record().to_dict() + all_privileges = self.generate_privileges_for_provider(include_inactive_privileges=True) + + # Determine the most recent (aka home) license for each license type + most_recent_licenses = { + (most_recent_license_for_type.jurisdiction.lower(), most_recent_license_for_type.licenseType) + for most_recent_license_for_type in self.find_most_recent_licenses_for_each_license_type() + } + + documents = [] + adverse_actions = [rec.to_dict() for rec in self.get_adverse_action_records()] + for license_record in self.get_license_records(): + license_dict = license_record.to_dict() + license_dict['adverseActions'] = [ + rec.to_dict() + for rec in self.get_adverse_action_records_for_license( + license_record.jurisdiction, license_record.licenseTypeAbbreviation + ) + ] + license_dict['investigations'] = [ + rec.to_dict() + for rec in self.get_investigation_records_for_license( + license_record.jurisdiction, license_record.licenseTypeAbbreviation + ) + ] + + is_most_recent_license_for_type = ( + license_record.jurisdiction.lower(), + license_record.licenseType, + ) in most_recent_licenses + license_privileges = ( + [p for p in all_privileges if p['licenseType'] == license_record.licenseType] + if is_most_recent_license_for_type + else [] + ) + license_dict['mostRecentLicenseForType'] = is_most_recent_license_for_type + + doc = dict(provider_dict) + doc['licenses'] = [license_dict] + doc['privileges'] = license_privileges + doc['adverseActions'] = adverse_actions + documents.append(doc) + + return documents diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/query_paginator.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/query_paginator.py new file mode 100644 index 0000000000..64b566febf --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/query_paginator.py @@ -0,0 +1,167 @@ +import json +from base64 import b64decode, b64encode +from collections.abc import Callable +from types import MethodType + +from botocore.exceptions import ClientError + +from cc_common.config import config, logger +from cc_common.exceptions import CCInvalidRequestException +from cc_common.utils import load_records_into_schemas + + +# It's conventional to name a decorator in snake_case, even if it is implemented as a class +class paginated_query: # noqa: N801 invalid-name + """Decorator to handle converting API interface pagination to DynamoDB pagination. + + This will process incoming pagination fields for passing to DynamoDB, then take the raw DynamoDB response and + transform it into a dict that includes an encoded lastKey field. + + { + 'items': response['Items'], + 'pagination': { + 'pageSize': , + 'prevLastKey': , + 'lastKey': + } + } + + IMPORTANT: When a FilterExpression is used over a large partition space, DynamoDB can return fewer items than is + specified as the pageSize. To ensure that we always return the full pageSize, this decorator will repeat queries as + needed until it has a full page of items to return. In order to reduce the number of queries made when using filter + expressions, you should set the set_query_limit_to_match_page_size flag to False. This will not set the Limit + parameter on the query, so DynamoDB will evaluate as many items as it can within a single query, returning all + evaluated items that match the filter expression. The decorator will handle truncating the items to fit within the + pageSize, and will also handle calculating the lastKey for the next page of results. + """ + + def __init__(self, set_query_limit_to_match_page_size: bool = True): + super().__init__() + self.set_query_limit_to_match_page_size = set_query_limit_to_match_page_size + + def __call__(self, fn: Callable): + return _PaginatedQueryDecorator(fn, self.set_query_limit_to_match_page_size) + + +class _PaginatedQueryDecorator: + """Internal decorator class that handles the actual pagination logic.""" + + def __init__(self, fn: Callable, set_query_limit_to_match_page_size: bool): + self.fn = fn + self.set_query_limit_to_match_page_size = set_query_limit_to_match_page_size + + def __get__(self, instance, owner): + return MethodType(self, instance) + + def __call__(self, *args, pagination: dict = None, client_filter: Callable[[dict], bool] = None, **kwargs): + if pagination is None: + pagination = {} + # We b64 encode/decode the lastKey just for convenience passing to/from the client over HTTP + last_key = pagination.get('lastKey') + if last_key is not None: + try: + last_key = json.loads(b64decode(last_key).decode('utf-8')) + except Exception as e: + raise CCInvalidRequestException(message='Invalid lastKey') from e + page_size = pagination.get('pageSize', config.default_page_size) + + items = [] + raw_resp = {} + last_known_evaluated_key = None + for raw_resp in self._generate_pages( + last_key=last_key, + page_size=page_size, + client_filter=client_filter, + args=args, + kwargs=kwargs, + ): + items.extend(raw_resp.get('Items', [])) + # track the last key for the page, in the event more items are returned on the last page than the page size + last_known_evaluated_key = raw_resp.get('LastEvaluatedKey', last_known_evaluated_key) + + # items can be longer than page_size, so we trim it to the page size: + if len(items) > page_size: + items = items[:page_size] + last_item = items[-1] + # Since we truncated our items, we need to recalculate the last key + # We will use the last known evaluated key to determine the needed fields + # for the last key if we have one + if last_known_evaluated_key is not None: + last_key = {k: last_item[k] for k in last_known_evaluated_key.keys()} + # Else the first query was the last query, but there were more items to be returned than the specified page + # size. In this case, we need to determine the last key for getting the next page. The keys needed by the + # last key are not static (for example, if you are querying by a GSI you must include the GSI fields as + # part of the last key) so to have a generic solution for this scenario, we make a query with a limit of 1 + # so we can get the LastEvaluatedKey from the response, and then map the keys from that to the values of + # the last item that will be returned in the response to create our own last key. + else: + last_key_resp = self._caught_query(client_filter, *args, dynamo_pagination={'Limit': 1}, **kwargs) + last_known_evaluated_key = last_key_resp.get('LastEvaluatedKey') + last_key = {k: last_item[k] for k in last_known_evaluated_key.keys()} + # else if the page size matched the query, and there are more records to return, set the last key to match + elif raw_resp.get('LastEvaluatedKey'): + last_key = raw_resp.get('LastEvaluatedKey') + # else there are not more records to fetch, set last key to none + else: + last_key = None + + resp = { + # Deserializing everything that comes out of the database + 'items': load_records_into_schemas(items), + 'pagination': {'pageSize': page_size, 'prevLastKey': pagination.get('lastKey')}, + } + + # Last key, if present, will be a dict like {'pk': 'some-pk', 'sk': 'socw/PROVIDER'} + if last_key is not None: + last_key = b64encode(json.dumps(last_key).encode('utf-8')).decode('utf-8') + resp['pagination']['lastKey'] = last_key + return resp + + def _generate_pages( + self, + *, + last_key: str | None, + page_size: int, + client_filter: Callable[[dict], bool] | None, + args, + kwargs, + ): + """Repeat the wrapped query until we get everything or the full page_size of items""" + dynamo_pagination = {**({'ExclusiveStartKey': last_key} if last_key is not None else {})} + if self.set_query_limit_to_match_page_size: + dynamo_pagination['Limit'] = page_size + + raw_resp = self._caught_query(client_filter, *args, dynamo_pagination=dynamo_pagination, **kwargs) + count = raw_resp['Count'] + last_key = raw_resp.get('LastEvaluatedKey') + + yield raw_resp + while last_key is not None and count < page_size: + dynamo_pagination = {**({'ExclusiveStartKey': last_key} if last_key is not None else {})} + if self.set_query_limit_to_match_page_size: + dynamo_pagination['Limit'] = page_size + + raw_resp = self._caught_query(client_filter, *args, dynamo_pagination=dynamo_pagination, **kwargs) + count += raw_resp['Count'] + last_key = raw_resp.get('LastEvaluatedKey') + yield raw_resp + + def _caught_query(self, client_filter: Callable[[dict], bool] | None, *args, **kwargs): + """Uniformly convert our DynamoDB query validation errors to invalid request exceptions""" + try: + raw_resp = self.fn(*args, **kwargs) + except ClientError as e: + # If the client sends in an invalid lastKey that is good enough to get sent to DynamoDB, + # DynamoDB will return us a ValidationException, so we'll handle that here + if e.response['Error']['Code'] == 'ValidationException': + logger.warning('Invalid request caused a ValidationException', response=e.response, exc_info=e) + raise CCInvalidRequestException('Invalid request') from e + raise + + # Apply client filter if provided + if client_filter is not None: + raw_resp['Items'] = [item for item in raw_resp['Items'] if client_filter(item)] + count = len(raw_resp['Items']) + raw_resp['Count'] = count + + return raw_resp diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/__init__.py new file mode 100644 index 0000000000..5764f6eeb0 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/__init__.py @@ -0,0 +1,9 @@ +# ruff: noqa: F401 +# We import all the record types with the package to ensure they are all registered +# Privilege records are no longer stored; privileges are generated at API runtime. +from .adverse_action.record import AdverseActionRecordSchema +from .compact.record import CompactRecordSchema +from .jurisdiction.record import JurisdictionRecordSchema +from .license.record import LicenseRecordSchema +from .provider.record import ProviderRecordSchema +from .user.record import UserRecordSchema diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/__init__.py new file mode 100644 index 0000000000..165c186050 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/__init__.py @@ -0,0 +1,135 @@ +# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition +from datetime import date, datetime +from uuid import UUID + +from cc_common.data_model.schema.adverse_action.record import AdverseActionRecordSchema +from cc_common.data_model.schema.common import ( + AdverseActionAgainstEnum, + CCDataClass, + EncumbranceType, +) + + +class AdverseActionData(CCDataClass): + """ + Class representing an Adverse Action with getters and setters for all properties. + Takes a dict as an argument to the constructor to avoid primitive obsession. + """ + + # Define record schema at the class level + _record_schema = AdverseActionRecordSchema() + + # Can use setters to set field data + _requires_data_at_construction = False + + @property + def compact(self) -> str: + return self._data['compact'] + + @compact.setter + def compact(self, value: str) -> None: + self._data['compact'] = value + + @property + def providerId(self) -> UUID: + return self._data['providerId'] + + @providerId.setter + def providerId(self, value: UUID) -> None: + self._data['providerId'] = value + + @property + def jurisdiction(self) -> str: + return self._data['jurisdiction'] + + @jurisdiction.setter + def jurisdiction(self, value: str) -> None: + self._data['jurisdiction'] = value + + @property + def licenseTypeAbbreviation(self) -> str: + return self._data['licenseTypeAbbreviation'] + + @licenseTypeAbbreviation.setter + def licenseTypeAbbreviation(self, value: str) -> None: + self._data['licenseTypeAbbreviation'] = value + + @property + def licenseType(self) -> str: + return self._data['licenseType'] + + @licenseType.setter + def licenseType(self, value: str) -> None: + self._data['licenseType'] = value + + @property + def actionAgainst(self) -> str: + return self._data['actionAgainst'] + + @actionAgainst.setter + def actionAgainst(self, action_against_enum: AdverseActionAgainstEnum) -> None: + self._data['actionAgainst'] = action_against_enum.value + + @property + def encumbranceType(self) -> str: + return self._data['encumbranceType'] + + @encumbranceType.setter + def encumbranceType(self, encumbrance_type_enum: EncumbranceType) -> None: + self._data['encumbranceType'] = encumbrance_type_enum.value + + @property + def clinicalPrivilegeActionCategories(self) -> list[str] | None: + return self._data.get('clinicalPrivilegeActionCategories') + + @clinicalPrivilegeActionCategories.setter + def clinicalPrivilegeActionCategories(self, value: list[str]) -> None: + self._data['clinicalPrivilegeActionCategories'] = value + + @property + def effectiveStartDate(self) -> date: + return self._data['effectiveStartDate'] + + @effectiveStartDate.setter + def effectiveStartDate(self, value: date) -> None: + self._data['effectiveStartDate'] = value + + @property + def submittingUser(self) -> UUID: + return self._data['submittingUser'] + + @submittingUser.setter + def submittingUser(self, value: UUID) -> None: + self._data['submittingUser'] = value + + @property + def creationDate(self) -> datetime: + return self._data['creationDate'] + + @creationDate.setter + def creationDate(self, value: datetime) -> None: + self._data['creationDate'] = value + + @property + def adverseActionId(self) -> UUID: + return self._data['adverseActionId'] + + @adverseActionId.setter + def adverseActionId(self, value: UUID) -> None: + self._data['adverseActionId'] = value + + @property + def effectiveLiftDate(self) -> date | None: + return self._data.get('effectiveLiftDate') + + @effectiveLiftDate.setter + def effectiveLiftDate(self, value: date) -> None: + self._data['effectiveLiftDate'] = value + + @property + def liftingUser(self) -> UUID | None: + return self._data.get('liftingUser') + + @liftingUser.setter + def liftingUser(self, value: UUID) -> None: + self._data['liftingUser'] = value diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/api.py new file mode 100644 index 0000000000..c6fbc43e18 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/api.py @@ -0,0 +1,86 @@ +# ruff: noqa: N801, N815 invalid-name +from marshmallow.fields import Date, List, Raw, String +from marshmallow.validate import Length, OneOf + +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.common import AdverseActionAgainstEnum +from cc_common.data_model.schema.fields import ( + ClinicalPrivilegeActionCategoryField, + Compact, + EncumbranceTypeField, + Jurisdiction, +) + + +class AdverseActionPostRequestSchema(ForgivingSchema): + """ + Schema for adverse action POST requests. + + This schema is used to validate incoming requests to the adverse action POST API endpoint. + + Serialization direction: + API -> load() -> Python + """ + + encumbranceEffectiveDate = Date(required=True, allow_none=False) + encumbranceType = EncumbranceTypeField(required=True, allow_none=False) + # in the case of Social Work, we only allow one category, but we are keeping this as a list for compatibility + # with the existing code base, and to allow the potential of supporting multiple categories should this be needed + # in the future + clinicalPrivilegeActionCategories = List( + ClinicalPrivilegeActionCategoryField(), required=True, allow_none=False, validate=Length(equal=1) + ) + + +class AdverseActionPatchRequestSchema(ForgivingSchema): + """ + Schema for adverse action PATCH requests (encumbrance lifting). + + This schema is used to validate incoming requests to the adverse action PATCH API endpoint + for lifting encumbrances. + + Serialization direction: + API -> load() -> Python + """ + + effectiveLiftDate = Date(required=True, allow_none=False) + + +class AdverseActionPublicResponseSchema(ForgivingSchema): + """ + Schema for adverse action public responses. + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False, validate=OneOf(['adverseAction'])) + compact = Compact(required=True, allow_none=False) + providerId = Raw(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseTypeAbbreviation = String(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + actionAgainst = String(required=True, allow_none=False, validate=OneOf([e for e in AdverseActionAgainstEnum])) + + # Populated on creation + effectiveStartDate = Raw(required=True, allow_none=False) + creationDate = Raw(required=True, allow_none=False) + adverseActionId = Raw(required=True, allow_none=False) + + # Populated when the action is lifted + effectiveLiftDate = Raw(required=False, allow_none=False) + dateOfUpdate = Raw(required=True, allow_none=False) + + +class AdverseActionGeneralResponseSchema(AdverseActionPublicResponseSchema): + """ + Schema for adverse action general responses. + + Serialization direction: + Python -> load() -> API + """ + + encumbranceType = EncumbranceTypeField(required=True, allow_none=False) + clinicalPrivilegeActionCategories = List(ClinicalPrivilegeActionCategoryField(), required=False, allow_none=False) + liftingUser = Raw(required=False, allow_none=False) + submittingUser = Raw(required=True, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/record.py new file mode 100644 index 0000000000..7c9f878328 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/adverse_action/record.py @@ -0,0 +1,78 @@ +# ruff: noqa: N801, N815 invalid-name +from marshmallow import ValidationError, pre_dump, validates_schema +from marshmallow.fields import UUID, AwareDateTime, Date, List, String +from marshmallow.validate import OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.common import AdverseActionAgainstEnum +from cc_common.data_model.schema.fields import ( + ClinicalPrivilegeActionCategoryField, + Compact, + EncumbranceTypeField, + Jurisdiction, +) + + +@BaseRecordSchema.register_schema('adverseAction') +class AdverseActionRecordSchema(BaseRecordSchema): + """ + Schema for adverse action records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'adverseAction' + + compact = Compact(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseTypeAbbreviation = String(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + actionAgainst = String(required=True, allow_none=False, validate=OneOf([e.value for e in AdverseActionAgainstEnum])) + + # Populated on creation + encumbranceType = EncumbranceTypeField(required=True, allow_none=False) + clinicalPrivilegeActionCategories = List(ClinicalPrivilegeActionCategoryField(), required=True, allow_none=False) + effectiveStartDate = Date(required=True, allow_none=False) + submittingUser = UUID(required=True, allow_none=False) + creationDate = AwareDateTime(required=True, allow_none=False) + adverseActionId = UUID(required=True, allow_none=False) + + # Populated when the action is lifted + effectiveLiftDate = Date(required=False, allow_none=False) + liftingUser = UUID(required=False, allow_none=False) + + @pre_dump + def pre_dump_serialization(self, in_data, **_kwargs): + """Pre-dump serialization to ensure the clinicalPrivilegeActionCategories list is serialized correctly.""" + return self.generate_pk_sk(in_data) + + def generate_pk_sk(self, in_data, **_kwargs): + in_data['pk'] = f'{in_data["compact"]}#PROVIDER#{in_data["providerId"]}' + # ensure this is passed in lowercase + license_type_abbr = in_data['licenseTypeAbbreviation'].lower() + in_data['sk'] = ( + f'{in_data["compact"]}#PROVIDER#{in_data["actionAgainst"]}/{in_data["jurisdiction"]}/{license_type_abbr}#ADVERSE_ACTION#{in_data["adverseActionId"]}' + ) + return in_data + + @validates_schema + def validate_license_type(self, data, **_kwargs): # noqa: ARG001 unused-argument + compact = data['compact'] + license_types = config.license_types_for_compact(compact) + if data.get('licenseType') not in license_types: + raise ValidationError({'licenseType': [f'Must be one of: {", ".join(license_types)}.']}) + # We have verified the license type name is valid, now verify the abbreviation matches + license_abbreviations = config.license_type_abbreviations_for_compact(compact) + if license_abbreviations.get(data['licenseType']) != data.get('licenseTypeAbbreviation'): + raise ValidationError( + { + 'licenseTypeAbbreviation': [ + f'License type abbreviation must match license type: ' + f'licenseType={license_abbreviations[data["licenseType"]]} ' + f'matching licenseTypeAbbreviation={license_abbreviations[data["licenseType"]]}.' + ] + } + ) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/base_record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/base_record.py new file mode 100644 index 0000000000..e7636ff0ba --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/base_record.py @@ -0,0 +1,126 @@ +# ruff: noqa: N801, N815 invalid-name +# We diverge from PEP8 variable naming in schema because they map to our API JSON Schema in which, +# by convention, we use camelCase. +from abc import ABC + +from marshmallow import EXCLUDE, RAISE, Schema, post_load, pre_dump +from marshmallow.fields import UUID, AwareDateTime, String + +from cc_common.config import config +from cc_common.data_model.schema.fields import Compact, SocialSecurityNumber +from cc_common.exceptions import CCInternalException + + +class StrictSchema(Schema): + """Base Schema explicitly stating what we do if unknown fields are included - raise an error""" + + class Meta: + unknown = RAISE + + +class ForgivingSchema(Schema): + """Base schema that will silently remove any unknown fields that are included""" + + class Meta: + unknown = EXCLUDE + + +class BaseRecordSchema(ForgivingSchema, ABC): + """ + Abstract base class, common to all records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = None + _registered_schema = {} + + # Generated fields + pk = String(required=True, allow_none=False) + sk = String(required=True, allow_none=False) + dateOfUpdate = AwareDateTime(required=True, allow_none=False) + + # Provided fields + type = String(required=True, allow_none=False) + + @post_load + def drop_base_gen_fields(self, in_data, **_kwargs): + """Drop the db-specific pk and sk fields before returning loaded data""" + del in_data['pk'] + del in_data['sk'] + return in_data + + @pre_dump + def populate_type(self, in_data, **_kwargs): + """Populate db-specific fields before dumping to the database""" + in_data['type'] = self._record_type + return in_data + + @pre_dump + def populate_date_of_update(self, in_data, **_kwargs): + """Populate db-specific fields before dumping to the database""" + # set the dateOfUpdate field to the current UTC time + in_data['dateOfUpdate'] = config.current_standard_datetime + return in_data + + @classmethod + def register_schema(cls, record_type: str): + """Add the record type to the class map of schema, so we can look one up by type""" + + def do_register(schema_cls: type[Schema]) -> type[Schema]: + cls._registered_schema[record_type] = schema_cls() + return schema_cls + + return do_register + + @classmethod + def get_schema_by_type(cls, record_type: str) -> Schema: + try: + return cls._registered_schema[record_type] + except KeyError as e: + raise CCInternalException(f'Unsupported record type, "{record_type}"') from e + + +class SSNIndexRecordSchema(StrictSchema): + """ + Schema for records that translate between SSN and provider_id + + Serialization direction: + DB -> load() -> Python + """ + + compact = Compact(required=True, allow_none=False) + ssn = SocialSecurityNumber(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + + # Generated fields + pk = String(required=True, allow_none=False) + sk = String(required=True, allow_none=False) + providerIdGSIpk = String(required=False, allow_none=False) + + @pre_dump + def populate_pk_sk(self, in_data, **_kwargs): + """Populate the pk and sk fields before dumping to the database""" + in_data['pk'] = f'{in_data["compact"]}#SSN#{in_data["ssn"]}' + in_data['sk'] = f'{in_data["compact"]}#SSN#{in_data["ssn"]}' + return in_data + + @post_load + def drop_pk_sk(self, in_data, **_kwargs): + """Drop the pk and sk fields after loading from the database""" + in_data.pop('pk', None) + in_data.pop('sk', None) + return in_data + + @pre_dump + def populate_provider_id_gsi_pk(self, in_data, **_kwargs): + """Populate the providerId GSI pk field before dumping to the database""" + in_data['providerIdGSIpk'] = f'{in_data["compact"]}#PROVIDER#{in_data["providerId"]}' + return in_data + + @post_load + def drop_provider_id_gsi_pk(self, in_data, **_kwargs): + """Drop the providerId GSI pk field after loading from the database""" + in_data.pop('providerIdGSIpk', None) + return in_data diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/common.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/common.py new file mode 100644 index 0000000000..8baf20d0a4 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/common.py @@ -0,0 +1,428 @@ +# ruff: noqa: N802, N815 invalid-name +import json +from copy import deepcopy +from datetime import UTC, datetime +from enum import StrEnum +from hashlib import md5 +from typing import Any + +from marshmallow import Schema, ValidationError, pre_load, validates_schema +from marshmallow.fields import Dict, String, Url + +from cc_common.config import config + + +class CCRequestSchema(Schema): + """ + Base class for Compact Connect request schemas. + + This schema provides common request processing functionality such as + whitespace trimming and other input sanitization that should be applied + to all incoming API requests. + + All request schemas should inherit from this class instead of directly + from marshmallow.Schema to ensure consistent input processing. + """ + + @pre_load + def strip_whitespace(self, in_data, **kwargs): # noqa: ARG002 unused-argument + """ + Pre-load hook that strips whitespace from all top-level string fields in the request data. + + This method processes only the top-level fields of the input data and strips leading and trailing + whitespace from values that are strings. Nested structures will be processed if they inherit from this class. + This allows us to determine where we want to strip whitespace from the request data for each schema. + + :param in_data: Input data dictionary from the request + :param kwargs: Additional keyword arguments from marshmallow + :return: Processed data with whitespace stripped from top-level string values + """ + if not isinstance(in_data, dict): + return in_data + + return {key: value.strip() if isinstance(value, str) else value for key, value in in_data.items()} + + +class CCDataClass: + """ + Base class for Compact Connect data classes + + These data classes provide an abstraction layer between the data model and the database schema. + They also provide a simple interface to validate data and get specific properties. They also have utility methods + to serialize and deserialize database records. + + Whenever possible, data classes should be used to interact with the data model from lambda functions, rather than + referencing the schemas directly. + + Data classes must be instantiated using one of the class factory methods: + 1. create_new(): For creating a new record that doesn't exist in the database yet + 2. from_database_record(): For loading an existing record from the database + + When putting records into the database, call the serialize_to_database_record method to convert the data class to a + dictionary using the record schema's dump method. + + Subclasses must define a class-level _record_schema attribute specifying the schema to use. + + Subclasses can also set _requires_data_at_construction = True to prevent empty initialization. + """ + + # Subclasses must override this with their specific schema + _record_schema = None + + # Subclasses can set this to True to prevent empty initialization + _requires_data_at_construction = False + + def __init__(self, data: dict[str, Any], _is_from_factory: bool = False): + """ + Initialize a data class instance. + + This constructor should not be called directly. Use the create_new() or + from_database_record() class methods instead. + + :param data: Data to initialize the instance with + :param _is_from_factory: Internal flag to ensure factory methods are used + """ + if not _is_from_factory: + raise ValueError( + 'Direct construction not allowed. Use create_new() or from_database_record() class methods instead.' + ) + + if self.__class__._record_schema is None: # noqa: SLF001 This access allows the base class to manage this logic + raise NotImplementedError(f'Class {self.__class__.__name__} must define a _record_schema class attribute.') + + self._data = data + + @classmethod + def create_new(cls, data: dict[str, Any] = None) -> 'CCDataClass': + """ + Create a new instance using the provided data. + + This method should be used for creating objects that don't yet exist in the database. + The data will be processed through a full serialization/deserialization cycle to populate + any required fields and validate the data. + + :param data: Data to initialize with (without 'pk'/'sk' keys) + :return: New instance of the data class + """ + if cls._requires_data_at_construction and not data: + raise ValueError(f'{cls.__name__} requires valid data and cannot be instantiated empty.') + + if data is None: + return cls({}, _is_from_factory=True) + + if 'pk' in data or 'sk' in data: + raise ValueError( + "Data contains database keys ('pk'/'sk'). Use from_database_record() for loading database records." + ) + + # Serialize and deserialize to populate GSIs and validate the data + serialized_object = cls._record_schema.dump(data) + loaded_data = cls._record_schema.load(serialized_object) + return cls(loaded_data, _is_from_factory=True) + + @classmethod + def from_database_record(cls, data: dict[str, Any]) -> 'CCDataClass': + """ + Create a new instance from a database record. + + This method should be used for loading objects that already exist in the database. + The data will be loaded directly through the schema without generating new GSIs. + + :param data: Database record data (containing 'pk'/'sk' keys) + :return: New instance of the data class + """ + if not data: + raise ValueError('Database record cannot be None or empty') + + # Load directly through the schema + loaded_data = cls._record_schema.load(data) + return cls(loaded_data, _is_from_factory=True) + + @property + def type(self) -> str: + """ + The type of the record, which is the record type of the schema. + """ + return self._data['type'] + + @property + def dateOfUpdate(self) -> datetime: + """ + The date of the latest update for the record. + """ + return self._data['dateOfUpdate'] + + @property + def licenseTypeAbbreviation(self) -> str | None: + """ + Computed property that returns the license type abbreviation if the instance + has both 'compact' and 'licenseType' fields, otherwise returns None. + """ + if 'compact' in self._data and 'licenseType' in self._data: + license_type_abbr = config.license_type_abbreviations.get(self._data['compact'], {}).get( + self._data['licenseType'] + ) + return license_type_abbr.lower() if license_type_abbr else None + + return None + + def to_dict(self) -> dict[str, Any]: + """Return the internal data dictionary + + The main purpose of this method is for ejecting the data into a form that is easy to make assertions on in + our testing, but may be used in other areas of the code which expect dictionary arguments for whatever reason. + + Note we return a deepcopy, to avoid mutations to nested objects causing the original data object to be modified. + + DO NOT use this method for generating database records. When you want to serialize the data for storage in the + DB, call the serialize_to_database_record method. + """ + return deepcopy(self._data) + + def update(self, data: dict[str, Any]) -> None: + """Update the internal data dictionary with the provided data. + + This method is useful for updating specific fields in the data class. + The method creates a deep copy of the current data, applies the updates, + and then runs the updated data through a full dump/load cycle with the schema + to ensure all transformations are applied and the data is validated. + + :param data: Dictionary containing the fields to update + :raises ValidationError: If the resulting data fails validation + """ + # Create a deep copy of the current data + updated_data = deepcopy(self._data) + + # Apply the updates to the copy + updated_data.update(data) + + # Run through a full dump/load cycle to apply all transformations and validate + validated_data = self.create_new(updated_data).to_dict() + + # Update the internal data with the validated result + self._data = validated_data + + def serialize_to_database_record(self) -> dict[str, Any]: + """Serialize the object using the schema's dump method""" + # we set a deepcopy here so that the GSIs and DB keys do not get added to the underlying data dictionary + return self.__class__._record_schema.dump(deepcopy(self._data)) # noqa: SLF001 this allows the base class to manage serialization logic + + +class CCEnum(StrEnum): + """ + Base class for Compact Connect enums + + We are using this class to ensure that all enums have a from_str method for consistency. + This pattern gives us flexibility to add additional mapping logic in the future if needed. + """ + + @classmethod + def from_str(cls, label: str) -> 'CCEnum': + return cls[label] + + +class CCPermissionsAction(StrEnum): + """ + Enum for Compact Connect permissions actions + """ + + READ = 'read' + WRITE = 'write' + ADMIN = 'admin' + READ_GENERAL = 'readGeneral' + READ_PRIVATE = 'readPrivate' + + +class S3PresignedPostSchema(Schema): + """ + Schema for S3 pre-signed post data + """ + + url = Url(schemes=['https'], required=True, allow_none=False) + fields = Dict(keys=String(), values=String(), required=True, allow_none=False) + + +def ensure_value_is_datetime(value: str): + """ + Checks a string value is always returned as a datetime string, even if it is a date string. + + Historically, many of our records were using Date fields to track when records in the system were created. + This was not sufficient for handling the many different timezone requirements of different states. We have + since moved to using DateTime fields most of those date fields, except in the case of licenses uploaded by states, + since states do not specify a time of day for when the license was issued. + + This function is used to ensure that all date fields are converted to datetime fields when they are loaded from the + database. If an old record is using a date field, it will be converted to a datetime field with the time set to the + end of the day in UTC time. This is done to ensure that all records are treated consistently by the system. + + :param value: The value to check, should be either a valid date or datetime string + :return: A datetime string + :raises: ValueError if the value is not a valid date or datetime string + """ + # Confirm that the value is either a valid date or datetime string + # this will raise a ValueError if the string is not a valid datetime + dt = datetime.fromisoformat(value) + # check if string is the same length as date format 'YYYY-MM-DD' + if len(value) == 10: + # convert it to a datetime + # we set it to the end of the day UTC time for overlap with U.S. timezones + value_dt = datetime.combine(dt, datetime.max.time(), tzinfo=UTC).replace(microsecond=0) + # return the datetime as a string + return value_dt.isoformat() + + # Not a date string, return the original + return value + + +class AdverseActionAgainstEnum(StrEnum): + """ + Enum for possible records that adverse actions can be made against + """ + + PRIVILEGE = 'privilege' + LICENSE = 'license' + + +class InvestigationAgainstEnum(StrEnum): + """ + Enum for possible records that investigations can be made against + """ + + PRIVILEGE = 'privilege' + LICENSE = 'license' + + +class UpdateCategory(CCEnum): + DEACTIVATION = 'deactivation' + EXPIRATION = 'expiration' + ISSUANCE = 'issuance' + RENEWAL = 'renewal' + ENCUMBRANCE = 'encumbrance' + INVESTIGATION = 'investigation' + CLOSING_INVESTIGATION = 'closingInvestigation' + LIFTING_ENCUMBRANCE = 'lifting_encumbrance' + # this is specific to privileges that are deactivated due to a state license deactivation + LICENSE_DEACTIVATION = 'licenseDeactivation' + # NOTE: this value should explicitly be used for license upload updates, not anywhere else + # it is referenced in the event that an invalid license upload needs to be reverted. + LICENSE_UPLOAD_UPDATE_OTHER = 'other' + + +# License upload related update categories +LICENSE_UPLOAD_UPDATE_CATEGORIES = { + UpdateCategory.DEACTIVATION, + UpdateCategory.RENEWAL, + UpdateCategory.LICENSE_UPLOAD_UPDATE_OTHER, +} + + +class ActiveInactiveStatus(CCEnum): + ACTIVE = 'active' + INACTIVE = 'inactive' + + +class CompactEligibilityStatus(CCEnum): + ELIGIBLE = 'eligible' + INELIGIBLE = 'ineligible' + + +class LicenseEncumberedStatusEnum(CCEnum): + ENCUMBERED = 'encumbered' + UNENCUMBERED = 'unencumbered' + + +class PrivilegeEncumberedStatusEnum(CCEnum): + ENCUMBERED = 'encumbered' + UNENCUMBERED = 'unencumbered' + # the following status is set whenever the license this privilege is associated with is encumbered + LICENSE_ENCUMBERED = 'licenseEncumbered' + + +class InvestigationStatusEnum(CCEnum): + UNDER_INVESTIGATION = 'underInvestigation' + NOT_UNDER_INVESTIGATION = 'notUnderInvestigation' + + +class HomeJurisdictionChangeStatusEnum(CCEnum): + """ + This is only used if the provider has existing privileges when they change their home jurisdiction, + and that change results in the privilege becoming inactive. + + This field will never be present for an 'active' privilege, hence the only allowed value for this + field is 'inactive'. + """ + + INACTIVE = 'inactive' + + +class LicenseDeactivatedStatusEnum(CCEnum): + """ + This is only used if the provider's privilege is deactivated due to their home state license + being deactivated by the jurisdiction. + + This field will never be present for an 'active' privilege, hence the only allowed value for this + field is 'LICENSE_DEACTIVATED'. + """ + + LICENSE_DEACTIVATED = 'licenseDeactivated' + + +class StaffUserStatus(CCEnum): + ACTIVE = 'active' + INACTIVE = 'inactive' + + +class EncumbranceType(CCEnum): + """ + Enum for the allowed types of encumbrances + """ + + SUSPENSION = 'suspension' + REVOCATION = 'revocation' + SURRENDER_OF_LICENSE = 'surrender of license' + + +class ClinicalPrivilegeActionCategory(CCEnum): + """Enum for adverse action clinical privilege action categories.""" + + FRAUD = 'fraud' + CONSUMER_HARM = 'consumer harm' + OTHER = 'other' + + +class ChangeHashMixin: + """ + Provides change hash methods for *UpdateRecordSchema + """ + + @classmethod + def hash_changes(cls, in_data) -> str: + """ + Generate a hash of the previous record, updated values, and removed values (if present), + to produce a deterministic sort key segment that will be unique among updates to this + particular license. + """ + # We don't need a cryptographically secure hash, just one that is reasonably cheap and reasonably unique + # Within the scope of a single provider for a single second. + change_hash = md5() # noqa: S324 + + # Build a dictionary of all values that contribute to the hash + hash_data = { + 'previous': in_data['previous'], + 'updatedValues': in_data['updatedValues'], + } + # Only include removedValues if it exists + if 'removedValues' in in_data: + hash_data['removedValues'] = sorted(in_data['removedValues']) + + change_hash.update(json.dumps(hash_data, sort_keys=True).encode('utf-8')) + + return change_hash.hexdigest() + + +class ValidatesLicenseTypeMixin: + @validates_schema + def validate_license_type(self, data, **kwargs): # noqa: ARG002 unused-argument + license_types = config.license_types_for_compact(data['compact']) + if data['licenseType'] not in license_types: + raise ValidationError({'licenseType': [f'Must be one of: {", ".join(license_types)}.']}) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py new file mode 100644 index 0000000000..78af488bfd --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py @@ -0,0 +1,72 @@ +# ruff: noqa: N801, N802, N815, ARG002 invalid-name unused-kwargs +from collections import UserDict + +from cc_common.data_model.schema.common import CCDataClass +from cc_common.data_model.schema.compact.record import CompactRecordSchema + + +class Compact(UserDict): + """ + Compact configuration data model. Used to access variables without needing to know the underlying key structure. + + Deprecated: This is a legacy class maintained for backward compatibility. For new code, prefer using + CompactConfigurationData instead. + """ + + @property + def compact_abbr(self) -> str: + return self['compactAbbr'] + + @property + def compact_name(self) -> str: + return self['compactName'] + + @property + def compact_operations_team_emails(self) -> list[str] | None: + return self.get('compactOperationsTeamEmails') + + @property + def compact_adverse_actions_notification_emails(self) -> list[str] | None: + return self.get('compactAdverseActionsNotificationEmails') + + @property + def licensee_registration_enabled(self): + return self.get('licenseeRegistrationEnabled', False) + + +# New data class-based implementation +class CompactConfigurationData(CCDataClass): + """ + Class representing a Compact Configuration with getters and setters for all properties. + This is the preferred way to work with compact configuration data. + """ + + # Define the record schema at the class level + _record_schema = CompactRecordSchema() + + # object is immutable and cannot be changed after construction + _requires_data_at_construction = True + + @property + def compactAbbr(self) -> str: + return self._data['compactAbbr'] + + @property + def compactName(self) -> str: + return self._data['compactName'] + + @property + def compactOperationsTeamEmails(self) -> list[str]: + return self._data.get('compactOperationsTeamEmails', []) + + @property + def compactAdverseActionsNotificationEmails(self) -> list[str]: + return self._data.get('compactAdverseActionsNotificationEmails', []) + + @property + def licenseeRegistrationEnabled(self) -> bool: + return self._data.get('licenseeRegistrationEnabled', False) + + @property + def configuredStates(self) -> list[dict]: + return self._data.get('configuredStates', []) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/api.py new file mode 100644 index 0000000000..61743f8f70 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/api.py @@ -0,0 +1,43 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow import Schema, validates_schema +from marshmallow.fields import Boolean, Email, List, Nested, String +from marshmallow.validate import Length + +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.compact.common import ( + ConfiguredStateSchema, + validate_no_duplicates_in_configured_states, +) + + +class CompactConfigurationResponseSchema(ForgivingSchema): + """Schema for API responses from GET /v1/compacts/{compact}""" + + compactAbbr = String(required=True, allow_none=False) + compactName = String(required=True, allow_none=False) + compactOperationsTeamEmails = List(String(required=True, allow_none=False), required=True, allow_none=False) + compactAdverseActionsNotificationEmails = List( + Email(required=True, allow_none=False), + required=True, + allow_none=False, + ) + licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) + configuredStates = List(Nested(ConfiguredStateSchema()), required=True, allow_none=False) + + +class PutCompactConfigurationRequestSchema(Schema): + """Schema for the PUT /v1/compacts/{compact} request body""" + + compactOperationsTeamEmails = List( + Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) + ) + compactAdverseActionsNotificationEmails = List( + Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) + ) + licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) + configuredStates = List(Nested(ConfiguredStateSchema()), required=True, allow_none=False) + + @validates_schema + def validate_no_duplicates_in_configured_states(self, data, **kwargs): # noqa: ARG001 unused-argument + """Validate that configuredStates list contains no duplicate postal abbreviations.""" + validate_no_duplicates_in_configured_states(data) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/common.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/common.py new file mode 100644 index 0000000000..ffb30a848e --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/common.py @@ -0,0 +1,34 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow import Schema, ValidationError +from marshmallow.fields import Boolean, String +from marshmallow.validate import OneOf + +from cc_common.config import config + +COMPACT_TYPE = 'compact' + + +class ConfiguredStateSchema(Schema): + """ + Schema for individual configured state entries in a compact configuration. + + This schema defines the structure for states that have submitted configurations + and are tracked for live status management. + """ + + postalAbbreviation = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + isLive = Boolean(required=True, allow_none=False) + + +def validate_no_duplicates_in_configured_states(data): # noqa: ARG001 unused-argument + """Common method to validate that configuredStates list contains no duplicate postal abbreviations.""" + configured_states = data.get('configuredStates', []) + + configured_state_postal_abbrs = [state['postalAbbreviation'] for state in configured_states] + if len(set(configured_state_postal_abbrs)) != len(configured_state_postal_abbrs): + sorted_states = sorted(configured_state_postal_abbrs) + raise ValidationError( + f'Duplicate states found in configuredStates: {", ".join(sorted_states)}. ' + f'Each state can only appear once in the list.', + field_name='configuredStates', + ) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/record.py new file mode 100644 index 0000000000..db3e19fdf2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/compact/record.py @@ -0,0 +1,48 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow import pre_dump, validates_schema +from marshmallow.fields import Boolean, List, Nested, String +from marshmallow.validate import Length, OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.compact.common import ( + COMPACT_TYPE, + ConfiguredStateSchema, + validate_no_duplicates_in_configured_states, +) + + +@BaseRecordSchema.register_schema(COMPACT_TYPE) +class CompactRecordSchema(BaseRecordSchema): + """Schema for the root compact configuration records""" + + _record_type = COMPACT_TYPE + + # Provided fields + compactAbbr = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + compactName = String(required=True, allow_none=False) + compactOperationsTeamEmails = List(String(required=True, allow_none=False), required=True, allow_none=False) + compactAdverseActionsNotificationEmails = List( + String(required=True, allow_none=False), + required=True, + allow_none=False, + ) + licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) + # List of states that have submitted configurations and their live status + configuredStates = List(Nested(ConfiguredStateSchema()), required=True, allow_none=False) + + # Generated fields + pk = String(required=True, allow_none=False) + sk = String(required=True, allow_none=False, validate=Length(2, 100)) + + @validates_schema + def validate_no_duplicates_in_configured_states(self, data, **kwargs): # noqa: ARG001 unused-argument + """Validate that configuredStates list contains no duplicate postal abbreviations.""" + validate_no_duplicates_in_configured_states(data) + + @pre_dump + def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument + # the pk and sk are the same for the root compact record + in_data['pk'] = f'{in_data["compactAbbr"]}#CONFIGURATION' + in_data['sk'] = f'{in_data["compactAbbr"]}#CONFIGURATION' + return in_data diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py new file mode 100644 index 0000000000..4df727eaeb --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py @@ -0,0 +1,50 @@ +# ruff: noqa: N801, N815 invalid-name +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.fields import ( + Compact, + Jurisdiction, +) +from marshmallow.fields import UUID, AwareDateTime, Date, String + + +class DataEventDetailBaseSchema(ForgivingSchema): + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + eventTime = AwareDateTime(required=True, allow_none=False) + + +class EncumbranceEventDetailSchema(DataEventDetailBaseSchema): + providerId = UUID(required=True, allow_none=False) + adverseActionId = UUID(required=False, allow_none=False) + licenseTypeAbbreviation = String(required=True, allow_none=False) + effectiveDate = Date(required=True, allow_none=False) + adverseActionCategory = String(required=False, allow_none=False) + + +class InvestigationEventDetailSchema(DataEventDetailBaseSchema): + providerId = UUID(required=True, allow_none=False) + investigationId = UUID(required=True, allow_none=False) + licenseTypeAbbreviation = String(required=True, allow_none=False) + investigationAgainst = String(required=True, allow_none=False) + # Only present for investigationClosed events with encumbrance + adverseActionId = UUID(required=False, allow_none=False) + + +class LicenseDeactivationDetailSchema(DataEventDetailBaseSchema): + providerId = UUID(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + + +class LicenseRevertDetailSchema(DataEventDetailBaseSchema): + providerId = UUID(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + rollbackReason = String(required=True, allow_none=False) + startTime = AwareDateTime(required=True, allow_none=False) + endTime = AwareDateTime(required=True, allow_none=False) + rollbackExecutionName = String(required=True, allow_none=False) + + +class HomeJurisdictionChangeEventDetailSchema(DataEventDetailBaseSchema): + providerId = UUID(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + formerHomeJurisdiction = Jurisdiction(required=True, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/fields.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/fields.py new file mode 100644 index 0000000000..bf06ba9f2a --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/fields.py @@ -0,0 +1,132 @@ +from marshmallow.fields import Decimal, List, String +from marshmallow.validate import OneOf, Range, Regexp, Validator + +from cc_common.config import config +from cc_common.data_model.schema.common import ( + ActiveInactiveStatus, + ClinicalPrivilegeActionCategory, + CompactEligibilityStatus, + EncumbranceType, + HomeJurisdictionChangeStatusEnum, + InvestigationAgainstEnum, + InvestigationStatusEnum, + LicenseDeactivatedStatusEnum, + LicenseEncumberedStatusEnum, + PrivilegeEncumberedStatusEnum, + UpdateCategory, +) + +# This is a special value that is used to indicate that the provider's home jurisdiction is not known. +# This can happen if a provider moves to a jurisdiction that is not part of the compact. +OTHER_JURISDICTION = 'other' + +# This is a special value that is used to indicate that the provider's home jurisdiction is not known. +# This can happen if a provider has not registered with the compact connect system yet. +UNKNOWN_JURISDICTION = 'unknown' + + +class SocialSecurityNumber(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=Regexp('^[0-9]{3}-[0-9]{2}-[0-9]{4}$'), **kwargs) + + +class Set(List): + """A Field that de/serializes to a Set (not compatible with JSON)""" + + default_error_messages = {'invalid': 'Not a valid set.'} + + def _serialize(self, *args, **kwargs): + return set(super()._serialize(*args, **kwargs)) + + def _deserialize(self, *args, **kwargs): + return set(super()._deserialize(*args, **kwargs)) + + +class NationalProviderIdentifier(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=Regexp('^[0-9]{10}$'), **kwargs) + + +class Compact(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf(config.compacts), **kwargs) + + +class Jurisdiction(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf(config.jurisdictions), **kwargs) + + +class ActiveInactive(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in ActiveInactiveStatus]), **kwargs) + + +class CompactEligibility(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in CompactEligibilityStatus]), **kwargs) + + +class UpdateType(String): + def __init__(self, *args, **kwargs): + # Merge any provided validators with our new desired one + validate = kwargs.pop('validate', []) + if isinstance(validate, Validator): + validate = [validate] + super().__init__(*args, validate=[OneOf([entry.value for entry in UpdateCategory]), *validate], **kwargs) + + +class LicenseEncumberedStatusField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in LicenseEncumberedStatusEnum]), **kwargs) + + +class PrivilegeEncumberedStatusField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in PrivilegeEncumberedStatusEnum]), **kwargs) + + +class InvestigationStatusField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in InvestigationStatusEnum]), **kwargs) + + +class HomeJurisdictionChangeStatusField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in HomeJurisdictionChangeStatusEnum]), **kwargs) + + +class LicenseDeactivatedStatusField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in LicenseDeactivatedStatusEnum]), **kwargs) + + +class ITUTE164PhoneNumber(String): + """Phone number format consistent with ITU-T E.164: + https://www.itu.int/rec/T-REC-E.164-201011-I/en + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=Regexp(r'^\+[0-9]{8,15}$'), **kwargs) + + +class EncumbranceTypeField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in EncumbranceType]), **kwargs) + + +class ClinicalPrivilegeActionCategoryField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in ClinicalPrivilegeActionCategory]), **kwargs) + + +class InvestigationAgainstField(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in InvestigationAgainstEnum]), **kwargs) + + +class PositiveDecimal(Decimal): + """A Decimal field that validates the value is greater than or equal to 0.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=Range(min=0), **kwargs) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/__init__.py new file mode 100644 index 0000000000..90c933ff17 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/__init__.py @@ -0,0 +1,156 @@ +# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition +from datetime import datetime +from uuid import UUID + +from cc_common.data_model.schema.common import ( + CCDataClass, + InvestigationAgainstEnum, +) +from cc_common.data_model.schema.investigation.record import InvestigationRecordSchema + + +class InvestigationData(CCDataClass): + """ + Class representing an Investigation with getters and setters for all properties. + Takes a dict as an argument to the constructor to avoid primitive obsession. + """ + + # Define record schema at the class level + _record_schema = InvestigationRecordSchema() + + # Can use setters to set field data + _requires_data_at_construction = False + + @staticmethod + def generate_pk(compact: str, provider_id: UUID): + return f'{compact}#PROVIDER#{provider_id}' + + @staticmethod + def generate_sk( + compact: str, + investigation_against: InvestigationAgainstEnum, + jurisdiction: str, + license_type_abbr: str, + investigation_id: UUID, + ): + return ( + f'{compact}#PROVIDER#{investigation_against}/{jurisdiction}/{license_type_abbr}#' + f'INVESTIGATION#{investigation_id}' + ) + + @property + def pk(self): + if self.compact is None: + raise ValueError('Cannot calculate pk if compact is not set') + if self.providerId is None: + raise ValueError('Cannot calculate pk if providerId is not set') + + return self.generate_pk(self.compact, self.providerId) + + @property + def sk(self): + if self.compact is None: + raise ValueError('Cannot calculate sk if compact is not set') + if self.investigationAgainst is None: + raise ValueError('Cannot calculate sk if investigationAgainst is not set') + if self.jurisdiction is None: + raise ValueError('Cannot calculate sk if jurisdiction is not set') + if self.licenseTypeAbbreviation is None: + raise ValueError('Cannot calculate sk if licenseType is not set') + if self.investigationId is None: + raise ValueError('Cannot calculate sk if investigationId is not set') + return self.generate_sk( + compact=self.compact, + investigation_against=self.investigationAgainst, + jurisdiction=self.jurisdiction, + license_type_abbr=self.licenseTypeAbbreviation, + investigation_id=self.investigationId, + ) + + @property + def compact(self) -> str: + return self._data['compact'] + + @compact.setter + def compact(self, value: str) -> None: + self._data['compact'] = value + + @property + def providerId(self) -> UUID: + return self._data['providerId'] + + @providerId.setter + def providerId(self, value: UUID) -> None: + self._data['providerId'] = value + + @property + def jurisdiction(self) -> str: + return self._data['jurisdiction'] + + @jurisdiction.setter + def jurisdiction(self, value: str) -> None: + self._data['jurisdiction'] = value + + @property + def licenseType(self) -> str: + return self._data['licenseType'] + + @licenseType.setter + def licenseType(self, value: str) -> None: + self._data['licenseType'] = value + + @property + def investigationAgainst(self) -> str: + return self._data['investigationAgainst'] + + @investigationAgainst.setter + def investigationAgainst(self, investigation_against_enum: InvestigationAgainstEnum) -> None: + self._data['investigationAgainst'] = investigation_against_enum.value + + @property + def investigationId(self) -> UUID: + return self._data['investigationId'] + + @investigationId.setter + def investigationId(self, value: UUID) -> None: + self._data['investigationId'] = value + + @property + def submittingUser(self) -> UUID: + return self._data['submittingUser'] + + @submittingUser.setter + def submittingUser(self, value: UUID) -> None: + self._data['submittingUser'] = value + + @property + def creationDate(self) -> datetime: + return self._data['creationDate'] + + @creationDate.setter + def creationDate(self, value: datetime) -> None: + self._data['creationDate'] = value + + @property + def closeDate(self) -> datetime | None: + return self._data.get('closeDate') + + @closeDate.setter + def closeDate(self, value: datetime) -> None: + self._data['closeDate'] = value + + @property + def closingUser(self) -> UUID | None: + return self._data.get('closingUser') + + @closingUser.setter + def closingUser(self, value: UUID) -> None: + self._data['closingUser'] = value + + @property + def resultingEncumbranceId(self) -> UUID | None: + return self._data.get('resultingEncumbranceId') + + @resultingEncumbranceId.setter + def resultingEncumbranceId(self, value: UUID) -> None: + self._data['resultingEncumbranceId'] = value diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/api.py new file mode 100644 index 0000000000..5a03405760 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/api.py @@ -0,0 +1,45 @@ +# ruff: noqa: N801, N815 invalid-name +from marshmallow.fields import Nested, Raw, String +from marshmallow.validate import OneOf + +from cc_common.data_model.schema.adverse_action.api import AdverseActionPostRequestSchema +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.fields import ( + Compact, + Jurisdiction, +) + + +class InvestigationPatchRequestSchema(ForgivingSchema): + """ + Schema for investigation PATCH requests (investigation closing). + + This schema is used to validate incoming requests to the investigation PATCH API endpoint + for closing investigations. + + Serialization direction: + API -> load() -> Python + """ + + # Optional encumbrance data to create when closing investigation + encumbrance = Nested(AdverseActionPostRequestSchema, required=False, allow_none=False) + + +class InvestigationGeneralResponseSchema(ForgivingSchema): + """ + Schema for investigation general responses. + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False, validate=OneOf(['investigation'])) + compact = Compact(required=True, allow_none=False) + providerId = Raw(required=True, allow_none=False) + investigationId = Raw(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + dateOfUpdate = Raw(required=True, allow_none=False) + + creationDate = Raw(required=True, allow_none=False) + submittingUser = Raw(required=True, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/record.py new file mode 100644 index 0000000000..db8bfba19d --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/investigation/record.py @@ -0,0 +1,66 @@ +# ruff: noqa: N801, N815 invalid-name +from marshmallow import Schema, ValidationError, pre_dump +from marshmallow.fields import UUID, AwareDateTime, String + +from cc_common.config import config +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.common import ValidatesLicenseTypeMixin +from cc_common.data_model.schema.fields import ( + Compact, + InvestigationAgainstField, + Jurisdiction, +) + + +@BaseRecordSchema.register_schema('investigation') +class InvestigationRecordSchema(BaseRecordSchema, ValidatesLicenseTypeMixin): + """ + Schema for investigation records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'investigation' + + compact = Compact(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + investigationAgainst = InvestigationAgainstField(required=True, allow_none=False) + + # Populated on creation + investigationId = UUID(required=True, allow_none=False) + submittingUser = UUID(required=True, allow_none=False) + creationDate = AwareDateTime(required=True, allow_none=False) + + # Populated when the investigation is closed + closeDate = AwareDateTime(required=False, allow_none=False) + closingUser = UUID(required=False, allow_none=False) + resultingEncumbranceId = UUID(required=False, allow_none=False) + + @pre_dump + def generate_pk_sk(self, in_data, **_kwargs): + in_data['pk'] = f'{in_data["compact"]}#PROVIDER#{in_data["providerId"]}' + # ensure this is passed in lowercase + try: + license_type_abbr = config.license_type_abbreviations[in_data['compact']][in_data['licenseType']] + except KeyError as e: + # Validation is usually done on load and this runs on dump, but we depend on this value being valid + # so we might as well raise a ValidationError if we try to dump an invalid license type + license_types = config.license_types_for_compact(in_data['compact']) + raise ValidationError({'licenseType': [f'Must be one of: {", ".join(license_types)}.']}) from e + in_data['sk'] = ( + f'{in_data["compact"]}#PROVIDER#{in_data["investigationAgainst"]}/{in_data["jurisdiction"]}/{license_type_abbr}#INVESTIGATION#{in_data["investigationId"]}' + ) + return in_data + + +class InvestigationDetailsSchema(Schema): + """ + Schema for tracking details about an investigation. + """ + + investigationId = UUID(required=True, allow_none=False) + # present if update is created by upstream license investigation + licenseJurisdiction = Jurisdiction(required=False, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py new file mode 100644 index 0000000000..aca2eebcec --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py @@ -0,0 +1,42 @@ +# ruff: noqa: N801, N802, N815, ARG002 invalid-name unused-kwargs + +from cc_common.data_model.schema.common import CCDataClass +from cc_common.data_model.schema.jurisdiction.record import JurisdictionRecordSchema + + +# data class-based implementation +class JurisdictionConfigurationData(CCDataClass): + """ + Class representing a Jurisdiction Configuration with getters and setters for all properties. + This is the preferred way to work with jurisdiction configuration data. + """ + + # Define the record schema at the class level + _record_schema = JurisdictionRecordSchema() + + # Can use setters to set field data + _requires_data_at_construction = False + + @property + def jurisdictionName(self) -> str: + return self._data['jurisdictionName'] + + @property + def postalAbbreviation(self) -> str: + return self._data['postalAbbreviation'] + + @property + def compact(self) -> str: + return self._data['compact'] + + @property + def jurisdictionOperationsTeamEmails(self) -> list[str]: + return self._data.get('jurisdictionOperationsTeamEmails', []) + + @property + def jurisdictionAdverseActionsNotificationEmails(self) -> list[str]: + return self._data.get('jurisdictionAdverseActionsNotificationEmails', []) + + @property + def licenseeRegistrationEnabled(self) -> bool: + return self._data.get('licenseeRegistrationEnabled', False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py new file mode 100644 index 0000000000..a1181b6c8b --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py @@ -0,0 +1,62 @@ +# ruff: noqa: N801, N802, N815, ARG002 invalid-name unused-kwargs +from marshmallow import Schema +from marshmallow.fields import Boolean, Email, List, String +from marshmallow.validate import Length, OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import ForgivingSchema + + +class CompactJurisdictionsStaffUsersResponseSchema(ForgivingSchema): + """ + Used to enforce which fields are returned in jurisdiction objects for the + GET /compacts/{compact}/jurisdictions endpoint + """ + + jurisdictionName = String(required=True, allow_none=False) + postalAbbreviation = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + + +class CompactJurisdictionsPublicResponseSchema(ForgivingSchema): + """ + Used to enforce which fields are returned in jurisdiction objects for the + GET public/compacts/{compact}/jurisdictions endpoint + """ + + jurisdictionName = String(required=True, allow_none=False) + postalAbbreviation = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + + +class CompactJurisdictionConfigurationResponseSchema(ForgivingSchema): + """ + Used to enforce which fields are returned in jurisdiction objects for the + GET /compacts/{compact}/jurisdictions/{jurisdiction} endpoint + """ + + jurisdictionName = String(required=True, allow_none=False) + postalAbbreviation = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + jurisdictionOperationsTeamEmails = List(Email(required=True, allow_none=False), required=True, allow_none=False) + jurisdictionAdverseActionsNotificationEmails = List( + Email(required=True, allow_none=False), + required=True, + allow_none=False, + ) + licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) + + +class PutCompactJurisdictionConfigurationRequestSchema(Schema): + """ + Used to enforce which fields are posted in jurisdiction objects for the + PUT /compacts/{compact}/jurisdictions/{jurisdiction} endpoint + """ + + licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) + jurisdictionOperationsTeamEmails = List( + Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) + ) + jurisdictionAdverseActionsNotificationEmails = List( + Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) + ) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/common.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/common.py new file mode 100644 index 0000000000..d026c2a800 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/common.py @@ -0,0 +1 @@ +JURISDICTION_TYPE = 'jurisdiction' diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py new file mode 100644 index 0000000000..4e23423237 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py @@ -0,0 +1,37 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow import pre_dump +from marshmallow.fields import Boolean, Email, List, String +from marshmallow.validate import Length, OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.jurisdiction.common import JURISDICTION_TYPE + + +@BaseRecordSchema.register_schema(JURISDICTION_TYPE) +class JurisdictionRecordSchema(BaseRecordSchema): + """Schema for the root jurisdiction configuration records""" + + _record_type = JURISDICTION_TYPE + + # Provided fields + jurisdictionName = String(required=True, allow_none=False) + postalAbbreviation = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + jurisdictionOperationsTeamEmails = List(Email(required=True, allow_none=False), required=True, allow_none=False) + jurisdictionAdverseActionsNotificationEmails = List( + Email(required=True, allow_none=False), + required=True, + allow_none=False, + ) + licenseeRegistrationEnabled = Boolean(required=True, allow_none=False) + + # Generated fields + pk = String(required=True, allow_none=False) + sk = String(required=True, allow_none=False, validate=Length(2, 100)) + + @pre_dump + def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['pk'] = f'{in_data["compact"]}#CONFIGURATION' + in_data['sk'] = f'{in_data["compact"]}#JURISDICTION#{in_data["postalAbbreviation"].lower()}' + return in_data diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/__init__.py new file mode 100644 index 0000000000..465e08ebdb --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/__init__.py @@ -0,0 +1,197 @@ +# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition +from datetime import date, datetime +from uuid import UUID + +from cc_common.data_model.schema.common import CCDataClass +from cc_common.data_model.schema.license.record import LicenseRecordSchema, LicenseUpdateRecordSchema + + +class LicenseData(CCDataClass): + """ + Class representing a License with read-only properties. + + Unlike several other CCDataClass subclasses, this one does not include setters. This is because + license records are only upserted during ingestion, so we can pass the entire record + from the ingestion process into the constructor. + + Note: This class requires valid data when created - it cannot be instantiated empty + and populated later. + """ + + # Define the record schema at the class level + _record_schema = LicenseRecordSchema() + + # Require valid data when creating instances + _requires_data_at_construction = True + + @property + def providerId(self) -> UUID: + return self._data['providerId'] + + @property + def compact(self) -> str: + return self._data['compact'] + + @property + def jurisdiction(self) -> str: + return self._data['jurisdiction'] + + @property + def licenseType(self) -> str: + return self._data['licenseType'] + + @property + def licenseNumber(self) -> str: + return self._data['licenseNumber'] + + @property + def ssnLastFour(self) -> str: + return self._data['ssnLastFour'] + + @property + def givenName(self) -> str: + return self._data['givenName'] + + @property + def middleName(self) -> str | None: + return self._data.get('middleName') + + @property + def familyName(self) -> str: + return self._data['familyName'] + + @property + def suffix(self) -> str | None: + return self._data.get('suffix') + + @property + def dateOfIssuance(self) -> date: + return self._data['dateOfIssuance'] + + @property + def dateOfRenewal(self) -> date | None: + return self._data.get('dateOfRenewal') + + @property + def dateOfExpiration(self) -> date: + return self._data['dateOfExpiration'] + + @property + def dateOfBirth(self) -> date: + return self._data['dateOfBirth'] + + @property + def homeAddressStreet1(self) -> str: + return self._data['homeAddressStreet1'] + + @property + def homeAddressStreet2(self) -> str | None: + return self._data.get('homeAddressStreet2') + + @property + def homeAddressCity(self) -> str: + return self._data['homeAddressCity'] + + @property + def homeAddressState(self) -> str: + return self._data['homeAddressState'] + + @property + def homeAddressPostalCode(self) -> str: + return self._data['homeAddressPostalCode'] + + @property + def emailAddress(self) -> str | None: + return self._data.get('emailAddress') + + @property + def phoneNumber(self) -> str | None: + return self._data.get('phoneNumber') + + @property + def licenseStatus(self) -> str | None: + return self._data.get('licenseStatus') + + @property + def licenseStatusName(self) -> str | None: + return self._data.get('licenseStatusName') + + @property + def jurisdictionUploadedLicenseStatus(self) -> str: + return self._data['jurisdictionUploadedLicenseStatus'] + + @property + def jurisdictionUploadedCompactEligibility(self) -> str: + return self._data['jurisdictionUploadedCompactEligibility'] + + @property + def compactEligibility(self) -> str: + return self._data['compactEligibility'] + + @property + def encumberedStatus(self) -> str | None: + return self._data.get('encumberedStatus') + + @property + def investigationStatus(self) -> str | None: + return self._data.get('investigationStatus') + + @property + def firstUploadDate(self) -> datetime | None: + return self._data.get('firstUploadDate') + + +class LicenseUpdateData(CCDataClass): + """ + Class representing a License Update with getters and setters for all properties. + Takes a dict as an argument to the constructor to avoid primitive obsession. + + Note: This class requires valid data when created - it cannot be instantiated empty + and populated later. + """ + + # Define the record schema at the class level + _record_schema = LicenseUpdateRecordSchema() + + # Require valid data when creating instances + _requires_data_at_construction = True + + @property + def updateType(self) -> str: + return self._data['updateType'] + + @property + def providerId(self) -> UUID: + return self._data['providerId'] + + @property + def compact(self) -> str: + return self._data['compact'] + + @property + def jurisdiction(self) -> str: + return self._data['jurisdiction'] + + @property + def licenseType(self) -> str: + return self._data['licenseType'] + + @property + def createDate(self) -> datetime: + return self._data['createDate'] + + @property + def effectiveDate(self) -> datetime: + return self._data['effectiveDate'] + + @property + def previous(self) -> dict: + return self._data['previous'] + + @property + def updatedValues(self) -> dict: + return self._data['updatedValues'] + + @property + def removedValues(self) -> list[str] | None: + return self._data.get('removedValues') diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/api.py new file mode 100644 index 0000000000..0dd849d827 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/api.py @@ -0,0 +1,270 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +""" +Schema for API objects. +""" + +from datetime import date + +from marshmallow import ValidationError, pre_load, validates_schema +from marshmallow.fields import Boolean, Date, Email, List, Nested, Raw, String +from marshmallow.validate import Length + +from cc_common.config import config +from cc_common.data_model.schema.adverse_action.api import AdverseActionGeneralResponseSchema +from cc_common.data_model.schema.base_record import ForgivingSchema, StrictSchema +from cc_common.data_model.schema.common import ActiveInactiveStatus, CCRequestSchema, CompactEligibilityStatus +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + CompactEligibility, + InvestigationStatusField, + ITUTE164PhoneNumber, + Jurisdiction, + SocialSecurityNumber, +) +from cc_common.data_model.schema.investigation.api import InvestigationGeneralResponseSchema + + +class LicenseExpirationStatusMixin: + """ + Mixin that corrects stale 'licenseStatus' values when loading license data. + + OpenSearch documents may have stale status values because the licenseStatus field is + calculated at write time. If the dateOfExpiration has passed since the last update, + the licenseStatus should be 'inactive' even if the stored value says 'active'. + + This mixin should be applied to license API response schemas that load data from + OpenSearch or other sources where the status may be stale. + """ + + @pre_load + def correct_expired_license_status(self, in_data, **kwargs): + """Correct licenseStatus and compactEligibility if the license has expired.""" + if in_data.get('licenseStatus') != ActiveInactiveStatus.ACTIVE: + # Already inactive, no correction needed + return in_data + + date_of_expiration = in_data.get('dateOfExpiration') + if date_of_expiration is None: + return in_data + + # Parse the expiration date (handle both string and date objects) + if isinstance(date_of_expiration, str): + expiration_date = date.fromisoformat(date_of_expiration) + else: + expiration_date = date_of_expiration + + # If expired, correct the status to inactive and eligibility to ineligible + if expiration_date < config.expiration_resolution_date: + in_data['licenseStatus'] = ActiveInactiveStatus.INACTIVE + in_data['compactEligibility'] = CompactEligibilityStatus.INELIGIBLE + + return in_data + + +class LicensePostRequestSchema(CCRequestSchema, StrictSchema): + """ + Schema for license data as posted by a board staff-user + + Serialization direction: + API -> load() -> Python + """ + + ssn = SocialSecurityNumber(required=True, allow_none=False) + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + licenseStatusName = String(required=False, allow_none=False, validate=Length(1, 100)) + # Note that the two fields below, `licenseStatus` and `compactEligibility`, are stored + # in the database as `jurisdictionUploadedLicenseStatus` and `jurisdictionUploadedCompactEligibility`. + # This is to distinguish them from the `licenseStatus` and `compactEligibility` fields returned via the + # API, which are dynamically calculated based on logic that includes the current time and the + # license expiration date. + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + # These date values are determined by the license records uploaded by a state + # they do not include a timestamp, so we use the Date field type + dateOfIssuance = Date(required=True, allow_none=False) + dateOfRenewal = Date(required=False, allow_none=False) + dateOfExpiration = Date(required=True, allow_none=False) + dateOfBirth = Date(required=True, allow_none=False) + homeAddressStreet1 = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressStreet2 = String(required=False, allow_none=False, validate=Length(1, 100)) + homeAddressCity = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressState = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressPostalCode = String(required=True, allow_none=False, validate=Length(5, 7)) + emailAddress = Email(required=False, allow_none=False, validate=Length(1, 100)) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + + @validates_schema + def validate_license_type(self, data, **_kwargs): + license_types = config.license_types_for_compact(data['compact']) + if data['licenseType'] not in license_types: + raise ValidationError({'licenseType': [f'Must be one of: {", ".join(license_types)}.']}) + + @validates_schema + def validate_compact_eligibility(self, data, **_kwargs): + if ( + data['licenseStatus'] == ActiveInactiveStatus.INACTIVE + and data['compactEligibility'] == CompactEligibilityStatus.ELIGIBLE + ): + raise ValidationError( + {'compactEligibility': ['compactEligibility cannot be eligible if licenseStatus is inactive.']} + ) + + +class LicenseReportResponseSchema(ForgivingSchema): + """ + License object fields, as included in ingest error reports to state operational staff. + + Serialization direction: + Python -> load() -> API + """ + + providerId = Raw(required=True, allow_none=False) + type = String(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + licenseStatusName = String(required=False, allow_none=False, validate=Length(1, 100)) + licenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + dateOfIssuance = Raw(required=True, allow_none=False) + dateOfRenewal = Raw(required=False, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + + +class LicenseGeneralResponseSchema(LicenseExpirationStatusMixin, ForgivingSchema): + """ + License object fields, as seen by staff users with only the 'readGeneral' permission. + + Serialization direction: + Python -> load() -> API + """ + + providerId = Raw(required=True, allow_none=False) + type = String(required=True, allow_none=False) + dateOfUpdate = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + licenseStatusName = String(required=False, allow_none=False, validate=Length(1, 100)) + licenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + dateOfIssuance = Raw(required=True, allow_none=False) + dateOfRenewal = Raw(required=False, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + homeAddressStreet1 = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressStreet2 = String(required=False, allow_none=False, validate=Length(1, 100)) + homeAddressCity = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressState = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressPostalCode = String(required=True, allow_none=False, validate=Length(5, 7)) + emailAddress = Email(required=False, allow_none=False) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + adverseActions = List(Nested(AdverseActionGeneralResponseSchema, required=False, allow_none=False)) + investigations = List(Nested(InvestigationGeneralResponseSchema, required=False, allow_none=False)) + # This field is only set if the license is under investigation + investigationStatus = InvestigationStatusField(required=False, allow_none=False) + + +class LicenseReadPrivateResponseSchema(LicenseExpirationStatusMixin, ForgivingSchema): + """ + License object fields, as seen by staff users with only the 'readPrivate' permission. + + Serialization direction: + Python -> load() -> API + """ + + providerId = Raw(required=True, allow_none=False) + type = String(required=True, allow_none=False) + dateOfUpdate = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + licenseStatusName = String(required=False, allow_none=False, validate=Length(1, 100)) + licenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + dateOfIssuance = Raw(required=True, allow_none=False) + dateOfRenewal = Raw(required=False, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + homeAddressStreet1 = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressStreet2 = String(required=False, allow_none=False, validate=Length(1, 100)) + homeAddressCity = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressState = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressPostalCode = String(required=True, allow_none=False, validate=Length(5, 7)) + emailAddress = Email(required=False, allow_none=False) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + adverseActions = List(Nested(AdverseActionGeneralResponseSchema, required=False, allow_none=False)) + investigations = List(Nested(InvestigationGeneralResponseSchema, required=False, allow_none=False)) + # This field is only set if the license is under investigation + investigationStatus = InvestigationStatusField(required=False, allow_none=False) + + # these fields are specific to the read private role + dateOfBirth = Raw(required=False, allow_none=False) + ssnLastFour = String(required=False, allow_none=False, validate=Length(equal=4)) + + +class LicenseOpenSearchDocumentSchema(LicenseGeneralResponseSchema): + """ + License object fields for OpenSearch document indexing. + + Extends LicenseGeneralResponseSchema with the dateOfBirth field to enable + authorized staff users to search providers by date of birth. This schema + is used only for indexing into OpenSearch, not for API responses. + + Additionally, this schema includes the mostRecentLicenseForType field to indicate + the most recent license for the provider for a specific license type. This + allows for filtering public search results by the most recent licenses for + the provider. + + Serialization direction: + Python -> load() -> OpenSearch document + """ + + dateOfBirth = Raw(required=False, allow_none=False) + mostRecentLicenseForType = Boolean(required=False, allow_none=False, load_default=False) + + +class LicensePublicResponseSchema(LicenseExpirationStatusMixin, ForgivingSchema): + """ + License object fields, as seen by the public lookup endpoints. + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/common.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/common.py new file mode 100644 index 0000000000..b2e367a8cc --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/common.py @@ -0,0 +1,43 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow.fields import Date, Email, String +from marshmallow.validate import Length + +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.common import ValidatesLicenseTypeMixin +from cc_common.data_model.schema.fields import ( + Compact, + ITUTE164PhoneNumber, + Jurisdiction, +) + + +class LicenseCommonSchema(ForgivingSchema, ValidatesLicenseTypeMixin): + """ + This schema is used for both the LicensePostSchema and LicenseIngestSchema. It contains the fields that are common + to both the external and internal representations of a license record. + + Serialization direction: + DB -> load() -> Python + """ + + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + # These date values are determined by the license records uploaded by a state + # they do not include a timestamp, so we use the Date field type + dateOfIssuance = Date(required=True, allow_none=False) + dateOfRenewal = Date(required=False, allow_none=False) + dateOfExpiration = Date(required=True, allow_none=False) + dateOfBirth = Date(required=True, allow_none=False) + homeAddressStreet1 = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressStreet2 = String(required=False, allow_none=False, validate=Length(1, 100)) + homeAddressCity = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressState = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressPostalCode = String(required=True, allow_none=False, validate=Length(5, 7)) + emailAddress = Email(required=False, allow_none=False) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + licenseStatusName = String(required=False, allow_none=False, validate=Length(1, 100)) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/ingest.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/ingest.py new file mode 100644 index 0000000000..132198f7c4 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/ingest.py @@ -0,0 +1,85 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow import ValidationError, pre_load, validates_schema +from marshmallow.fields import UUID, AwareDateTime, Date, String +from marshmallow.validate import Length + +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.common import ActiveInactiveStatus, CompactEligibilityStatus +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + CompactEligibility, + Jurisdiction, +) +from cc_common.data_model.schema.license.common import LicenseCommonSchema + + +class LicenseIngestSchema(LicenseCommonSchema): + """ + Schema for converting the external license data to the internal format + + Serialization direction: + SQS -> load() -> Python + """ + + ssnLastFour = String(required=True, allow_none=False, validate=Length(equal=4)) + providerId = UUID(required=True, allow_none=False) + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + # This is used to calculate the actual 'licenseStatus' used by the system in addition + # to the expiration date of the license. + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + + @pre_load + def pre_load_initialization(self, in_data, **_kwargs): + in_data = self._set_jurisdiction_status(in_data) + return self._set_compact_eligibility(in_data) + + def _set_jurisdiction_status(self, in_data, **_kwargs): + """ + This maps the incoming 'licenseStatus' value to the internal 'jurisdictionUploadedLicenseStatus' field. + """ + in_data['jurisdictionUploadedLicenseStatus'] = in_data.pop('licenseStatus') + return in_data + + def _set_compact_eligibility(self, in_data, **_kwargs): + """ + This maps the incoming 'compactEligibility' value to the internal 'jurisdictionUploadedCompactEligibility' + field. + """ + in_data['jurisdictionUploadedCompactEligibility'] = in_data.pop('compactEligibility') + return in_data + + @validates_schema + def validate_persisted_compact_eligibility(self, data, **_kwargs): + if ( + data['jurisdictionUploadedLicenseStatus'] == ActiveInactiveStatus.INACTIVE + and data['jurisdictionUploadedCompactEligibility'] == CompactEligibilityStatus.ELIGIBLE + ): + raise ValidationError( + { + 'jurisdictionUploadedCompactEligibility': [ + 'jurisdictionUploadedCompactEligibility cannot be eligible if jurisdictionUploadedLicenseStatus' + ' is inactive.' + ] + } + ) + + +class SanitizedLicenseIngestDataEventSchema(ForgivingSchema): + """ + Schema which removes all pii from the license ingest event for storing in the database + + Serialization direction: + SQS -> load() -> Python + """ + + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + dateOfIssuance = Date(required=True, allow_none=False) + dateOfRenewal = Date(required=False, allow_none=False) + dateOfExpiration = Date(required=True, allow_none=False) + eventTime = AwareDateTime(required=True, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/record.py new file mode 100644 index 0000000000..3d2d1f2bfa --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/license/record.py @@ -0,0 +1,299 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from datetime import date +from urllib.parse import quote + +from marshmallow import ValidationError, post_dump, post_load, pre_dump, pre_load, validates_schema +from marshmallow.fields import UUID, AwareDateTime, Date, Email, List, Nested, String +from marshmallow.validate import Length + +from cc_common.config import config +from cc_common.data_model.schema.base_record import ( + BaseRecordSchema, + ForgivingSchema, +) +from cc_common.data_model.schema.common import ( + LICENSE_UPLOAD_UPDATE_CATEGORIES, + ActiveInactiveStatus, + ChangeHashMixin, + CompactEligibilityStatus, + LicenseEncumberedStatusEnum, + UpdateCategory, +) +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + CompactEligibility, + InvestigationStatusField, + ITUTE164PhoneNumber, + Jurisdiction, + LicenseEncumberedStatusField, + UpdateType, +) +from cc_common.data_model.schema.investigation.record import InvestigationDetailsSchema +from cc_common.data_model.schema.license.common import LicenseCommonSchema +from cc_common.data_model.update_tier_enum import UpdateTierEnum + + +@BaseRecordSchema.register_schema('license') +class LicenseRecordSchema(BaseRecordSchema, LicenseCommonSchema): + """ + Schema for license records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'license' + + providerId = UUID(required=True, allow_none=False) + licenseGSIPK = String(required=True, allow_none=False) + licenseGSISK = String(required=True, allow_none=False) + licenseUploadDateGSIPK = String(required=False, allow_none=False) + licenseUploadDateGSISK = String(required=False, allow_none=False) + + # Optional field for tracking the first license upload that caused this record to be created + # Note that records which were uploaded before this field was supported will not have this included + # and will not be included in the license upload date GSI + firstUploadDate = AwareDateTime(required=False, allow_none=False) + + # Provided fields + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + ssnLastFour = String(required=True, allow_none=False) + + # optional field for setting encumbrance status + encumberedStatus = LicenseEncumberedStatusField(required=False, allow_none=False) + # optional field for setting investigation status + investigationStatus = InvestigationStatusField(required=False, allow_none=False) + + # Persisted values + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + licenseStatusName = String(required=False, allow_none=False, validate=Length(1, 100)) + # Calculated values + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + + @pre_load + def _calculate_statuses(self, in_data, **_kwargs): + """Determine the statuses of the record based on the expiration date""" + in_data = self._calculate_license_status(in_data) + return self._calculate_compact_eligibility(in_data) + + def _calculate_license_status(self, in_data, **_kwargs): + """Determine the status of the license based on the expiration date""" + in_data['licenseStatus'] = ( + ActiveInactiveStatus.ACTIVE + if ( + in_data['jurisdictionUploadedLicenseStatus'] == ActiveInactiveStatus.ACTIVE + and date.fromisoformat(in_data['dateOfExpiration']) >= config.expiration_resolution_date + and in_data.get('encumberedStatus', LicenseEncumberedStatusEnum.UNENCUMBERED) + == LicenseEncumberedStatusEnum.UNENCUMBERED + ) + else ActiveInactiveStatus.INACTIVE + ) + return in_data + + def _calculate_compact_eligibility(self, in_data, **_kwargs): + """ + Licenses are only eligible for the compact if their jurisdiction says they are, they are not encumbered, + and they have an active license status. + """ + in_data['compactEligibility'] = ( + CompactEligibilityStatus.ELIGIBLE + if ( + in_data['jurisdictionUploadedCompactEligibility'] == CompactEligibilityStatus.ELIGIBLE + and in_data['licenseStatus'] == ActiveInactiveStatus.ACTIVE + and in_data.get('encumberedStatus', LicenseEncumberedStatusEnum.UNENCUMBERED) + == LicenseEncumberedStatusEnum.UNENCUMBERED + ) + else CompactEligibilityStatus.INELIGIBLE + ) + return in_data + + @pre_dump + def remove_calculated_fields(self, in_data, **_kwargs): + """Remove the calculated status fields before dumping to the database""" + in_data.pop('status', None) + in_data.pop('licenseStatus', None) + in_data.pop('compactEligibility', None) + return in_data + + @pre_dump + def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['pk'] = f'{in_data["compact"]}#PROVIDER#{in_data["providerId"]}' + license_type_abbr = config.license_type_abbreviations[in_data['compact']][in_data['licenseType']] + in_data['sk'] = f'{in_data["compact"]}#PROVIDER#license/{in_data["jurisdiction"]}/{license_type_abbr}#' + return in_data + + @pre_dump + def generate_license_gsi_fields(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['licenseGSIPK'] = f'C#{in_data["compact"].lower()}#J#{in_data["jurisdiction"].lower()}' + in_data['licenseGSISK'] = f'FN#{quote(in_data["familyName"].lower())}#GN#{quote(in_data["givenName"].lower())}' + return in_data + + @pre_dump + def generate_license_upload_date_gsi_fields(self, in_data, **kwargs): # noqa: ARG001 unused-argument + """Generate GSI fields for license upload date tracking (only if firstUploadDate is present)""" + if 'firstUploadDate' in in_data and in_data['firstUploadDate'] is not None: + # Extract YYYY-MM from firstUploadDate + upload_date = in_data['firstUploadDate'] + year_month = upload_date.strftime('%Y-%m') + + # Generate GSI PK: C#{compact}#J#{jurisdiction}#D#{YYYY-MM} + in_data['licenseUploadDateGSIPK'] = ( + f'C#{in_data["compact"].lower()}#J#{in_data["jurisdiction"].lower()}#D#{year_month}' + ) + # Generate GSI SK: TIME#{epoch_timestamp}#LT#{licenseType}#PID#{providerId} + upload_epoch_time = int(upload_date.timestamp()) + license_type_abbr = config.license_type_abbreviations[in_data['compact']][in_data['licenseType']] + in_data['licenseUploadDateGSISK'] = ( + f'TIME#{upload_epoch_time}#LT#{license_type_abbr}#PID#{in_data["providerId"]}' + ) + return in_data + + @post_load + def drop_license_gsi_fields(self, in_data, **kwargs): # noqa: ARG001 unused-argument + """Drop the db-specific license GSI fields before returning loaded data""" + # only drop the field if it's present, else continue on + in_data.pop('licenseGSIPK', None) + in_data.pop('licenseGSISK', None) + in_data.pop('licenseUploadDateGSIPK', None) + in_data.pop('licenseUploadDateGSISK', None) + return in_data + + +class LicenseUpdateRecordPreviousSchema(ForgivingSchema): + """ + A snapshot of a previous state of a license record + + Serialization direction: + DB -> load() -> Python + """ + + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + ssnLastFour = String(required=True, allow_none=False) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + dateOfUpdate = AwareDateTime(required=True, allow_none=False) + # These date values are determined by the license records uploaded by a state + # they do not include a timestamp, so we use the Date field type + dateOfIssuance = Date(required=True, allow_none=False) + dateOfRenewal = Date(required=False, allow_none=False) + dateOfExpiration = Date(required=True, allow_none=False) + dateOfBirth = Date(required=True, allow_none=False) + homeAddressStreet1 = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressStreet2 = String(required=False, allow_none=False, validate=Length(1, 100)) + homeAddressCity = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressState = String(required=True, allow_none=False, validate=Length(2, 100)) + homeAddressPostalCode = String(required=True, allow_none=False, validate=Length(5, 7)) + emailAddress = Email(required=False, allow_none=False) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + licenseStatusName = String(required=False, allow_none=False, validate=Length(1, 100)) + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + encumberedStatus = LicenseEncumberedStatusField(required=False, allow_none=False) + investigationStatus = InvestigationStatusField(required=False, allow_none=False) + + +@BaseRecordSchema.register_schema('licenseUpdate') +class LicenseUpdateRecordSchema(BaseRecordSchema, ChangeHashMixin): + """ + Schema for license update history records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'licenseUpdate' + + updateType = UpdateType(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + previous = Nested(LicenseUpdateRecordPreviousSchema, required=True, allow_none=False) + # this tracks when the update record was created + createDate = AwareDateTime(required=True, allow_none=False) + # this tracks when the update event should be considered in effect for the history of the license record + # note for most update types this is the same as the createDate, except encumbrances, which are effective + # based on the value provided by the state administrator + effectiveDate = AwareDateTime(required=True, allow_none=False) + # We'll allow any fields that can show up in the previous field to be here as well, but none are required + updatedValues = Nested(LicenseUpdateRecordPreviousSchema(partial=True), required=True, allow_none=False) + # optional field that is only included if the update was an investigation + investigationDetails = Nested(InvestigationDetailsSchema(), required=False, allow_none=False) + # List of field names that were present in the previous record but removed in the update + removedValues = List(String(), required=False, allow_none=False) + + # Optional GSI fields for license upload date tracking + licenseUploadDateGSIPK = String(required=False, allow_none=False) + licenseUploadDateGSISK = String(required=False, allow_none=False) + + @post_dump # Must be _post_ dump so we have values that are more easily hashed + def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument + """ + NOTE: Because the 'sk' field in this record type contains a hash that is generated based on the values of the + record itself and because, in some cases, the values could be guessed and verified by the hash with relative + ease, regardless of the strength of the hash, we need to treat the 'sk' field as if it were just as sensitive as + the most sensitive field in the record. More to the point, we need to be sure that this internal field is never + served out via API. + """ + in_data['pk'] = f'{in_data["compact"]}#PROVIDER#{in_data["providerId"]}' + # This needs to include an iso formatted datetime string and a hash of the changes + # to the record. We'll use the createDate and the hash of the updatedValues + # field for this. + change_hash = self.hash_changes(in_data) + license_type_abbr = config.license_type_abbreviations[in_data['compact']][in_data['licenseType']] + in_data['sk'] = ( + f'{in_data["compact"]}#UPDATE#{UpdateTierEnum.TIER_THREE}#license/{in_data["jurisdiction"]}/{license_type_abbr}/{in_data["createDate"]}/{change_hash}' + ) + return in_data + + @pre_dump + def generate_license_upload_date_gsi_fields(self, in_data, **kwargs): # noqa: ARG001 unused-argument + """Generate GSI fields for license upload date tracking""" + # If the update is related to an upload event, we generate the upload GSI fields to allow the system to + # query when certain uploads occurred + if in_data['updateType'] in LICENSE_UPLOAD_UPDATE_CATEGORIES: + # Extract YYYY-MM from createDate + upload_date = in_data['createDate'] + year_month = upload_date.strftime('%Y-%m') + + # Generate GSI PK: C#{compact}#J#{jurisdiction}#D#{YYYY-MM} + in_data['licenseUploadDateGSIPK'] = ( + f'C#{in_data["compact"].lower()}#J#{in_data["jurisdiction"].lower()}#D#{year_month}' + ) + + # Generate GSI SK: TIME#{epoch_timestamp}#LT#{licenseType}#PID#{providerId} + upload_epoch_time = int(upload_date.timestamp()) + license_type_abbr = config.license_type_abbreviations[in_data['compact']][in_data['licenseType']] + in_data['licenseUploadDateGSISK'] = ( + f'TIME#{upload_epoch_time}#LT#{license_type_abbr}#PID#{in_data["providerId"]}' + ) + return in_data + + @post_load + def drop_license_gsi_fields(self, in_data, **kwargs): # noqa: ARG001 unused-argument + """Drop the db-specific license GSI fields before returning loaded data""" + # only drop the field if it's present, else continue on + in_data.pop('licenseUploadDateGSIPK', None) + in_data.pop('licenseUploadDateGSISK', None) + return in_data + + @validates_schema + def validate_license_type(self, data, **kwargs): # noqa: ARG001 unused-argument + license_types = config.license_types_for_compact(data['compact']) + if data['licenseType'] not in license_types: + raise ValidationError({'licenseType': [f'Must be one of: {", ".join(license_types)}.']}) + + @validates_schema + def validate_investigation_details_present_if_investigation_status_updated(self, data, **kwargs): # noqa: ARG002 + """ + Require investigationDetails whenever update type is investigation + """ + if data['updateType'] == UpdateCategory.INVESTIGATION and not data.get('investigationDetails'): + raise ValidationError( + {'investigationDetails': ['This field is required when update was investigation type']} + ) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/privilege/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/privilege/__init__.py new file mode 100644 index 0000000000..c0583122fa --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/privilege/__init__.py @@ -0,0 +1,2 @@ +# Privilege record schemas (Dynamo storage) were removed; privileges under the multi-state license model +# are generated at API runtime. API response schemas remain in schema/privilege/api.py diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/privilege/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/privilege/api.py new file mode 100644 index 0000000000..f08f35e0de --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/privilege/api.py @@ -0,0 +1,84 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow.fields import List, Nested, Raw, String +from marshmallow.validate import Length + +from cc_common.data_model.schema.adverse_action.api import ( + AdverseActionGeneralResponseSchema, +) +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + InvestigationStatusField, + Jurisdiction, +) +from cc_common.data_model.schema.investigation.api import InvestigationGeneralResponseSchema + + +class PrivilegeGeneralResponseSchema(ForgivingSchema): + """ + Schema defining fields available to all staff users with only the 'readGeneral' permission. + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False) + providerId = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + adverseActions = List(Nested(AdverseActionGeneralResponseSchema, required=False, allow_none=False)) + investigations = List(Nested(InvestigationGeneralResponseSchema, required=False, allow_none=False)) + administratorSetStatus = ActiveInactive(required=True, allow_none=False) + status = ActiveInactive(required=True, allow_none=False) + # This field is only set if the privilege is under investigation + investigationStatus = InvestigationStatusField(required=False, allow_none=False) + + +class PrivilegeReadPrivateResponseSchema(ForgivingSchema): + """ + Schema defining fields available to staff users with the 'readPrivate' or higher permission. + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False) + providerId = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + adverseActions = List(Nested(AdverseActionGeneralResponseSchema, required=False, allow_none=False)) + investigations = List(Nested(InvestigationGeneralResponseSchema, required=False, allow_none=False)) + administratorSetStatus = ActiveInactive(required=True, allow_none=False) + status = ActiveInactive(required=True, allow_none=False) + # This field is only set if the privilege is under investigation + investigationStatus = InvestigationStatusField(required=False, allow_none=False) + + # these fields are specific to the read private role + dateOfBirth = Raw(required=False, allow_none=False) + ssnLastFour = String(required=False, allow_none=False, validate=Length(equal=4)) + + +class PrivilegePublicResponseSchema(ForgivingSchema): + """ + Privilege object fields, as seen by the public lookup endpoints. + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False) + providerId = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + administratorSetStatus = ActiveInactive(required=True, allow_none=False) + status = ActiveInactive(required=True, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/__init__.py new file mode 100644 index 0000000000..8f4cdf9d3d --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/__init__.py @@ -0,0 +1,130 @@ +# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition + +from datetime import date +from uuid import UUID + +from cc_common.data_model.schema.common import CCDataClass +from cc_common.data_model.schema.provider.record import ( + ProviderRecordSchema, + ProviderUpdateRecordSchema, +) + + +class ProviderData(CCDataClass): + """ + Class representing a Provider with getters for all properties. + """ + + # Define record schema at the class level + _record_schema = ProviderRecordSchema() + + # Require valid data when creating instances + _requires_data_at_construction = True + + @property + def providerId(self) -> UUID: + return self._data['providerId'] + + @property + def compact(self) -> str: + return self._data['compact'] + + @property + def licenseJurisdiction(self) -> str: + return self._data['licenseJurisdiction'] + + @property + def jurisdictionUploadedLicenseStatus(self) -> str: + return self._data['jurisdictionUploadedLicenseStatus'] + + @property + def jurisdictionUploadedCompactEligibility(self) -> str: + return self._data['jurisdictionUploadedCompactEligibility'] + + @property + def ssnLastFour(self) -> str: + return self._data['ssnLastFour'] + + @property + def givenName(self) -> str: + return self._data['givenName'] + + @property + def middleName(self) -> str | None: + return self._data.get('middleName') + + @property + def familyName(self) -> str: + return self._data['familyName'] + + @property + def suffix(self) -> str | None: + return self._data.get('suffix') + + @property + def dateOfExpiration(self) -> date: + return self._data['dateOfExpiration'] + + @property + def dateOfBirth(self) -> date: + return self._data['dateOfBirth'] + + @property + def birthMonthDay(self) -> str | None: + return self._data.get('birthMonthDay') + + @property + def encumberedStatus(self) -> str | None: + return self._data.get('encumberedStatus') + + @property + def compactEligibility(self) -> str: + return self._data['compactEligibility'] + + @property + def licenseStatus(self) -> str | None: + return self._data.get('licenseStatus') + + +class ProviderUpdateData(CCDataClass): + """ + Class representing a Provider Update with getters and setters for all properties. + Takes a dict as an argument to the constructor to avoid primitive obsession. + + Note: This class requires valid data when created - it cannot be instantiated empty + and populated later. + """ + + # Define the record schema at the class level + _record_schema = ProviderUpdateRecordSchema() + + # Require valid data when creating instances + _requires_data_at_construction = True + + @property + def updateType(self) -> str: + return self._data['updateType'] + + @property + def providerId(self) -> UUID: + return self._data['providerId'] + + @property + def compact(self) -> str: + return self._data['compact'] + + @property + def createDate(self) -> str: + return self._data['createDate'] + + @property + def previous(self) -> dict: + return self._data['previous'] + + @property + def updatedValues(self) -> dict: + return self._data['updatedValues'] + + @property + def removedValues(self) -> list[str] | None: + return self._data.get('removedValues') diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py new file mode 100644 index 0000000000..509d5c8591 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py @@ -0,0 +1,327 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow import ValidationError, validates_schema +from marshmallow.fields import Integer, List, Nested, Raw, String +from marshmallow.validate import Length, Range, Regexp + +from cc_common.data_model.schema.adverse_action.api import AdverseActionGeneralResponseSchema +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.common import CCRequestSchema +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + CompactEligibility, + Jurisdiction, +) +from cc_common.data_model.schema.license.api import ( + LicenseGeneralResponseSchema, + LicenseOpenSearchDocumentSchema, + LicensePublicResponseSchema, + LicenseReadPrivateResponseSchema, +) +from cc_common.data_model.schema.privilege.api import ( + PrivilegeGeneralResponseSchema, + PrivilegePublicResponseSchema, + PrivilegeReadPrivateResponseSchema, +) + +# Keys that indicate cross-index query attempts in OpenSearch DSL +# These are used by terms lookup, more_like_this, and other queries to reference external indices +_CROSS_INDEX_KEYS = frozenset({'index', '_index'}) + + +def _validate_no_cross_index_keys(obj, path: str = 'query') -> None: + """ + Recursively validate that an object does not contain cross-index lookup keys. + + This function traverses the query structure looking for keys that would indicate + an attempt to access data from other indices: + - 'index': Used in terms lookup queries to specify an external index + - '_index': Used in more_like_this queries to reference documents from other indices + + These keys should never appear in legitimate single-index queries against the + provider search index. + + :param obj: The object to validate (dict, list, or scalar) + :param path: The current path in the object for error messages + :raises ValidationError: If a cross-index key is found + """ + if isinstance(obj, dict): + for key, value in obj.items(): + if key in _CROSS_INDEX_KEYS: + raise ValidationError(f"Cross-index queries are not allowed. Found '{key}' at {path}.{key}") + _validate_no_cross_index_keys(value, path=f'{path}.{key}') + elif isinstance(obj, list): + for i, item in enumerate(obj): + _validate_no_cross_index_keys(item, path=f'{path}[{i}]') + # Scalar values (str, int, bool, None) are safe - we only check keys + + +class ProviderReadPrivateResponseSchema(ForgivingSchema): + """ + Provider object fields that are sanitized for users with the 'readPrivate' permission. + + This schema is intended to be used to filter from the database in order to remove all fields not defined here. + It should NEVER be used to load data into the database. Use the ProviderRecordSchema for that. + + This schema should be used by any endpoint that returns provider information to staff users with read private + permissions (ie the query provider and GET provider endpoints). + + Serialization direction: + Python -> load() -> API + """ + + providerId = Raw(required=True, allow_none=False) + type = String(required=True, allow_none=False) + + dateOfUpdate = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + # This date is determined by the license records uploaded by a state + # they do not include a timestamp, so we use the Date field type + dateOfExpiration = Raw(required=True, allow_none=False) + + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + + providerFamGivMid = String(required=False, allow_none=False, validate=Length(2, 400)) + providerDateOfUpdate = Raw(required=False, allow_none=False) + birthMonthDay = String(required=True, allow_none=False, validate=Regexp('^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}')) + + # these records are present when getting provider information from the GET endpoint + # so we check for them here and sanitize them if they are present + licenses = List(Nested(LicenseReadPrivateResponseSchema(), required=False, allow_none=False)) + privileges = List(Nested(PrivilegeReadPrivateResponseSchema(), required=False, allow_none=False)) + # list of all adverse action records, used by the disciplinary information table + adverseActions = List(Nested(AdverseActionGeneralResponseSchema(), required=False, allow_none=False)) + + # these fields are specific to the read private role + dateOfBirth = Raw(required=True, allow_none=False) + ssnLastFour = String(required=False, allow_none=False, validate=Length(equal=4)) + + +class ProviderGeneralResponseSchema(ForgivingSchema): + """ + Provider object fields that are sanitized for users with the 'readGeneral' permission. + + This schema is intended to be used to filter from the database in order to remove all fields not defined here. + It should NEVER be used to load data into the database. Use the ProviderRecordSchema for that. + + This schema should be used by any endpoint that returns provider information to staff users (ie the query provider + and GET provider endpoints). + + Serialization direction: + Python -> load() -> API + """ + + providerId = Raw(required=True, allow_none=False) + type = String(required=True, allow_none=False) + + dateOfUpdate = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + # This date is determined by the license records uploaded by a state + dateOfExpiration = Raw(required=True, allow_none=False) + + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + + providerFamGivMid = String(required=False, allow_none=False, validate=Length(2, 400)) + providerDateOfUpdate = Raw(required=False, allow_none=False) + birthMonthDay = String(required=True, allow_none=False, validate=Regexp('^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}')) + + # these records are present when getting provider information from the GET endpoint + # so we check for them here and sanitize them if they are present + licenses = List(Nested(LicenseGeneralResponseSchema(), required=False, allow_none=False)) + privileges = List(Nested(PrivilegeGeneralResponseSchema(), required=False, allow_none=False)) + # list of all adverse action records, used by the disciplinary information table + adverseActions = List(Nested(AdverseActionGeneralResponseSchema(), required=False, allow_none=False)) + + +class ProviderOpenSearchDocumentSchema(ProviderGeneralResponseSchema): + """ + Provider object fields for OpenSearch document indexing. + + Extends ProviderGeneralResponseSchema with license objects that include dateOfBirth, + enabling authorized staff users to search providers by date of birth. This schema + is used only for indexing into OpenSearch, not for API responses. + + Serialization direction: + Python -> load() -> OpenSearch document + """ + + licenses = List(Nested(LicenseOpenSearchDocumentSchema(), required=False, allow_none=False)) + + +class ProviderPublicResponseSchema(ForgivingSchema): + """ + Provider object fields that are sanitized for the public lookup endpoints. + + This schema is intended to be used to filter from the database in order to remove all fields not defined here. + It should NEVER be used to load data into the database. Use the ProviderRecordSchema for that. + + This schema should be used by any endpoint that returns provider information to the public lookup endpoints + (ie the public query provider and public GET provider endpoints). + + Serialization direction: + Python -> load() -> API + """ + + providerId = Raw(required=True, allow_none=False) + type = String(required=True, allow_none=False) + + dateOfUpdate = Raw(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + + # Unlike the JCC public provider search, which only returns privilege data for a provider,Social Workreturns both + # licenses and privileges and does not return any adverse action data. + licenses = List(Nested(LicensePublicResponseSchema(), required=False, allow_none=False)) + privileges = List(Nested(PrivilegePublicResponseSchema(), required=False, allow_none=False)) + + +class PublicLicenseSearchResponseSchema(ForgivingSchema): + """ + License object fields returned by the public query providers endpoint. + """ + + providerId = Raw(required=True, allow_none=False) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + licenseJurisdiction = String(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + licenseNumber = String(required=True, allow_none=False, validate=Length(1, 100)) + licenseEligibility = CompactEligibility(required=True, allow_none=False) + + +class QueryProvidersRequestSchema(CCRequestSchema): + """ + Schema for staff query providers requests. + + It corresponds to the V1QueryProvidersRequestModel in the API model. + + Serialization direction: + API -> load() -> Python + """ + + class QuerySchema(CCRequestSchema): + """ + Nested schema for the query object within the request. + """ + + providerId = String(required=False, allow_none=False, validate=Length(min=1)) + jurisdiction = Jurisdiction(required=False, allow_none=False) + givenName = String(required=False, allow_none=False, validate=Length(min=1, max=100)) + familyName = String(required=False, allow_none=False, validate=Length(min=1, max=100)) + licenseNumber = String(required=False, allow_none=False, validate=Length(min=1, max=100)) + + class PaginationSchema(ForgivingSchema): + """ + Nested schema for the pagination object within the request. + """ + + lastKey = String(required=False, allow_none=False, validate=Length(min=1, max=1024)) + pageSize = Integer(required=False, allow_none=False, validate=Range(min=5, max=100)) + + class SortingSchema(ForgivingSchema): + """ + Nested schema for the sorting object within the request. + """ + + key = String(required=False, allow_none=False) + direction = String(required=False, allow_none=False) + + query = Nested(QuerySchema, required=True, allow_none=False) + pagination = Nested(PaginationSchema, required=False, allow_none=False) + sorting = Nested(SortingSchema, required=False, allow_none=False) + + +class PublicQueryProvidersRequestSchema(CCRequestSchema): + """ + Request body for the public POST .../providers/query endpoint only. + + The query object allows only jurisdiction, givenName, familyName, and licenseNumber. + Pagination and sorting match QueryProvidersRequestSchema. + """ + + class PublicQuerySchema(CCRequestSchema): + """ + Nested schema for the query object within the request. + """ + + jurisdiction = Jurisdiction(required=False, allow_none=False) + givenName = String(required=False, allow_none=False, validate=Length(min=1, max=100)) + familyName = String(required=False, allow_none=False, validate=Length(min=1, max=100)) + licenseNumber = String(required=False, allow_none=False, validate=Length(min=1, max=100)) + + query = Nested(PublicQuerySchema, required=True, allow_none=False) + pagination = Nested(QueryProvidersRequestSchema.PaginationSchema, required=False, allow_none=False) + sorting = Nested(QueryProvidersRequestSchema.SortingSchema, required=False, allow_none=False) + + +class SearchProvidersRequestSchema(CCRequestSchema): + """ + Schema for advanced search providers requests. + + This schema is used to validate incoming requests to the advanced search providers API endpoint. + It accepts an OpenSearch DSL query body for flexible querying of the provider index. + + The request body closely mirrors OpenSearch DSL for pagination using `search_after`. + See: https://docs.opensearch.org/latest/search-plugins/searching-data/paginate/#the-search_after-parameter + + Serialization direction: + API -> load() -> Python + """ + + # The OpenSearch query body - we use Raw to allow the full flexibility of OpenSearch queries + query = Raw(required=True, allow_none=False) + + # Pagination parameters following OpenSearch DSL + # 'from' is a reserved word in Python, so we use 'from_' with data_key='from' + from_ = Integer(required=False, allow_none=False, data_key='from', validate=Range(min=0, max=9900)) + size = Integer(required=False, allow_none=False, validate=Range(min=1, max=100)) + + # Sort order - required when using search_after pagination + # Example: [{"providerId": "asc"}, {"dateOfUpdate": "desc"}] + sort = Raw(required=False, allow_none=False) + + # The search_after parameter for cursor-based pagination + # This should be the 'sort' values from the last hit of the previous page + # Example: ["provider-uuid-123", "2024-01-15T10:30:00Z"] + search_after = Raw(required=False, allow_none=False) + + @validates_schema + def validate_no_cross_index_queries(self, data, **kwargs): + """ + Validate that the query does not contain cross-index lookup attempts. + + This is a defense-in-depth security measure to prevent queries that attempt to access + data from other compact indices. The primary protection is the OpenSearch domain setting + `rest.action.multi.allow_explicit_index: false`, but this validation provides an + additional application-layer check. + + Dangerous patterns blocked: + - Terms lookup with external index: {"terms": {"field": {"index": "other_index", ...}}} + - More like this with external docs: {"more_like_this": {"like": [{"_index": "other_index"}]}} + """ + _validate_no_cross_index_keys(data.get('query', {})) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/record.py new file mode 100644 index 0000000000..ec9860ca54 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/provider/record.py @@ -0,0 +1,213 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from datetime import date +from urllib.parse import quote + +from marshmallow import post_dump, post_load, pre_dump, pre_load +from marshmallow.fields import UUID, AwareDateTime, Date, List, Nested, String +from marshmallow.validate import Length, Regexp + +from cc_common.config import config +from cc_common.data_model.schema.base_record import BaseRecordSchema, ForgivingSchema +from cc_common.data_model.schema.common import ( + ActiveInactiveStatus, + ChangeHashMixin, + CompactEligibilityStatus, + LicenseEncumberedStatusEnum, +) +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + CompactEligibility, + Jurisdiction, + LicenseEncumberedStatusField, + UpdateType, +) +from cc_common.data_model.update_tier_enum import UpdateTierEnum + + +@BaseRecordSchema.register_schema('provider') +class ProviderRecordSchema(BaseRecordSchema): + """ + Schema for provider records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'provider' + + # Provided fields + providerId = UUID(required=True, allow_none=False) + + compact = Compact(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + + # Calculated fields + licenseStatus = ActiveInactive(required=True, allow_none=False) + compactEligibility = CompactEligibility(required=True, allow_none=False) + + # optional field for setting encumbrance status + encumberedStatus = LicenseEncumberedStatusField(required=False, allow_none=False) + + ssnLastFour = String(required=True, allow_none=False) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + # these dates are determined by the license records uploaded by a state + # they do not include a timestamp, so we use the Date field type + dateOfExpiration = Date(required=True, allow_none=False) + dateOfBirth = Date(required=True, allow_none=False) + + # Generated fields + birthMonthDay = String(required=False, allow_none=False, validate=Regexp('^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}')) + providerFamGivMid = String(required=False, allow_none=False, validate=Length(2, 400)) + providerDateOfUpdate = AwareDateTime(required=True, allow_none=False) + + @pre_load + def _calculate_statuses(self, in_data, **_kwargs): + """Determine the statuses of the record based on the expiration date""" + in_data = self._calculate_license_status(in_data) + return self._calculate_compact_eligibility(in_data) + + def _calculate_license_status(self, in_data, **_kwargs): + """Determine the status of the license based on the expiration date""" + in_data['licenseStatus'] = ( + ActiveInactiveStatus.ACTIVE + if ( + in_data['jurisdictionUploadedLicenseStatus'] == ActiveInactiveStatus.ACTIVE + and date.fromisoformat(in_data['dateOfExpiration']) >= config.expiration_resolution_date + and in_data.get('encumberedStatus', LicenseEncumberedStatusEnum.UNENCUMBERED) + == LicenseEncumberedStatusEnum.UNENCUMBERED + ) + else ActiveInactiveStatus.INACTIVE + ) + return in_data + + def _calculate_compact_eligibility(self, in_data, **_kwargs): + """ + Providers are only eligible for the compact if their home jurisdiction says they are, none of their licenses + are encumbered, and their license is active. + """ + in_data['compactEligibility'] = ( + CompactEligibilityStatus.ELIGIBLE + if ( + in_data['jurisdictionUploadedCompactEligibility'] == CompactEligibilityStatus.ELIGIBLE + and in_data['licenseStatus'] == ActiveInactiveStatus.ACTIVE + and in_data.get('encumberedStatus', LicenseEncumberedStatusEnum.UNENCUMBERED) + == LicenseEncumberedStatusEnum.UNENCUMBERED + ) + else CompactEligibilityStatus.INELIGIBLE + ) + return in_data + + @pre_dump + def remove_calculated_fields(self, in_data, **_kwargs): + """Remove the calculated status fields before dumping to the database""" + in_data.pop('status', None) + in_data.pop('licenseStatus', None) + in_data.pop('compactEligibility', None) + return in_data + + @pre_dump + def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['pk'] = f'{in_data["compact"]}#PROVIDER#{in_data["providerId"]}' + in_data['sk'] = f'{in_data["compact"]}#PROVIDER' + return in_data + + @pre_dump + def populate_birth_month_day(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['birthMonthDay'] = in_data['dateOfBirth'].strftime('%m-%d') + return in_data + + @pre_dump + def populate_prov_date_of_update(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['providerDateOfUpdate'] = in_data['dateOfUpdate'] + return in_data + + @post_load + def drop_prov_date_of_update(self, in_data, **kwargs): # noqa: ARG001 unused-argument + del in_data['providerDateOfUpdate'] + return in_data + + @pre_dump + def populate_fam_giv_mid(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['providerFamGivMid'] = '#'.join( + # make names on GSI lowercase for case-insensitive search + ( + quote(in_data['familyName'].lower()), + quote(in_data['givenName'].lower()), + quote(in_data.get('middleName', '').lower()), + ), + ) + return in_data + + @post_load + def drop_fam_giv_mid(self, in_data, **kwargs): # noqa: ARG001 unused-argument + del in_data['providerFamGivMid'] + return in_data + + +class ProviderUpdatePreviousRecordSchema(ForgivingSchema): + """ + A snapshot of a previous state of a provider record + + Serialization direction: + DB -> load() -> Python + """ + + providerId = UUID(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) + jurisdictionUploadedLicenseStatus = ActiveInactive(required=True, allow_none=False) + jurisdictionUploadedCompactEligibility = CompactEligibility(required=True, allow_none=False) + encumberedStatus = LicenseEncumberedStatusField(required=False, allow_none=False) + ssnLastFour = String(required=True, allow_none=False) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + middleName = String(required=False, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + suffix = String(required=False, allow_none=False, validate=Length(1, 100)) + dateOfExpiration = Date(required=True, allow_none=False) + dateOfBirth = Date(required=True, allow_none=False) + dateOfUpdate = AwareDateTime(required=True, allow_none=False) + + +@BaseRecordSchema.register_schema('providerUpdate') +class ProviderUpdateRecordSchema(BaseRecordSchema, ChangeHashMixin): + """ + Schema for provider update history records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'providerUpdate' + + updateType = UpdateType(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + previous = Nested(ProviderUpdatePreviousRecordSchema, required=True, allow_none=False) + # this tracks when the update record was created + createDate = AwareDateTime(required=True, allow_none=False) + # We'll allow any fields that can show up in the previous field to be here as well, but none are required + updatedValues = Nested(ProviderUpdatePreviousRecordSchema(partial=True), required=True, allow_none=False) + # List of field names that were present in the previous record but removed in the update + removedValues = List(String(), required=False, allow_none=False) + + @post_dump # Must be _post_ dump so we have values that are more easily hashed + def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument + in_data['pk'] = f'{in_data["compact"]}#PROVIDER#{in_data["providerId"]}' + # This needs to include an iso formatted datetime string and a hash of the changes + # to the record. We'll use the createDate and the hash of the updatedValues + # field for this. + # Provider update records are considered a tier 2 update. Privilege updates are tier 1 because they are accessed + # most frequently. Provider update records are not generated often, so it is more performant to place them at + # tier 2, with license updates being the last tier 3. + change_hash = self.hash_changes(in_data) + in_data['sk'] = ( + f'{in_data["compact"]}#UPDATE#{UpdateTierEnum.TIER_TWO}#provider/{in_data["createDate"]}/{change_hash}' + ) + return in_data diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py new file mode 100644 index 0000000000..010a8bb5a2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py @@ -0,0 +1,120 @@ +# ruff: noqa: N801, N815 invalid-name +from marshmallow import Schema, post_dump, pre_load +from marshmallow.fields import Boolean, Dict, Nested, Raw, String +from marshmallow.validate import Length, OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.fields import Compact + + +class UserAttributesAPISchema(Schema): + email = String(required=True, allow_none=False, validate=Length(1, 100)) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + + +class CompactActionPermissionAPISchema(Schema): + actions = Dict( + keys=String(), # Keys are actions + values=Boolean(), + required=False, + allow_none=False, + ) + + +class CompactPermissionsAPISchema(CompactActionPermissionAPISchema): + jurisdictions = Dict( + keys=String(validate=OneOf(config.jurisdictions)), # Keys are jurisdictions + values=Nested(CompactActionPermissionAPISchema(), required=True, allow_none=False), + dump_default={}, + required=True, + allow_none=False, + ) + + +class UserAPISchema(Schema): + """Schema to transform from the API-facing user data format (load) to the internal format (dump) that is ready for + serialization to the DynamoDB table. + + Note: This schema is not intended for actual validation, only serialization/deserialization. + """ + + type = String(required=True, allow_none=False, validate=OneOf(['user'])) + userId = Raw(required=True, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus])) + dateOfUpdate = Raw(required=True, allow_none=False) + attributes = Nested(UserAttributesAPISchema(), required=True, allow_none=False) + permissions = Dict( + keys=String(validate=OneOf(config.compacts)), # Key is one compact + values=Nested(CompactPermissionsAPISchema(), required=True, allow_none=False), + validate=Length(equal=1), + ) + + @pre_load + def transform_to_api_permissions(self, data, **kwargs): # noqa: ARG002 unused-kwargs + """Transform compact permissions from database format into API format""" + compact = data.pop('compact') + compact_permissions = data['permissions'] + + user_permissions = {compact: {}} + + compact_actions = compact_permissions.get('actions') + if compact_actions is not None: + # Set to dict of '{action}: True' items + user_permissions[compact]['actions'] = {action: True for action in compact_permissions['actions']} + jurisdictions = compact_permissions['jurisdictions'] + if jurisdictions is not None: + # Transform jurisdiction permissions + user_permissions[compact]['jurisdictions'] = {} + for jurisdiction, jurisdiction_permissions in jurisdictions.items(): + # Set to dict of '{action}: True' items + user_permissions[compact]['jurisdictions'][jurisdiction] = { + 'actions': {action: True for action in jurisdiction_permissions}, + } + data['permissions'] = user_permissions + + return data + + @post_dump # Note _post_ dump, so after any type conversions happen, in this case + def transform_to_dynamo_permissions(self, data, **kwargs): # noqa: ARG002 unused-kwargs + # { "permissions": { "socw": { ... } } } -> { "compact": "socw", "permissions": { ... } } + for compact, compact_permissions in data['permissions'].items(): + data['permissions'] = compact_permissions + data['compact'] = compact + + # { "actions": { "read": True } } -> { "actions": { "read" } } + data['permissions']['actions'] = { + key for key, value in data['permissions'].get('actions', {}).items() if value is True + } + + # { "oh": { "actions": { "write": True } } } -> { "oh": { "write" } } + for jurisdiction, jurisdiction_permissions in data['permissions']['jurisdictions'].items(): + data['permissions']['jurisdictions'][jurisdiction] = { + key for key, value in jurisdiction_permissions.get('actions', {}).items() if value is True + } + + return data + + +class UserMergedResponseSchema(ForgivingSchema): + """ + Schema for merged user response data from the /me endpoint. + + This schema validates the merged user data that combines multiple user records + across different compacts into a single response object. + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False, validate=OneOf(['user'])) + userId = Raw(required=True, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus])) + dateOfUpdate = Raw(required=True, allow_none=False) + attributes = Nested(UserAttributesAPISchema(), required=True, allow_none=False) + permissions = Dict( + keys=Compact(), # Key is one compact + values=Nested(CompactPermissionsAPISchema(), required=True, allow_none=False), + ) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py new file mode 100644 index 0000000000..715fccf877 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py @@ -0,0 +1,74 @@ +# ruff: noqa: N801, N815 invalid-name +from marshmallow import Schema, post_dump, post_load, pre_dump +from marshmallow.fields import UUID, Dict, Nested, String +from marshmallow.validate import Length, OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.fields import Set + + +class CompactPermissionsRecordSchema(Schema): + actions = Set(String, required=False, allow_none=False) + jurisdictions = Dict( + keys=String(validate=OneOf(config.jurisdictions)), + values=Set(String, required=False, allow_none=False), + dump_default={}, + required=True, + allow_none=False, + ) + + @post_dump + def drop_empty_actions(self, data, **kwargs): # noqa: ARG002 unused-kwargs + """ + DynamoDB doesn't like empty sets, so we will make a point to drop an actions field entirely, + if it is empty. + """ + if not data.get('actions', {}): + data.pop('actions', None) + empty_jurisdictions = [jurisdiction for jurisdiction, actions in data['jurisdictions'].items() if not actions] + for jurisdiction in empty_jurisdictions: + del data['jurisdictions'][jurisdiction] + return data + + +class UserAttributesRecordSchema(Schema): + email = String(required=True, allow_none=False, validate=Length(1, 100)) + givenName = String(required=True, allow_none=False, validate=Length(1, 100)) + familyName = String(required=True, allow_none=False, validate=Length(1, 100)) + + +@BaseRecordSchema.register_schema('user') +class UserRecordSchema(BaseRecordSchema): + _record_type = 'user' + + # Provided fields + userId = UUID(required=True, allow_none=False) + attributes = Nested(UserAttributesRecordSchema(), required=True, allow_none=False) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + permissions = Nested(CompactPermissionsRecordSchema(), required=True, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus])) + + # Generated fields + famGiv = String(required=True, allow_none=False) + + @pre_dump + def generate_pk(self, in_data, **kwargs): # noqa: ARG002 unused-kwargs + in_data['pk'] = f'USER#{in_data["userId"]}' + return in_data + + @pre_dump + def generate_sk(self, in_data, **kwargs): # noqa: ARG002 unused-kwargs + in_data['sk'] = f'COMPACT#{in_data["compact"]}' + return in_data + + @pre_dump + def generate_fam_giv(self, in_data, **kwargs): # noqa: ARG002 unused-kwargs + in_data['famGiv'] = '#'.join([in_data['attributes']['familyName'], in_data['attributes']['givenName']]) + return in_data + + @post_load + def drop_fam_giv(self, in_data, **kwargs): # noqa: ARG002 unused-kwargs + del in_data['famGiv'] + return in_data diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/update_tier_enum.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/update_tier_enum.py new file mode 100644 index 0000000000..a05fd8fe2b --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/update_tier_enum.py @@ -0,0 +1,40 @@ +from enum import StrEnum + + +class UpdateTierEnum(StrEnum): + """ + Enum for update record tiers in the sort key hierarchy. + + DynamoDB sort keys are treated as numeric values, even if the key is a string. + This means we can perform comparison operations on string sort keys, such as less than (lt) + and grab records within a certain range. + + To reduce risk that massive invalid updates from a jurisdiction will cause the system to crash + when loading provider data, we migrated the sort keys of our update records to follow this + tier based pattern, which will allow us to query for update records only as needed. + + Update records are organized into tiers to enable efficient range queries. + Because all the primary provider records are prefixed under a common `{compact}#PROVIDER` prefix, + which is lexicographically less than the `{compact}#UPDATE` prefix, using the lt condition with the + UPDATE prefix will grab all the update records up to the specified tier and all primary records under + the PROVIDER prefix. + + Tier structure in sort keys: + - Tier 1: {compact}#UPDATE#1#privilege/... (Privilege updates) + - Tier 2: {compact}#UPDATE#2#provider/... (Provider updates) + - Tier 3: {compact}#UPDATE#3#license/... (License updates) + + Query patterns: + - TIER_ONE: Fetches privilege updates only + Query: Key('sk').lt('{compact}#UPDATE#2') + + - TIER_TWO: Fetches privilege + provider updates + Query: Key('sk').lt('{compact}#UPDATE#3') + + - TIER_THREE: Fetches all updates (privilege + provider + license) + Query: Key('sk').lt('{compact}#UPDATE#4') + """ + + TIER_ONE = '1' # Privilege updates only + TIER_TWO = '2' # Privilege + Provider updates + TIER_THREE = '3' # All updates (Privilege + Provider + License) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py new file mode 100644 index 0000000000..fcbbfceab3 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -0,0 +1,442 @@ +from collections.abc import Iterable +from enum import StrEnum +from secrets import token_hex + +from boto3.dynamodb.conditions import Attr, Key +from botocore.exceptions import ClientError + +from cc_common.config import _Config, logger +from cc_common.data_model.query_paginator import paginated_query +from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.user.record import ( + CompactPermissionsRecordSchema, + UserAttributesRecordSchema, + UserRecordSchema, +) +from cc_common.exceptions import CCInternalException, CCInvalidRequestException, CCNotFoundException +from cc_common.utils import get_sub_from_user_attributes + + +class UserStatus(StrEnum): + # These top four should not happen for our user clients + ARCHIVED = 'ARCHIVED' # Not explained in Cognito documentation + UNCONFIRMED = 'UNCONFIRMED' # User has been created but not confirmed. + EXTERNAL_PROVIDER = 'EXTERNAL_PROVIDER' # User signed in with a third-party IdP. + UNKNOWN = 'UNKNOWN' # User status is unknown. + + # User has been confirmed + CONFIRMED = 'CONFIRMED' + # User is confirmed, but the user must request a code and reset their password before they can sign in. + RESET_REQUIRED = 'RESET_REQUIRED' + # The user is confirmed and the user can sign in using a temporary password, but on first sign-in, the user must + # change their password to a new value before doing anything else. + FORCE_CHANGE_PASSWORD = 'FORCE_CHANGE_PASSWORD' # noqa: S105 + + +class UserClient: + """Client interface for license data dynamodb queries""" + + def __init__(self, config: _Config): + self.config = config + self.schema = UserRecordSchema() + self.user_attributes_schema = UserAttributesRecordSchema() + self.compact_permissions_schema = CompactPermissionsRecordSchema() + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_user(self, *, user_id: str, dynamo_pagination: dict, scan_forward: bool = True) -> list[dict]: + resp = self.config.users_table.query( + KeyConditionExpression=Key('pk').eq(f'USER#{user_id}'), + ScanIndexForward=scan_forward, + **dynamo_pagination, + ) + if not resp.get('Items', []): + raise CCNotFoundException('User not found') + return resp + + def get_user_in_compact(self, *, compact: str, user_id: str): + user = self.config.users_table.get_item(Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}).get('Item') + + if user is None: + raise CCNotFoundException('User not found') + return self.schema.load(user) + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_users_sorted_by_family_name( + self, + *, + compact: str, + dynamo_pagination: dict, + jurisdictions: Iterable[str] = None, + scan_forward: bool = True, + ): + """Get users with permissions in the provided compact, sorted by family name + :param compact: The compact to filter by + :param dynamo_pagination: DynamoDB query pagination fields + :param jurisdictions: List of jurisdiction codes to filter by + :param scan_forward: Whether to scan forward (True) or backward (False) + """ + logger.info('Getting staff users by family name') + + filter_expression = None + if jurisdictions: + # Form an attribute_exists() OR attribute_exists() OR ... expression for each jurisdiction + iter_jurisdictions = iter(jurisdictions) + jurisdiction = next(iter_jurisdictions) + filter_expression = Attr(f'permissions.jurisdictions.{jurisdiction}').exists() + for jurisdiction in iter_jurisdictions: + filter_expression = filter_expression | Attr(f'permissions.jurisdictions.{jurisdiction}').exists() + return self.config.users_table.query( + IndexName=self.config.fam_giv_index_name, + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('sk').eq(f'COMPACT#{compact}'), + ScanIndexForward=scan_forward, + **({'FilterExpression': filter_expression} if filter_expression is not None else {}), + **dynamo_pagination, + ) + + def update_user_permissions( + self, + *, + compact: str, + user_id: str, + compact_action_additions: set = None, + compact_action_removals: set = None, + jurisdiction_action_additions: dict = None, + jurisdiction_action_removals: dict = None, + ): + """Update the provided user's permissions + :param str compact: The compact the user's permissions are within + :param str user_id: The user to update + :param set compact_action_additions: Set of compact actions to add to the user ('read' or 'admin') + :param set compact_action_removals: Set of compact actions to remove from the user ('read' or 'admin') + :param dict jurisdiction_action_additions: Dict of jurisdiction actions to add to the user. + Keys are the jurisdiction codes, values are sets of actions to add ('write' or 'admin') + :param dict jurisdiction_action_removals: Dict of jurisdiction actions to remove from the user. + Keys are the jurisdiction codes, values are sets of actions to remove ('write' or 'admin') + + A given compact's permissions record looks something like: + + ```json + { + "permissions": { + "actions": { "admin", "readPrivate" }, + "jurisdictions": { + "oh": { + "actions": { "admin", "write", "readPrivate" } + } + } + } + } + ``` + """ + logger.info('Updating staff user permissions', user_id=user_id) + + # Creating a mutable collection so the handlers can add their collected changes + update_expression_parts = {'add': [], 'delete': []} + + # DynamoDB does not support both ADD and DELETE on the same String Set in a single UpdateItem call, so we will + # split additions and removals into two calls to prevent a conflict. + resp = self._handle_user_permission_additions( + user_id=user_id, + compact=compact, + update_expression_parts=update_expression_parts['add'], + compact_action_additions=compact_action_additions, + jurisdiction_action_additions=jurisdiction_action_additions, + ) + resp = ( + self._handle_user_permission_removals( + user_id=user_id, + compact=compact, + update_expression_parts=update_expression_parts['delete'], + compact_action_removals=compact_action_removals, + jurisdiction_action_removals=jurisdiction_action_removals, + ) + or resp + ) + + if not (update_expression_parts['add'] or update_expression_parts['delete']): + logger.warning('No changes provided for update_user_permissions') + raise CCInvalidRequestException('No changes requested') + + return self.schema.load(resp['Attributes']) + + def _handle_user_permission_additions( + self, + *, + user_id: str, + compact: str, + update_expression_parts: list, + compact_action_additions: set, + jurisdiction_action_additions: dict, + ) -> dict | None: + expression_attribute_names = {} + expression_attribute_values = {} + + if compact_action_additions: + update_expression_parts.append('#permissions.#actions :addActions') + expression_attribute_names['#permissions'] = 'permissions' + expression_attribute_names['#actions'] = 'actions' + expression_attribute_values[':addActions'] = compact_action_additions + + if jurisdiction_action_additions: + for jurisdiction, actions in jurisdiction_action_additions.items(): + expression_attribute_names['#permissions'] = 'permissions' + expression_attribute_names['#jurisdictions'] = 'jurisdictions' + expression_attribute_names[f'#{jurisdiction}'] = jurisdiction + + # If this is not their first action, we simply add to the set + update_expression_parts.append( + f'#permissions.#jurisdictions.#{jurisdiction} :{jurisdiction}AddActions', + ) + expression_attribute_values[f':{jurisdiction}AddActions'] = actions + + if update_expression_parts: + update_expression = 'ADD ' + ', '.join(update_expression_parts) + + try: + return self.config.users_table.update_item( + Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, + UpdateExpression=update_expression, + ExpressionAttributeNames=expression_attribute_names, + ExpressionAttributeValues=expression_attribute_values, + ReturnValues='ALL_NEW', + ) + except ClientError as e: + if e.response['Error']['Code'] == 'ValidationException': + # This error occurs when the document path is invalid, which happens when the user doesn't exist + raise CCNotFoundException('User not found') from e + raise + + return None + + def _handle_user_permission_removals( + self, + *, + user_id: str, + compact: str, + update_expression_parts: list, + compact_action_removals: set, + jurisdiction_action_removals: dict, + ) -> dict | None: + expression_attribute_names = {} + expression_attribute_values = {} + + if compact_action_removals: + update_expression_parts.append('#permissions.#actions :deleteActions') + expression_attribute_names['#permissions'] = 'permissions' + expression_attribute_names['#actions'] = 'actions' + expression_attribute_values[':deleteActions'] = compact_action_removals + + if jurisdiction_action_removals: + for jurisdiction, actions in jurisdiction_action_removals.items(): + update_expression_parts.append( + f'#permissions.#jurisdictions.#{jurisdiction} :{jurisdiction}DeleteActions', + ) + expression_attribute_names['#permissions'] = 'permissions' + expression_attribute_names['#jurisdictions'] = 'jurisdictions' + expression_attribute_names[f'#{jurisdiction}'] = jurisdiction + expression_attribute_values[f':{jurisdiction}DeleteActions'] = actions + + if update_expression_parts: + update_expression = 'DELETE ' + ', '.join(update_expression_parts) + + return self.config.users_table.update_item( + Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, + UpdateExpression=update_expression, + ExpressionAttributeNames=expression_attribute_names, + ExpressionAttributeValues=expression_attribute_values, + ReturnValues='ALL_NEW', + ) + + return None + + def update_user_attributes(self, *, user_id: str, attributes: dict): + """Update the provided user's attributes + :param str user_id: The user to update + :param dict attributes: Dict of user attributes to update. + Keys are the attribute names, values are the attribute values + ```json + { + "attributes": { + "email": "justin@example.com", + "familyName": "Justin", + "givenName": "Case" + } + } + ``` + """ + logger.info('Updating staff user attributes', user_id=user_id) + + update_expression_parts = [] + expression_attribute_names = {} + expression_attribute_values = {} + + for attr_name, attr_value in attributes.items(): + update_expression_parts.append(f'attributes.#{attr_name} = :{attr_name}') + expression_attribute_names[f'#{attr_name}'] = attr_name + expression_attribute_values[f':{attr_name}'] = attr_value + + update_expression = 'SET ' + ', '.join(update_expression_parts) + + records = self.get_user(user_id=user_id)['items'] + compacts = {record['compact'] for record in records} + + # We'll just serially update each of the user's records, since we realistically only + # expect users to have two or three. If latency gets excessive, we can refactor. + records = [] + for compact in compacts: + resp = self.config.users_table.update_item( + Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, + UpdateExpression=update_expression, + ExpressionAttributeNames=expression_attribute_names, + ExpressionAttributeValues=expression_attribute_values, + ReturnValues='ALL_NEW', + ) + records.append(resp['Attributes']) + return self.schema.load(records, many=True) + + def create_user(self, compact: str, attributes: dict, permissions: dict): + """Create a new Cognito user and DB record with the given attributes and permissions + :param str compact: The compact the user will have permissions in + :param dict attributes: The user attributes + :param dict permissions: The permissions for the user + :return: + """ + logger.info('Creating staff user', attributes=attributes) + attributes = self.user_attributes_schema.load(attributes) + permissions = self.compact_permissions_schema.load(permissions) + + try: + resp = self.config.cognito_client.admin_create_user( + UserPoolId=self.config.user_pool_id, + Username=attributes['email'], + # Email will be the only attribute we actually manage in Cognito + UserAttributes=[ + {'Name': 'email', 'Value': attributes['email']}, + {'Name': 'email_verified', 'Value': 'True'}, + ], + DesiredDeliveryMediums=['EMAIL'], + ) + user_id = get_sub_from_user_attributes(resp['User']['Attributes']) + except ClientError as e: + if e.response['Error']['Code'] == 'UsernameExistsException': + # If the user already exists, look them up + resp = self.config.cognito_client.admin_get_user( + UserPoolId=self.config.user_pool_id, + Username=attributes['email'], + ) + user_id = get_sub_from_user_attributes(resp['UserAttributes']) + + # If the user was previously disabled, re-enable them + if not resp.get('Enabled', True): + logger.info('Re-enabling previously disabled user', user_id=user_id, email=attributes['email']) + self.config.cognito_client.admin_enable_user( + UserPoolId=self.config.user_pool_id, Username=attributes['email'] + ) + # resend the invitation to the user after they have been re-enabled to force them to reset + # credentials + self.reinvite_user(email=attributes['email']) + else: + raise + + try: + user = self.schema.dump( + { + 'userId': user_id, + 'compact': compact, + 'attributes': attributes, + 'permissions': permissions, + 'status': StaffUserStatus.INACTIVE.value, + }, + ) + # If the user doesn't already exist, add them + self.config.users_table.put_item( + Item=user, + ConditionExpression=Attr('pk').not_exists() & Attr('sk').not_exists(), + ) + user = self.schema.load(user) + except ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + # The user record already exists - we'll update the existing record permissions and ignore attributes + compact_permissions = permissions.get('actions', set()) + jurisdiction_permissions = permissions.get('jurisdictions', {}) + user = self.update_user_permissions( + compact=compact, + user_id=user_id, + compact_action_additions=compact_permissions, + jurisdiction_action_additions=jurisdiction_permissions, + ) + else: + raise + return user + + def delete_user(self, *, compact: str, user_id: str) -> None: + """ + Delete a staff user's compact permissions record from DynamoDB + :param str compact: The compact the user has permissions in + :param str user_id: The user's ID + """ + try: + # We add a ConditionExpression to force this operation to _not_ be idempotent. We want an exception + # if the user's record doesn't exist. + self.config.users_table.delete_item( + Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, ConditionExpression=Attr('pk').exists() + ) + logger.info('deleted user record from staff users table', user_id=user_id) + except ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + raise CCNotFoundException('User not found') from e + return + + def reinvite_user(self, *, email: str) -> None: + """ + Reinvite a staff user by resetting their password and resending their invite email + :param str email: The user's email address + """ + try: + # Check their current status + user_data = self.config.cognito_client.admin_get_user( + UserPoolId=self.config.user_pool_id, + Username=email, + ) + + # If they're in CONFIRMED state, we need to reset their password first + if user_data['UserStatus'] in (UserStatus.CONFIRMED, UserStatus.RESET_REQUIRED): + self.config.cognito_client.admin_set_user_password( + UserPoolId=self.config.user_pool_id, + Username=email, + # We need to reset their password, but they will never use it, so we + # just need to set it to something random. Note that this value should not be referenced + # outside of this function, as it is a real password and we want it to be cleaned up + # by the garbage collector, as soon as possible. + # These passwords require at least one number, one uppercase, and one lowercase letter, so we add + # the prefix to meet these requirements + Password='!1Ha' + token_hex(45), + Permanent=False, + ) + # If the user is in any unexpected state, we'll raise an exception + elif user_data['UserStatus'] != UserStatus.FORCE_CHANGE_PASSWORD: + logger.error( + 'User is in unexpected state', + user_id=get_sub_from_user_attributes(user_data['UserAttributes']), + user_status=user_data['UserStatus'], + email=email, + user_data=user_data, + ) + raise CCInternalException(f'User is in unexpected state: {user_data["UserStatus"]}') + except ClientError as e: + if e.response['Error']['Code'] == 'UserNotFoundException': + raise CCNotFoundException('User not found') from e + raise + + # Now resend the invite + try: + self.config.cognito_client.admin_create_user( + UserPoolId=self.config.user_pool_id, + Username=email, + MessageAction='RESEND', + ) + except ClientError as e: + if e.response['Error']['Code'] == 'UserNotFoundException': + raise CCNotFoundException('User not found') from e + raise diff --git a/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py b/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py new file mode 100644 index 0000000000..10cc4feeaa --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py @@ -0,0 +1,408 @@ +import json +from dataclasses import dataclass +from datetime import date +from typing import Any, Protocol +from uuid import UUID + +import boto3 +from aws_lambda_powertools.logging import Logger + +from cc_common.exceptions import CCInternalException + + +@dataclass +class EncumbranceNotificationTemplateVariables: + """ + Template variables for encumbrance notification emails. + """ + + provider_first_name: str + provider_last_name: str + encumbered_jurisdiction: str + license_type: str + effective_date: date + provider_id: UUID | None = None + + +@dataclass +class InvestigationNotificationTemplateVariables: + """ + Template variables for investigation notification emails. + """ + + provider_first_name: str + provider_last_name: str + investigation_jurisdiction: str + license_type: str + provider_id: UUID + + +@dataclass +class HomeJurisdictionChangeNotificationTemplateVariables: + """ + Template variables for license home state change notification emails. + """ + + provider_first_name: str + provider_last_name: str + former_jurisdiction: str + current_jurisdiction: str + license_type: str + provider_id: UUID + + +class JurisdictionNotificationMethod(Protocol): + """Protocol for Jurisdiction encumbrance notification methods.""" + + def __call__( + self, *, compact: str, jurisdiction: str, template_variables: EncumbranceNotificationTemplateVariables + ) -> dict[str, Any]: ... + + +class EmailServiceClient: + """ + Client for sending email notifications through the email notification service lambda. + This class abstracts the lambda client and provides a clean interface for sending emails. + """ + + def __init__(self, lambda_client: boto3.client, email_notification_service_lambda_name: str, logger: Logger): + """ + Initialize the EmailServiceClient. + + :param lambda_client: boto3 lambda client. + :param email_notification_service_lambda_name: Name of the email notification service lambda. + """ + self._lambda_client = lambda_client + self._email_notification_service_lambda_name = email_notification_service_lambda_name + self._logger = logger + + def _invoke_lambda(self, payload: dict[str, Any]) -> dict[str, Any]: + """ + Invoke the email notification service lambda with the given payload. + + :param payload: Payload to send to the lambda + :return: Response from the lambda + :raises CCInternalException: If the lambda invocation fails + """ + if not self._email_notification_service_lambda_name: + raise CCInternalException('Email notification service lambda name not set') + + try: + response = self._lambda_client.invoke( + FunctionName=self._email_notification_service_lambda_name, + InvocationType='RequestResponse', + Payload=json.dumps(payload), + ) + + if response.get('FunctionError'): + error_message = f'Failed to send email notification: {response.get("FunctionError")}' + self._logger.error(error_message, payload=payload) + raise CCInternalException(error_message) + + return response + except Exception as e: + error_message = f'Error invoking email notification service lambda: {str(e)}' + self._logger.error(error_message, payload=payload, exception=str(e)) + raise CCInternalException(error_message) from e + + def send_license_encumbrance_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: EncumbranceNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a license encumbrance notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('Provider ID is required for state notification emails') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'licenseEncumbranceStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'encumberedJurisdiction': template_variables.encumbered_jurisdiction, + 'licenseType': template_variables.license_type, + 'effectiveStartDate': template_variables.effective_date.strftime('%B %d, %Y'), + }, + } + return self._invoke_lambda(payload) + + def send_license_encumbrance_lifting_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: EncumbranceNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a license encumbrance lifting notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('Provider ID is required for state notification emails') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'licenseEncumbranceLiftingStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'liftedJurisdiction': template_variables.encumbered_jurisdiction, + 'licenseType': template_variables.license_type, + 'effectiveLiftDate': template_variables.effective_date.strftime('%B %d, %Y'), + }, + } + return self._invoke_lambda(payload) + + def send_privilege_encumbrance_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: EncumbranceNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a privilege encumbrance notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('Provider ID is required for state notification emails.') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'privilegeEncumbranceStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'encumberedJurisdiction': template_variables.encumbered_jurisdiction, + 'licenseType': template_variables.license_type, + 'effectiveStartDate': template_variables.effective_date.strftime('%B %d, %Y'), + }, + } + return self._invoke_lambda(payload) + + def send_privilege_encumbrance_lifting_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: EncumbranceNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a privilege encumbrance lifting notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('Provider ID is required for state notification emails.') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'privilegeEncumbranceLiftingStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'liftedJurisdiction': template_variables.encumbered_jurisdiction, + 'licenseType': template_variables.license_type, + 'effectiveLiftDate': template_variables.effective_date.strftime('%B %d, %Y'), + }, + } + return self._invoke_lambda(payload) + + def send_license_investigation_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: InvestigationNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a license investigation notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('provider_id must be provided for state notifications') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'licenseInvestigationStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'investigationJurisdiction': template_variables.investigation_jurisdiction, + 'licenseType': template_variables.license_type, + }, + } + return self._invoke_lambda(payload) + + def send_license_investigation_closed_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: InvestigationNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a license investigation closed notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('provider_id must be provided for state notifications') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'licenseInvestigationClosedStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'investigationJurisdiction': template_variables.investigation_jurisdiction, + 'licenseType': template_variables.license_type, + }, + } + return self._invoke_lambda(payload) + + def send_privilege_investigation_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: InvestigationNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a privilege investigation notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('provider_id must be provided for state notifications') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'privilegeInvestigationStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'investigationJurisdiction': template_variables.investigation_jurisdiction, + 'licenseType': template_variables.license_type, + }, + } + return self._invoke_lambda(payload) + + def send_privilege_investigation_closed_state_notification_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: InvestigationNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a privilege investigation closed notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('provider_id must be provided for state notifications') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'privilegeInvestigationClosedStateNotification', + 'recipientType': 'JURISDICTION_ADVERSE_ACTIONS', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'investigationJurisdiction': template_variables.investigation_jurisdiction, + 'licenseType': template_variables.license_type, + }, + } + return self._invoke_lambda(payload) + + def send_provider_home_state_change_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: HomeJurisdictionChangeNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a license home state change notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('provider_id must be provided for state notifications') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'homeJurisdictionChangeNotification', + 'recipientType': 'JURISDICTION_OPERATIONS_TEAM', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'previousJurisdiction': template_variables.former_jurisdiction, + 'newJurisdiction': template_variables.current_jurisdiction, + 'licenseType': template_variables.license_type, + }, + } + return self._invoke_lambda(payload) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/event_batch_writer.py b/backend/social-work-app/lambdas/python/common/cc_common/event_batch_writer.py new file mode 100644 index 0000000000..77ba98d3bf --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/event_batch_writer.py @@ -0,0 +1,49 @@ +from botocore.client import BaseClient + + +class EventBatchWriter: + """Utility class to batch event bridge event puts for better efficiency with the AWS EventBridge API""" + + def __init__(self, client: BaseClient, batch_size: int = 10): + """:param BaseClient client: A boto3 EventBridge client to use for API calls + :param int batch_size: Batch size to use for API calls, default: 10 + """ + self._client = client + self._batch_size = batch_size + self._batch = None + self._count = 0 + self.failed_entry_count = 0 + self.failed_entries = None + + def _do_put(self): + resp = self._client.put_events(Entries=self._batch) + failure_count = resp.get('FailedEntryCount', 0) + if failure_count > 0: + self.failed_entry_count += failure_count + self.failed_entries.extend(entry for entry in resp.get('Entries') if entry.get('ErrorCode')) + self._batch = [] + self._count = 0 + + def __enter__(self): + self._batch = [] + self._count = 0 + self.failed_entries = [] + self.failed_entry_count = 0 + return self + + def __exit__(self, exc_type=None, exc_val=None, exc_tb=None): + # We'll check the actual batch length, instead of count here, + # just to be a bit defensive in the case that we mess up the counter. + if len(self._batch) > 0: + self._do_put() + if exc_val is not None: + raise exc_val + + def put_event(self, Entry: dict): # noqa: N803 invalid-name + if self._batch is None: + # Protecting ourselves from accidental misuse + raise RuntimeError('This object must be used as a context manager') + self._batch.append(Entry) + self._count += 1 + if self._count >= self._batch_size: + self._do_put() diff --git a/backend/social-work-app/lambdas/python/common/cc_common/event_bus_client.py b/backend/social-work-app/lambdas/python/common/cc_common/event_bus_client.py new file mode 100644 index 0000000000..f9f19cd36e --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/event_bus_client.py @@ -0,0 +1,444 @@ +import json +from datetime import date, datetime +from uuid import UUID + +from marshmallow import ValidationError + +from cc_common.config import config +from cc_common.data_model.schema.common import InvestigationAgainstEnum +from cc_common.data_model.schema.data_event.api import ( + EncumbranceEventDetailSchema, + HomeJurisdictionChangeEventDetailSchema, + InvestigationEventDetailSchema, + LicenseDeactivationDetailSchema, + LicenseRevertDetailSchema, +) +from cc_common.event_batch_writer import EventBatchWriter +from cc_common.utils import ResponseEncoder + + +class EventBusClient: + """ + Client for publishing events to the event bus. + This class abstracts the event bus client and provides a clean interface for publishing events. + """ + + def __init__(self): + """ + Initialize the EventBusClient. + """ + + def _publish_event( + self, + source: str, + detail: dict, + detail_type: str, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish event to the event bus, with event dateTime added + """ + event_entry = { + 'Source': source, + 'DetailType': detail_type, + 'Detail': json.dumps(detail, cls=ResponseEncoder), + 'EventBusName': config.event_bus_name, + } + # We'll support using a provided event batch writer to send the event to the event bus + if event_batch_writer: + event_batch_writer.put_event(Entry=event_entry) + else: + # If no event batch writer is provided, we'll use the default event bus client + config.events_client.put_events(Entries=[event_entry]) + + def generate_license_deactivation_event( + self, source: str, compact: str, jurisdiction: str, provider_id: UUID, license_type: str + ) -> dict: + """ + Generate a license deactivation event entry for use with batch writers. + + :param source: The source of the event + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction where the license was deactivated + :param provider_id: The provider's unique identifier + :param license_type: The type of license that was deactivated + :returns: Event entry dict that can be used with EventBatchWriter + """ + event_detail = { + 'eventTime': config.current_standard_datetime.isoformat(), + 'compact': compact, + 'jurisdiction': jurisdiction, + 'providerId': str(provider_id), + 'licenseType': license_type, + } + + # Validate the event detail using the schema + license_deactivation_schema = LicenseDeactivationDetailSchema() + loaded_detail = license_deactivation_schema.load(event_detail) + + return { + 'Source': source, + 'DetailType': 'license.deactivation', + 'Detail': json.dumps(loaded_detail, cls=ResponseEncoder), + 'EventBusName': config.event_bus_name, + } + + def generate_home_jurisdiction_change_event( + self, + source: str, + compact: str, + jurisdiction: str, + provider_id: UUID, + license_type: str, + former_home_jurisdiction: str, + ) -> dict: + """ + Generate a home jurisdiction change event entry for use with batch writers. + + :param source: The source of the event + :param compact: The compact abbreviation + :param jurisdiction: The new jurisdiction that uploaded the license + :param provider_id: The provider's unique identifier + :param license_type: The type of license + :param former_home_jurisdiction: The former home jurisdiction of the provider + :returns: Event entry dict that can be used with EventBatchWriter + """ + event_detail = { + 'eventTime': config.current_standard_datetime.isoformat(), + 'compact': compact, + 'jurisdiction': jurisdiction, + 'providerId': str(provider_id), + 'licenseType': license_type, + 'formerHomeJurisdiction': former_home_jurisdiction, + } + + home_jurisdiction_change_schema = HomeJurisdictionChangeEventDetailSchema() + loaded_detail = home_jurisdiction_change_schema.load(event_detail) + + return { + 'Source': source, + 'DetailType': 'provider.homeStateChange', + 'Detail': json.dumps(loaded_detail, cls=ResponseEncoder), + 'EventBusName': config.event_bus_name, + } + + def publish_license_encumbrance_event( + self, + source: str, + compact: str, + provider_id: UUID, + jurisdiction: str, + adverse_action_id: UUID, + license_type_abbreviation: str, + effective_date: date, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish a license encumbrance event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction of the license + :param adverse_action_id: The adverse action ID + :param license_type_abbreviation: The license type abbreviation + :param effective_date: The date when the encumbrance became effective + :param event_batch_writer: Optional EventBatchWriter for efficient batch publishing + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'jurisdiction': jurisdiction, + 'adverseActionId': adverse_action_id, + 'licenseTypeAbbreviation': license_type_abbreviation, + 'effectiveDate': effective_date, + 'eventTime': config.current_standard_datetime, + } + + encumbrance_detail_schema = EncumbranceEventDetailSchema() + + deserialized_detail = encumbrance_detail_schema.dump(event_detail) + + self._publish_event( + source=source, + detail_type='license.encumbrance', + detail=deserialized_detail, + event_batch_writer=event_batch_writer, + ) + + def publish_license_encumbrance_lifting_event( + self, + source: str, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + effective_date: date, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish a license encumbrance lifting event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction of the license + :param license_type_abbreviation: The license type abbreviation + :param effective_date: The date when the encumbrance was lifted + :param event_batch_writer: Optional EventBatchWriter for efficient batch publishing + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'jurisdiction': jurisdiction, + 'licenseTypeAbbreviation': license_type_abbreviation, + 'effectiveDate': effective_date, + 'eventTime': config.current_standard_datetime, + } + + encumbrance_detail_schema = EncumbranceEventDetailSchema() + + deserialized_detail = encumbrance_detail_schema.dump(event_detail) + + self._publish_event( + source=source, + detail_type='license.encumbranceLifted', + detail=deserialized_detail, + event_batch_writer=event_batch_writer, + ) + + def publish_privilege_encumbrance_event( + self, + source: str, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + effective_date: date, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish a privilege encumbrance event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction of the privilege + :param license_type_abbreviation: The license type abbreviation + :param effective_date: The date when the encumbrance became effective + :param event_batch_writer: Optional EventBatchWriter for efficient batch publishing + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'jurisdiction': jurisdiction, + 'licenseTypeAbbreviation': license_type_abbreviation, + 'effectiveDate': effective_date, + 'eventTime': config.current_standard_datetime, + } + + encumbrance_detail_schema = EncumbranceEventDetailSchema() + + deserialized_detail = encumbrance_detail_schema.dump(event_detail) + + self._publish_event( + source=source, + detail_type='privilege.encumbrance', + detail=deserialized_detail, + event_batch_writer=event_batch_writer, + ) + + def publish_privilege_encumbrance_lifting_event( + self, + source: str, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + effective_date: date, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish a privilege encumbrance lifting event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction of the privilege + :param license_type_abbreviation: The license type abbreviation + :param effective_date: The date when the encumbrance was lifted + :param event_batch_writer: Optional EventBatchWriter for efficient batch publishing + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'jurisdiction': jurisdiction, + 'licenseTypeAbbreviation': license_type_abbreviation, + 'effectiveDate': effective_date, + 'eventTime': config.current_standard_datetime, + } + + encumbrance_detail_schema = EncumbranceEventDetailSchema() + + deserialized_detail = encumbrance_detail_schema.dump(event_detail) + + self._publish_event( + source=source, + detail_type='privilege.encumbranceLifted', + detail=deserialized_detail, + event_batch_writer=event_batch_writer, + ) + + def publish_investigation_event( + self, + source: str, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + create_date: datetime, + investigation_against: InvestigationAgainstEnum, + investigation_id: UUID, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish an investigation event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction of the record being investigated + :param license_type_abbreviation: The license type abbreviation + :param create_date: The datetime when the investigation record was created + :param investigation_against: The type of record being investigated (privilege or license) + :param investigation_id: The investigation ID + :param event_batch_writer: Optional EventBatchWriter for efficient batch publishing + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'jurisdiction': jurisdiction, + 'licenseTypeAbbreviation': license_type_abbreviation, + 'investigationAgainst': investigation_against.value, + 'investigationId': investigation_id, + 'eventTime': create_date, + } + + investigation_detail_schema = InvestigationEventDetailSchema() + deserialized_detail = investigation_detail_schema.dump(event_detail) + + # Determine the detail type based on investigation_against + detail_type = f'{investigation_against}.investigation' + + self._publish_event( + source=source, + detail_type=detail_type, + detail=deserialized_detail, + event_batch_writer=event_batch_writer, + ) + + def publish_investigation_closed_event( + self, + source: str, + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbreviation: str, + close_date: datetime, + investigation_against: InvestigationAgainstEnum, + investigation_id: UUID, + adverse_action_id: UUID | None = None, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish an investigation closed event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction of the record being investigated + :param license_type_abbreviation: The license type abbreviation + :param close_date: The datetime when the investigation record was closed + :param investigation_against: The type of record being investigated (privilege or license) + :param investigation_id: The id of the investigation closed + :param adverse_action_id: Optional adverse action ID if an encumbrance resulted from the investigation + :param event_batch_writer: Optional EventBatchWriter for efficient batch publishing + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'jurisdiction': jurisdiction, + 'licenseTypeAbbreviation': license_type_abbreviation, + 'investigationAgainst': investigation_against.value, + 'investigationId': investigation_id, + 'eventTime': close_date, + } + + # Include adverseActionId if an encumbrance resulted from the investigation + if adverse_action_id is not None: + event_detail['adverseActionId'] = adverse_action_id + + investigation_detail_schema = InvestigationEventDetailSchema() + deserialized_detail = investigation_detail_schema.dump(event_detail) + + # Determine the detail type based on investigation_against + detail_type = f'{investigation_against.value}.investigationClosed' + + self._publish_event( + source=source, + detail_type=detail_type, + detail=deserialized_detail, + event_batch_writer=event_batch_writer, + ) + + def publish_license_revert_event( + self, + source: str, + compact: str, + provider_id: str, + jurisdiction: str, + license_type: str, + rollback_reason: str, + start_time: datetime, + end_time: datetime, + execution_name: str, + event_batch_writer: EventBatchWriter | None = None, + ): + """ + Publish a license revert event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction of the license. + :param license_type: The license type. + :param rollback_reason: The reason for the rollback + :param start_time: The start time of the rollback window + :param end_time: The end time of the rollback window + :param execution_name: The execution name for the rollback operation + :param event_batch_writer: Optional EventBatchWriter for efficient batch publishing + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'jurisdiction': jurisdiction, + 'licenseType': license_type, + 'rollbackReason': rollback_reason, + 'startTime': start_time, + 'endTime': end_time, + 'rollbackExecutionName': execution_name, + 'eventTime': config.current_standard_datetime, + } + + license_revert_detail_schema = LicenseRevertDetailSchema() + deserialized_detail = license_revert_detail_schema.dump(event_detail) + validation_errors = license_revert_detail_schema.validate(deserialized_detail) + if validation_errors: + raise ValidationError(message=validation_errors) + + self._publish_event( + source=source, + detail_type='license.revert', + detail=deserialized_detail, + event_batch_writer=event_batch_writer, + ) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/event_state_client.py b/backend/social-work-app/lambdas/python/common/cc_common/event_state_client.py new file mode 100644 index 0000000000..5bafddd648 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/event_state_client.py @@ -0,0 +1,227 @@ +import time +from datetime import timedelta +from enum import StrEnum +from uuid import UUID + +from cc_common.config import _Config, logger + + +class RecipientType(StrEnum): + """Enum for notification recipient types.""" + + STATE = 'state' + + +class NotificationStatus(StrEnum): + """Enum for notification delivery status.""" + + SUCCESS = 'SUCCESS' + FAILED = 'FAILED' + + +class EventType(StrEnum): + """Enum for encumbrance event types.""" + + LICENSE_ENCUMBRANCE = 'license.encumbrance' + LICENSE_ENCUMBRANCE_LIFTED = 'license.encumbranceLifted' + PRIVILEGE_ENCUMBRANCE = 'privilege.encumbrance' + PRIVILEGE_ENCUMBRANCE_LIFTED = 'privilege.encumbranceLifted' + + +class EventStateClient: + """Client interface for event state table operations to track notification delivery state.""" + + def __init__(self, config: _Config): + self.config = config + + def record_notification_attempt( + self, + *, + compact: str, + message_id: str, + recipient_type: RecipientType, + status: NotificationStatus, + provider_id: UUID, + event_type: EventType, + event_time: str, + jurisdiction: str | None = None, + error_message: str | None = None, + ttl_weeks: int = 4, + ) -> None: + """ + Record a notification attempt to the event state table. + + :param compact: The compact identifier + :param message_id: SQS message ID + :param recipient_type: RecipientType enum or string ('provider' or 'state') + :param status: NotificationStatus enum or string ('SUCCESS' or 'FAILED') + :param provider_id: Provider ID + :param event_type: EventType enum or string (e.g., 'license.encumbrance') + :param event_time: Event timestamp + :param jurisdiction: Jurisdiction code (for state notifications) + :param error_message: Error message if failed + :param ttl_weeks: TTL in weeks (default 4 weeks) + """ + # Build partition and sort keys + pk = f'COMPACT#{compact}#SQS_MESSAGE#{message_id}' + + sk = f'NOTIFICATION#{recipient_type}#{jurisdiction or ""}' + + # Calculate TTL + ttl = int(time.time()) + int(timedelta(weeks=ttl_weeks).total_seconds()) + + # Build item (ensure all values are DynamoDB-compatible types) + item = { + 'pk': pk, + 'sk': sk, + 'status': status, + 'providerId': str(provider_id), # Convert UUID to string for DynamoDB + 'eventType': event_type, + 'eventTime': str(event_time), # Ensure string format for DynamoDB + 'ttl': ttl, + } + + # Add optional fields + if jurisdiction: + item['jurisdiction'] = jurisdiction + + if error_message: + item['errorMessage'] = error_message + + # Write to table + self.config.event_state_table.put_item(Item=item) + logger.debug('Recorded notification attempt', pk=pk, sk=sk, status=status) + + def _get_notification_attempts(self, *, compact: str, message_id: str) -> dict[str, dict]: + """ + Query all notification attempts for a message. + + :param compact: The compact identifier + :param message_id: SQS message ID + :return: Dict mapping SK to item data + """ + pk = f'COMPACT#{compact}#SQS_MESSAGE#{message_id}' + + response = self.config.event_state_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': pk}, + ConsistentRead=True, + ) + + return {item['sk']: item for item in response.get('Items', [])} + + +class NotificationTracker: + """ + Helper class to track which notifications have been sent for an SQS message. + Provides convenient methods to check status and determine what needs to be sent. + Encapsulates the EventStateClient to simplify handler interfaces. + """ + + def __init__(self, *, compact: str, message_id: str): + from cc_common.config import config + + self.compact = compact + self.message_id = message_id + self.event_state_client = config.event_state_client + self._attempts = self.event_state_client._get_notification_attempts( # noqa: SLF001 meant for use within the notification tracker + compact=compact, message_id=message_id + ) + + def should_send_state_notification(self, jurisdiction: str) -> bool: + """ + Check if state notification needs to be sent. + + :param jurisdiction: Jurisdiction code + :return: True if notification should be sent, False otherwise + """ + sk = f'NOTIFICATION#{RecipientType.STATE}#{jurisdiction}' + return self._attempts.get(sk, {}).get('status') != 'SUCCESS' + + def record_success( + self, + *, + recipient_type: RecipientType, + provider_id: UUID, + event_type: EventType, + event_time: str, + jurisdiction: str | None = None, + ) -> None: + """ + Record a successful notification. + + :param recipient_type: RecipientType enum or string ('provider' or 'state') + :param provider_id: Provider ID + :param event_type: EventType enum or string + :param event_time: Event timestamp + :param jurisdiction: Jurisdiction code (for state notifications) + """ + try: + self.event_state_client.record_notification_attempt( + compact=self.compact, + message_id=self.message_id, + recipient_type=recipient_type, + status=NotificationStatus.SUCCESS, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction=jurisdiction, + ) + except Exception as e: # noqa: BLE001 + # If this cannot be written for whatever reason, we swallow the error since the notification itself was + # sent, and this step is just another layer of system redundancy, not business critical. Just log the error + # and move on. + logger.error( + 'Unable to record notification success.', + compact=self.compact, + recipient_type=recipient_type, + provider_id=provider_id, + event_type=event_type, + jurisdiction=jurisdiction or 'None', + error=str(e), + ) + + def record_failure( + self, + *, + recipient_type: RecipientType, + provider_id: UUID, + event_type: EventType, + event_time: str, + error_message: str, + jurisdiction: str | None = None, + ) -> None: + """ + Record a failed notification. + + :param recipient_type: RecipientType enum or string ('provider' or 'state') + :param provider_id: Provider ID + :param event_type: EventType enum or string + :param event_time: Event timestamp + :param error_message: Error message describing the failure + :param jurisdiction: Jurisdiction code (for state notifications) + """ + try: + self.event_state_client.record_notification_attempt( + compact=self.compact, + message_id=self.message_id, + recipient_type=recipient_type, + status=NotificationStatus.FAILED, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction=jurisdiction, + error_message=error_message, + ) + except Exception as e: # noqa: BLE001 + # If this cannot be written, we swallow the error as the lambda will automatically retry and + # attempt to send out the notification again. Just log the error and move on. + logger.error( + 'Unable to record notification failure.', + compact=self.compact, + recipient_type=recipient_type, + provider_id=provider_id, + event_type=event_type, + jurisdiction=jurisdiction or 'None', + error=str(e), + ) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/exceptions.py b/backend/social-work-app/lambdas/python/common/cc_common/exceptions.py new file mode 100644 index 0000000000..0d9b09037d --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/exceptions.py @@ -0,0 +1,57 @@ +class CCBaseException(Exception): + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +class CCInvalidRequestException(CCBaseException): + """Client error in the request, corresponds to a 400 response""" + + +class CCInvalidRequestCustomResponseException(CCInvalidRequestException): + """Client error with custom response body format, corresponds to a 400 response""" + + def __init__(self, response_body: dict | list): + self.response_body = response_body + # Still need a message for logging purposes + super().__init__('Invalid request') + + +class CCUnauthorizedException(CCInvalidRequestException): + """Client is not authorized, corresponds to a 401 response""" + + +class CCUnauthorizedCustomResponseException(CCUnauthorizedException): + """Client is not authorized, corresponds to a 401 response with a custom response body""" + + +class CCAccessDeniedException(CCInvalidRequestException): + """Client is forbidden, corresponds to a 403 response""" + + +class CCNotFoundException(CCInvalidRequestException): + """Requested resource is not found, corresponds to a 404 response""" + + +class CCUnsupportedMediaTypeException(CCInvalidRequestException): + """Unsupported media type, corresponds to a 415 response""" + + +class CCRateLimitingException(CCInvalidRequestException): + """Client is rate limited, corresponds to a 429 response""" + + +class CCInternalException(CCBaseException): + """Internal error in the request, corresponds to a 500 response""" + + +class CCFailedTransactionException(CCBaseException): + """Authorize.Net transaction failed due to user input, corresponds to a 400 response""" + + +class CCAwsServiceException(CCBaseException): + """This is raised when an AWS service fails, corresponds to a 500 response""" + + +class CCConflictException(CCBaseException): + """Client error in the request, corresponds to a 409 response""" diff --git a/backend/social-work-app/lambdas/python/common/cc_common/feature_flag_client.py b/backend/social-work-app/lambdas/python/common/cc_common/feature_flag_client.py new file mode 100644 index 0000000000..2d4264fd9f --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/feature_flag_client.py @@ -0,0 +1,129 @@ +""" +Feature flag client for checking feature flags via the internal API. + +This module provides a simple, stateless interface for checking feature flags +from other Lambda functions without direct dependency on the feature flag provider. +""" + +from dataclasses import dataclass +from typing import Any + +import requests + +from cc_common.config import config, logger +from cc_common.feature_flag_enum import FeatureFlagEnum + + +@dataclass +class FeatureFlagContext: + """ + Context information for feature flag evaluation. + + This context is used by the feature flag provider to determine whether a flag + should be enabled for a specific user or scenario. + + :param user_id: Optional user identifier for user-specific flag evaluation + :param custom_attributes: Optional dictionary of custom attributes for advanced targeting + (e.g., {'licenseType': 'physician', 'jurisdiction': 'oh'}) + """ + + user_id: str | None = None + custom_attributes: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + """ + Convert the context to a dictionary for API serialization. + + :return: Dictionary representation of the context, excluding None values + """ + result = {} + if self.user_id is not None: + result['userId'] = self.user_id + if self.custom_attributes: + result['customAttributes'] = self.custom_attributes + return result + + +def is_feature_enabled( + flag_name: FeatureFlagEnum, context: FeatureFlagContext | None = None, fail_default: bool = False +) -> bool: + """ + Check if a feature flag is enabled. + + This function calls the internal feature flag API endpoint to determine + if a feature flag is enabled for the given context. + + :param flag_name: The name of the feature flag to check. + :param context: Optional FeatureFlagContext for feature flag evaluation + :param fail_default: If True, return True on errors; if False, return False on errors (default: False) + :return: True if the feature flag is enabled, False otherwise (or fail_default value on error) + + Example: + # Simple check without context + if is_feature_enabled('test-feature'): + # feature code here + + # Check with user ID + if is_feature_enabled( + 'test-feature', + context=FeatureFlagContext(user_id='user123') + ): + + # Check with user ID and custom attributes + if is_feature_enabled( + 'test-feature', + context=FeatureFlagContext( + user_id='user456', + custom_attributes={'licenseType': 'lpc', 'jurisdiction': 'oh'} + ) + ): + """ + try: + logger.info('checking status of feature flag', flag_name=flag_name) + api_base_url = _get_api_base_url() + endpoint_url = f'{api_base_url}/v1/flags/{flag_name}/check' + + # Build request payload + payload = {} + if context: + payload['context'] = context.to_dict() + + response = requests.post( + endpoint_url, + json=payload, + timeout=5, + headers={'Content-Type': 'application/json'}, + ) + + # Raise exception for HTTP errors (4xx, 5xx) + response.raise_for_status() + + # Parse response + response_data = response.json() + + # Extract and return the 'enabled' field + if 'enabled' not in response_data: + logger.info('Invalid response format - return fail_default value', response_data=response_data) + # Invalid response format - return fail_default value + return fail_default + + logger.info('Checked flag status successfully', flag_name=flag_name, enabled=response_data['enabled']) + return response_data['enabled'] + + # We catch all exceptions to prevent a feature flag issue causing the system from operating + except Exception as e: # noqa: BLE001 + # Any error (timeout, network, parsing, etc.) - return fail_default value + logger.info('Error checking feature flag - return fail_default value', exc_info=e) + return fail_default + + +def _get_api_base_url() -> str: + """ + Get the API base URL from environment variables. + + :return: The base URL for the API + :raises KeyError: If API_BASE_URL is not set + """ + api_base_url = config.api_base_url + # Remove trailing slash if present + return api_base_url.rstrip('/') diff --git a/backend/social-work-app/lambdas/python/common/cc_common/feature_flag_enum.py b/backend/social-work-app/lambdas/python/common/cc_common/feature_flag_enum.py new file mode 100644 index 0000000000..53e5795688 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/feature_flag_enum.py @@ -0,0 +1,14 @@ +from enum import StrEnum + + +class FeatureFlagEnum(StrEnum): + """ + Central source for all feature flags currently referenced in the python code of the project. + Flags should be defined here when first added, and removed when the flag + is no longer in use. + """ + + # flag used by internal testing + TEST_FLAG = 'test-flag' + # runtime flags + DUPLICATE_SSN_UPLOAD_CHECK_FLAG = 'duplicate-ssn-upload-check-flag' diff --git a/backend/social-work-app/lambdas/python/common/cc_common/license_util.py b/backend/social-work-app/lambdas/python/common/cc_common/license_util.py new file mode 100644 index 0000000000..423840af8a --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/license_util.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass + +from cc_common.config import config, logger +from cc_common.exceptions import CCInvalidRequestException + + +@dataclass +class LicenseType: + """Represents a license type with name and abbreviation.""" + + name: str + abbreviation: str + + +class LicenseUtility: + """Utility class for working with license types across compacts.""" + + @staticmethod + def get_license_type_by_abbreviation(compact: str, abbreviation: str) -> LicenseType: + """ + Get a license type by its abbreviation within a compact. + + :param compact: The compact code + :param abbreviation: The license type abbreviation + + :return: LicenseType object + """ + try: + abbreviations = config.license_type_abbreviations_for_compact(compact) + for name, abbr in abbreviations.items(): + if abbr.lower() == abbreviation.lower(): + return LicenseType(name=name, abbreviation=abbr) + raise CCInvalidRequestException(f'Invalid license type abbreviation: {abbreviation}') + except KeyError as e: + logger.error('Invalid license type abbreviation provided.', exc_info=e) + raise CCInvalidRequestException(f'Invalid license type abbreviation: {abbreviation}') from e + + @staticmethod + def get_valid_license_type_abbreviations(compact: str) -> set[str]: + """ + Get all valid license type abbreviations for a compact. + + :param compact: The compact code + :return: Set of valid license type abbreviations + """ + license_types = config.license_types_for_compact(compact) + return {config.license_type_abbreviations[compact][license_type] for license_type in license_types} + + @staticmethod + def find_invalid_license_type_abbreviations(compact: str, abbreviations: list[str]) -> list[str]: + """ + Check if the provided license type abbreviations are valid for the given compact. + + :param compact: The compact code + :param abbreviations: List of license type abbreviations to validate + :return: List of invalid license type abbreviations, empty if all are valid + """ + valid_abbreviations = LicenseUtility.get_valid_license_type_abbreviations(compact) + + return [abbr for abbr in abbreviations if abbr not in valid_abbreviations] diff --git a/backend/social-work-app/lambdas/python/common/cc_common/signature_auth.py b/backend/social-work-app/lambdas/python/common/cc_common/signature_auth.py new file mode 100644 index 0000000000..dce8c4dcdd --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/signature_auth.py @@ -0,0 +1,411 @@ +""" +Signature Authentication Module + +This module provides decorators for validating ECDSA-based request signatures. +""" + +import base64 +from collections.abc import Callable +from datetime import UTC, datetime +from functools import wraps +from typing import Any +from urllib.parse import quote + +from boto3.dynamodb.conditions import Attr, Key +from botocore.exceptions import ClientError +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec + +from cc_common.config import config, logger +from cc_common.exceptions import ( + CCInvalidRequestException, + CCUnauthorizedCustomResponseException, + CCUnauthorizedException, +) +from cc_common.utils import CaseInsensitiveDict + + +def required_signature_auth(fn: Callable) -> Callable: + """ + Decorator to validate signatures for API requests. + + This decorator validates ECDSA signatures according to the specification: + - Extracts required headers (X-Algorithm, X-Timestamp, X-Nonce, X-Signature, X-Key-Id) + - Validates timestamp is within configurable max clock skew + - Reconstructs signature string from request components + - Verifies signature using public key from DynamoDB + - Raises appropriate exceptions for validation failures + + :param fn: The function to decorate + :return: Decorated function + """ + + @wraps(fn) + def validate_signature(event: dict, context: Any) -> Any: + # Extract compact and jurisdiction from path parameters + compact, jurisdiction = _extract_path_parameters(event) + + # Extract key ID from headers (required) + key_id = _extract_key_id(event) + if not key_id: + logger.warning('Missing X-Key-Id header', compact=compact, jurisdiction=jurisdiction) + raise CCUnauthorizedCustomResponseException('Missing required X-Key-Id header') + + # Get public key from DynamoDB (required) + public_key_pem = _get_public_key_from_dynamodb(compact, jurisdiction, key_id) + if not public_key_pem: + logger.warning( + 'Public key not found for compact/jurisdiction/key_id', + compact=compact, + jurisdiction=jurisdiction, + key_id=key_id, + ) + raise CCUnauthorizedCustomResponseException('Public key not found for this compact/jurisdiction/key-id') + + # Validate signature + _validate_signature(event, compact, jurisdiction, public_key_pem) + + logger.info('Signature validated successfully', compact=compact, jurisdiction=jurisdiction, key_id=key_id) + return fn(event, context) + + return validate_signature + + +def optional_signature_auth(fn: Callable) -> Callable: + """ + Decorator for optional signature validation. + + This decorator checks if signature keys are configured for the compact/state combination. + If keys are configured and X-Key-Id is provided, it enforces signature validation. + If no keys are configured, it allows the request to proceed without signature validation. + If keys are configured but no X-Key-Id is provided, access is denied. + + This is useful for endpoints that support both signature-authenticated and Oauth-only access, + where the authentication requirement is determined by whether signature keys are configured. + + :param fn: The function to decorate + :return: Decorated function + """ + + @wraps(fn) + def validate_optional_signature(event: dict, context: Any) -> Any: + # Extract compact and jurisdiction from path parameters + compact, jurisdiction = _extract_path_parameters(event) + + # Get all configured keys for this compact/jurisdiction in a single query + configured_keys = _get_configured_keys_for_jurisdiction(compact, jurisdiction) + + if not configured_keys: + # No keys configured - allow request to proceed without signature validation + logger.info( + 'No signature keys configured for compact/jurisdiction - proceeding without signature validation', + compact=compact, + jurisdiction=jurisdiction, + ) + return fn(event, context) + + # Keys are configured - check if X-Key-Id is provided + key_id = _extract_key_id(event) + if not key_id: + logger.warning( + 'Signature keys configured but no X-Key-Id provided - denying access', + compact=compact, + jurisdiction=jurisdiction, + ) + raise CCUnauthorizedCustomResponseException('X-Key-Id header required when signature keys are configured') + + # Get public key for the specific key ID from our cached keys + public_key_pem = configured_keys.get(key_id) + if not public_key_pem: + logger.warning( + 'Public key not found for compact/jurisdiction/key_id', + compact=compact, + jurisdiction=jurisdiction, + key_id=key_id, + ) + raise CCUnauthorizedCustomResponseException('Public key not found for this compact/jurisdiction/key-id') + + # Validate signature + _validate_signature(event, compact, jurisdiction, public_key_pem) + + logger.info( + 'Optional signature validated successfully', compact=compact, jurisdiction=jurisdiction, key_id=key_id + ) + return fn(event, context) + + return validate_optional_signature + + +def _extract_path_parameters(event: dict) -> tuple[str, str]: + """ + Extract compact and jurisdiction from path parameters. + + :param event: API Gateway event + :return: Tuple of (compact, jurisdiction) + :raises CCInvalidRequestException: If compact or jurisdiction is missing + """ + path_params = event.get('pathParameters') or {} + compact = path_params.get('compact') + jurisdiction = path_params.get('jurisdiction') + + if not compact or not jurisdiction: + logger.error('Missing compact or jurisdiction in path parameters', path_params=path_params) + raise CCInvalidRequestException('Missing compact or jurisdiction parameters') + + return compact, jurisdiction + + +def _extract_key_id(event: dict) -> str | None: + """ + Extract key ID from request headers. + + :param event: API Gateway event + :return: Key ID or None if not present + """ + headers = CaseInsensitiveDict(event.get('headers') or {}) + return headers.get('X-Key-Id') + + +def _validate_nonce_format(nonce: str) -> None: + """ + Validate that a nonce contains only alphanumeric characters and hyphens, and is not longer than 256 characters. + + :param nonce: The nonce to validate + :raises CCInvalidRequestException: If the nonce format is invalid + """ + if not nonce: + raise CCUnauthorizedCustomResponseException('Nonce cannot be empty') + + if len(nonce) > 256: + logger.warning('Nonce too long', nonce_length=len(nonce), max_length=256) + raise CCUnauthorizedCustomResponseException('Nonce cannot be longer than 256 characters') + + # Check that nonce contains only alphanumeric characters and hyphens + import re + + if not re.match(r'^[a-zA-Z0-9-]+$', nonce): + logger.warning('Invalid nonce format - contains invalid characters', nonce=nonce) + raise CCUnauthorizedCustomResponseException('Nonce can only contain alphanumeric characters and hyphens') + + +def _validate_signature(event: dict, compact: str, jurisdiction: str, public_key_pem: str) -> None: + """ + Validate signature for a request. + + This function performs all the signature validation steps: + - Extracts and validates required headers + - Validates timestamp + - Reconstructs and verifies signature + + :param event: API Gateway event + :param compact: Compact abbreviation + :param jurisdiction: Jurisdiction abbreviation + :param public_key_pem: PEM-encoded public key + :raises CCUnauthorizedException: If signature validation fails + """ + # Extract headers using CaseInsensitiveDict for consistent handling + headers = CaseInsensitiveDict(event.get('headers') or {}) + + # Extract required signature headers + algorithm = headers.get('X-Algorithm') + timestamp_str = headers.get('X-Timestamp') + nonce = headers.get('X-Nonce') + signature_b64 = headers.get('X-Signature') + key_id = headers.get('X-Key-Id') + + # Validate all required headers are present + if not all([algorithm, timestamp_str, nonce, signature_b64, key_id]): + logger.warning( + 'Missing required signature headers', + algorithm=algorithm, + timestamp=timestamp_str, + nonce=nonce, + signature_present=bool(signature_b64), + key_id=key_id, + compact=compact, + jurisdiction=jurisdiction, + ) + raise CCUnauthorizedCustomResponseException('Missing required signature authentication headers') + + # Validate nonce format (before we try to calculate the signature string) + _validate_nonce_format(nonce) + + # Validate algorithm + if algorithm != 'ECDSA-SHA256': + logger.warning( + 'Unsupported signature algorithm', algorithm=algorithm, compact=compact, jurisdiction=jurisdiction + ) + raise CCUnauthorizedCustomResponseException('Unsupported signature algorithm') + + # Validate timestamp + try: + timestamp = datetime.fromisoformat(timestamp_str) + if timestamp.tzinfo is None: + # Treat naive timestamps as UTC to avoid mismatched aware vs naive comparisons + timestamp = timestamp.replace(tzinfo=UTC) + now = config.current_standard_datetime + time_diff = abs((timestamp - now).total_seconds()) + + if time_diff > config.signature_max_clock_skew_seconds: + logger.warning( + 'Request timestamp too old or too far in the future', + timestamp=timestamp_str, + time_diff_seconds=time_diff, + max_clock_skew_seconds=config.signature_max_clock_skew_seconds, + ) + raise CCUnauthorizedCustomResponseException('Request timestamp is too old or too far in the future') + except ValueError as e: + logger.warning('Invalid timestamp format', timestamp=timestamp_str, error=str(e)) + raise CCUnauthorizedCustomResponseException('Invalid timestamp format') from e + + # Reconstruct signature string + signature_string = _build_signature_string(event) + + # Verify signature + if not _verify_signature(signature_string, signature_b64, public_key_pem): + logger.warning('Invalid signature for request', compact=compact, jurisdiction=jurisdiction) + raise CCUnauthorizedCustomResponseException('Invalid request signature') + + # Validate and store nonce to prevent reuse + _validate_and_store_nonce(compact, jurisdiction, nonce) + + +def _get_public_key_from_dynamodb(compact: str, jurisdiction: str, key_id: str) -> str | None: + """ + Retrieve the public key for a compact/jurisdiction/key_id combination from DynamoDB. + + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction abbreviation + :param key_id: The key ID + :return: PEM-encoded public key or None if not found + """ + # Query the compact configuration table for the public key + response = config.compact_configuration_table.get_item( + Key={'pk': f'{compact}#SIGNATURE_KEYS#{jurisdiction}', 'sk': f'{compact}#JURISDICTION#{jurisdiction}#{key_id}'} + ) + + return response.get('Item', {}).get('publicKey') + + +def _get_configured_keys_for_jurisdiction(compact: str, jurisdiction: str) -> dict[str, str]: + """ + Retrieve all configured signature keys for a specific jurisdiction. + + This function queries DynamoDB to get all key IDs and their corresponding public keys + for a given compact and jurisdiction. It returns a dictionary mapping key_id to public_key_pem. + + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction abbreviation + :return: Dictionary of key_id to public_key_pem + """ + # Query for all keys with the jurisdiction prefix + response = config.compact_configuration_table.query( + KeyConditionExpression=Key('pk').eq(f'{compact}#SIGNATURE_KEYS#{jurisdiction}') + & Key('sk').begins_with(f'{compact}#JURISDICTION#{jurisdiction}#'), + ) + + configured_keys: dict[str, str] = {} + for item in response.get('Items', []): + key_id = item['sk'].split('#')[-1] # Extract key_id from sk + public_key_pem = item['publicKey'] + configured_keys[key_id] = public_key_pem + + return configured_keys + + +def _build_signature_string(event: dict) -> str: + """ + Build the signature string according to the signature specification. + + The signature string is constructed as: + HTTP_METHOD\nREQUEST_PATH\nSORTED_QUERY_PARAMETERS\nTIMESTAMP\nNONCE\nKEY_ID + + :param event: API Gateway event + :return: Signature string + """ + # Extract components + http_method = event.get('httpMethod', '') + path = event.get('path', '') + + # Handle query parameters + query_params = event.get('queryStringParameters') or {} + sorted_params = '&'.join( + f'{quote(str(k), safe="")}={quote(str(v), safe="")}' for k, v in sorted(query_params.items()) + ) + + # Extract timestamp, nonce, and key_id from headers + headers = CaseInsensitiveDict(event.get('headers') or {}) + timestamp = headers.get('X-Timestamp', '') + nonce = headers.get('X-Nonce', '') + key_id = headers.get('X-Key-Id', '') + + # Build signature string with newlines + return '\n'.join([http_method, path, sorted_params, timestamp, nonce, key_id]) + + +def _validate_and_store_nonce(compact: str, jurisdiction: str, nonce: str) -> None: + """ + Validate that a nonce has not been used before and store it to prevent reuse. + + This function uses a conditional write to DynamoDB to atomically check if the nonce + already exists and store it if it doesn't. If the nonce already exists, it raises + a CCUnauthorizedException. + + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction abbreviation + :param nonce: The nonce to validate and store + :raises CCUnauthorizedException: If the nonce has already been used + """ + try: + # Calculate TTL based on 3x the configured signature clock skew + ttl = int(config.current_standard_datetime.timestamp()) + (3 * config.signature_max_clock_skew_seconds) + + # Attempt to store the nonce with a condition that it doesn't already exist + config.rate_limiting_table.put_item( + Item={ + 'pk': f'NONCE#{compact}#JURISDICTION#{jurisdiction}', + 'sk': f'NONCE#{nonce}', + 'ttl': ttl, + }, + ConditionExpression=Attr('pk').not_exists() & Attr('sk').not_exists(), + ) + + logger.debug('Nonce stored successfully', compact=compact, jurisdiction=jurisdiction, nonce=nonce) + + except ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + logger.warning( + 'Nonce reuse detected', + compact=compact, + jurisdiction=jurisdiction, + nonce=nonce, + ) + raise CCUnauthorizedCustomResponseException('Nonce has already been used') from e + logger.error('Failed to validate nonce', error=str(e), compact=compact, jurisdiction=jurisdiction) + raise CCUnauthorizedException('Failed to validate nonce') from e + + +def _verify_signature(signature_string: str, signature_b64: str, public_key_pem: str) -> bool: + """ + Verify the ECDSA signature using the provided public key. + + :param signature_string: The string that was signed + :param signature_b64: Base64-encoded signature + :param public_key_pem: PEM-encoded public key + :return: True if signature is valid, False otherwise + """ + try: + # Load the public key + public_key = serialization.load_pem_public_key(public_key_pem.encode()) + + # Decode the signature + signature_bytes = base64.b64decode(signature_b64) + + # Verify the signature + public_key.verify(signature_bytes, signature_string.encode(), ec.ECDSA(hashes.SHA256())) + + return True + except (InvalidSignature, ValueError): + logger.debug('Signature verification failed - invalid signature or format') + return False diff --git a/backend/social-work-app/lambdas/python/common/cc_common/utils.py b/backend/social-work-app/lambdas/python/common/cc_common/utils.py new file mode 100644 index 0000000000..5ed3d794cb --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/cc_common/utils.py @@ -0,0 +1,1008 @@ +import json +import time +from collections import UserDict +from collections.abc import Callable +from datetime import date +from decimal import Decimal +from functools import wraps +from json import JSONEncoder +from re import match +from types import MethodType +from typing import Any +from uuid import UUID + +import requests +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError +from marshmallow import ValidationError + +from cc_common.config import config, logger, metrics +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.common import CCPermissionsAction +from cc_common.data_model.schema.provider.api import ProviderGeneralResponseSchema, ProviderReadPrivateResponseSchema +from cc_common.exceptions import ( + CCAccessDeniedException, + CCInternalException, + CCInvalidRequestCustomResponseException, + CCInvalidRequestException, + CCNotFoundException, + CCRateLimitingException, + CCUnauthorizedCustomResponseException, + CCUnauthorizedException, + CCUnsupportedMediaTypeException, +) + + +class ResponseEncoder(JSONEncoder): + """JSON Encoder to handle data types that come out of our schema""" + + def default(self, o): + if isinstance(o, Decimal): + ratio = o.as_integer_ratio() + if ratio[1] == 1: + return ratio[0] + return float(o) + + if isinstance(o, UUID): + return str(o) + + if isinstance(o, date): + return o.isoformat() + + if isinstance(o, set): + return list(o) + + # This is just a catch-all that shouldn't realistically ever be reached. + return super().default(o) + + +class CaseInsensitiveDict(UserDict): + """ + Dictionary that enforces case-insensitive keys + + To accommodate HTTP2 vs HTTP1.1 behavior RE header capitalization + https://www.rfc-editor.org/rfc/rfc7540#section-8.1.2 + """ + + def __init__(self, in_dict: dict[str, Any], /): + if in_dict: + # Force all keys to lowercase + super().__init__({k.lower(): v for k, v in in_dict.items()}) + else: + super().__init__({}) + + def pop(self, key: str, default=None): + return super().pop(key.lower(), default) + + def __setitem__(self, key: str, value): + super().__setitem__(key.lower(), value) + + def __getitem__(self, key: str): + return super().__getitem__(key.lower()) + + def get(self, key: str, default=None): + return super().get(key.lower(), default) + + +def api_handler(fn: Callable): + """Decorator to wrap an api gateway event handler in standard logging, HTTPError handling. + + - Logs each access + - JSON-encodes returned responses + - Translates CCBaseException subclasses to their respective HTTP response codes + """ + + @wraps(fn) + @metrics.log_metrics + @logger.inject_lambda_context + def caught_handler(event, context: LambdaContext): + event['headers'] = CaseInsensitiveDict(event.get('headers') or {}) + # We have to jump through extra hoops to handle the case where APIGW sets headers to null + (event.get('headers') or {}).pop('Authorization', None) + (event.get('multiValueHeaders') or {}).pop('Authorization', None) + + # Determine the appropriate CORS origin header value + origin = event['headers'].get('Origin') + if origin in config.allowed_origins: + cors_origin = origin + else: + cors_origin = config.allowed_origins[0] + + content_type = event['headers'].get('Content-Type') + + # Propagate these keys to all log messages in this with block + with logger.append_context_keys( + method=event['httpMethod'], + origin=origin, + path=event['requestContext']['resourcePath'], + content_type=content_type, + identity={'user': event['requestContext'].get('authorizer', {}).get('claims', {}).get('sub')}, + query_params=event['queryStringParameters'], + username=event['requestContext'].get('authorizer', {}).get('claims', {}).get('cognito:username'), + ): + logger.info('Incoming request') + + try: + # We'll enforce json-only content for the whole API, right here. + if event.get('body') is not None and content_type != 'application/json': + raise CCUnsupportedMediaTypeException(f'Unsupported media type: {content_type}') + + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 200, + 'body': json.dumps(fn(event, context), cls=ResponseEncoder), + } + except CCUnauthorizedCustomResponseException as e: + logger.info('Unauthorized request', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 401, + 'body': json.dumps({'message': e.message}), + } + except CCUnauthorizedException as e: + logger.info('Unauthorized request', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 401, + 'body': json.dumps({'message': 'Unauthorized'}), + } + except CCAccessDeniedException as e: + logger.info('Forbidden request', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 403, + 'body': json.dumps({'message': 'Access denied'}), + } + except CCNotFoundException as e: + logger.info('Resource not found', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 404, + 'body': json.dumps({'message': f'{e.message}'}), + } + except CCUnsupportedMediaTypeException as e: + logger.info('Unsupported media type', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 415, + 'body': json.dumps({'message': 'Unsupported media type'}), + } + except CCRateLimitingException as e: + logger.info('Rate limiting request', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 429, + 'body': json.dumps({'message': e.message}), + } + except CCInvalidRequestCustomResponseException as e: + logger.info('Invalid request with custom response') + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 400, + 'body': json.dumps(e.response_body, cls=ResponseEncoder), + } + except CCInvalidRequestException as e: + logger.info('Invalid request', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 400, + 'body': json.dumps({'message': e.message}), + } + except json.JSONDecodeError as e: + logger.warning('Invalid JSON in request body', exc_info=e) + return { + 'headers': {'Access-Control-Allow-Origin': cors_origin, 'Vary': 'Origin'}, + 'statusCode': 400, + 'body': json.dumps({'message': 'Invalid request: Malformed JSON'}), + } + except ClientError as e: + # Any boto3 ClientErrors we haven't already caught and transformed are probably on us + logger.error('boto3 ClientError', response=e.response, exc_info=e) + raise + except Exception as e: + logger.warning( + 'Error processing request', + exc_info=e, + ) + raise + + return caught_handler + + +class logger_inject_kwargs: # noqa: N801 invalid-name + """Decorator to inject kwargs into the logger context""" + + def __init__(self, logger: Logger, *arg_names: tuple[str, ...]): + if not isinstance(logger, Logger): + raise ValueError('logger must be an instance of Logger') + self.logger = logger + self.arg_names = arg_names + + def __get__(self, instance, owner): + return MethodType(self, instance) + + def __call__(self, fn: Callable): + @wraps(fn) + def wrapped(*args, **kwargs): + if not self.arg_names: + raise ValueError('No argument names provided to logger_inject_kwargs') + with self.logger.append_context_keys(**{k: kwargs.get(k) for k in self.arg_names}): + return fn(*args, **kwargs) + + return wrapped + + +class authorize_compact_level_only_action: # noqa: N801 invalid-name + """Authorize endpoint by matching path parameter compact to the expected scope limited to compact level + (i.e. socw/admin). + + This wrapper should be used when we want to explicitly restrict access to callers with permission scopes + at the compact level. + """ + + def __init__(self, action: str): + super().__init__() + self.action = action + + def __call__(self, fn: Callable): + @wraps(fn) + @logger.inject_lambda_context + def authorized(event: dict, context: LambdaContext): + try: + resource_value = event['pathParameters']['compact'] + except KeyError as e: + logger.error('Access attempt with missing path parameter!') + raise CCInvalidRequestException('Missing path parameter!') from e + + logger.debug('Checking authorizer context', request_context=event['requestContext']) + try: + scopes = event['requestContext']['authorizer']['claims']['scope'].split(' ') + except KeyError as e: + logger.error('Unauthorized access attempt!', exc_info=e) + raise CCUnauthorizedException('Unauthorized access attempt!') from e + + required_scope = f'{resource_value}/{self.action}' + if required_scope not in scopes: + logger.warning('Forbidden access attempt!') + raise CCAccessDeniedException('Forbidden access attempt!') + return fn(event, context) + + return authorized + + +class authorize_state_level_only_action: # noqa: N801 invalid-name + """Authorize endpoint by matching path parameter compact to the expected scope limited to state level + (i.e. oh/{compact}.admin). + + This wrapper should be used when we want to explicitly restrict access to callers with permission scopes + at the state level. + """ + + def __init__(self, action: str): + super().__init__() + self.action = action + + def __call__(self, fn: Callable): + @wraps(fn) + @logger.inject_lambda_context + def authorized(event: dict, context: LambdaContext): + try: + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + except KeyError as e: + logger.error('Access attempt with missing path parameter!') + raise CCInvalidRequestException('Missing path parameter!') from e + + logger.debug('Checking authorizer context', request_context=event['requestContext']) + try: + scopes = event['requestContext']['authorizer']['claims']['scope'].split(' ') + except KeyError as e: + logger.error('Unauthorized access attempt!', exc_info=e) + raise CCUnauthorizedException('Unauthorized access attempt!') from e + + required_scope = f'{jurisdiction}/{compact}.{self.action}' + if required_scope not in scopes: + logger.warning('Forbidden access attempt!') + raise CCAccessDeniedException('Forbidden access attempt!') + + return fn(event, context) + + return authorized + + +class authorize_compact: # noqa: N801 invalid-name + """Authorize endpoint by matching path parameter compact to the expected scope + + This wrapper checks if the caller has the permission at either the compact or jurisdiction level for the compact + (i.e. socw/write or oh/socw.write). + """ + + def __init__(self, action: str): + super().__init__() + self.action = action + + def __call__(self, fn: Callable): + @wraps(fn) + @logger.inject_lambda_context + def authorized(event: dict, context: LambdaContext): + try: + compact = event['pathParameters']['compact'] + except KeyError as e: + logger.error('Access attempt with missing path parameter!') + raise CCInvalidRequestException('Missing path parameter!') from e + + logger.debug('Checking authorizer context', request_context=event['requestContext']) + try: + scopes: list[str] = event['requestContext']['authorizer']['claims']['scope'].split(' ') + except KeyError as e: + logger.error('Unauthorized access attempt!', exc_info=e) + raise CCUnauthorizedException('Unauthorized access attempt!') from e + + compact_level_required_scope = f'{compact}/{self.action}' + jurisdiction_level_required_scope = f'/{compact}.{self.action}' + for scope in scopes: + if compact_level_required_scope == scope or scope.endswith(jurisdiction_level_required_scope): + return fn(event, context) + logger.warning('Forbidden access attempt!') + raise CCAccessDeniedException('Forbidden access attempt!') + + return authorized + + +def _authorize_compact_with_scope(event: dict, resource_parameter: str, scope_parameter: str, action: str) -> None: + """ + Check the authorization of the user attempting to access the endpoint. + + There are three types of action level permissions which can be granted to a user: + + 1. read: Allows the user to read data from the compact. + 2. write: Allows the user to write data to the compact. + 3. admin: Allows the user to perform administrative actions on the compact. + + For each of these actions, specific rules apply to the scope required to perform the action, which are + as follows: + + ReadGeneral - granted at compact level, allows read access to all generally available (not private) jurisdiction + data within the compact. + i.e. socw/readGeneral would allow read access to all generally available jurisdiction data within the socw compact. + + Write - granted at jurisdiction level, allows write access to a specific jurisdiction within the compact. + i.e. oh/socw.write would allow write access to the ohio jurisdiction within the socw compact. + + Admin - granted at compact level and jurisdiction level, allows administrative access to either a specific + compact or a specific jurisdiction within the compact. + i.e. 'socw/admin' would allow administrative access to the socw compact. 'oh/socw.admin' would allow + administrative access to the ohio jurisdiction within the socw compact. + + :param dict event: The event object passed to the lambda function. + :param str resource_parameter: The value of the resource parameter in the path. + :param str scope_parameter: The value of the scope parameter in the path. + :param str action: The action we want to ensure the user has permissions for. + :raises CCUnauthorizedException: If the user is missing scope claims. + :raises CCAccessDeniedException: If the user does not have the necessary access. + """ + try: + resource_value = event['pathParameters'][resource_parameter] + if scope_parameter != resource_parameter: + scope_value = event['pathParameters'][scope_parameter] + else: + # if the scope parameter is the same as the resource parameter, + # we use the resource value as the scope value + scope_value = resource_value + except KeyError as e: + logger.error('Access attempt with missing path parameters!') + raise CCInvalidRequestException('Missing path parameter!') from e + + try: + scopes = event['requestContext']['authorizer']['claims']['scope'].split(' ') + except KeyError as e: + logger.error('Unauthorized access attempt!', exc_info=e) + raise CCUnauthorizedException('Unauthorized access attempt!') from e + + required_scope = f'{resource_value}/{scope_value}.{action}' + if required_scope not in scopes: + logger.warning('Forbidden access attempt!', scopes=scopes, required_scope=required_scope) + raise CCAccessDeniedException('Forbidden access attempt!') + + +class authorize_compact_jurisdiction: # noqa: N801 invalid-name + """ + Authorize endpoint by matching path parameters compact and jurisdiction to the expected scope. + (i.e. oh/socw.write) + """ + + def __init__(self, action: str): + super().__init__() + self.resource_parameter = 'jurisdiction' + self.scope_parameter = 'compact' + self.action = action + + def __call__(self, fn: Callable): + @wraps(fn) + @logger.inject_lambda_context + def authorized(event: dict, context: LambdaContext): + _authorize_compact_with_scope(event, self.resource_parameter, self.scope_parameter, self.action) + return fn(event, context) + + return authorized + + +def sqs_handler(fn: Callable): + """Process messages from an SQS queue. + + This handler uses batch item failure reporting: + https://docs.aws.amazon.com/lambda/latest/dg/example_serverless_SQS_Lambda_batch_item_failures_section.html + This allows the queue to continue to scale under load, even if a number of the messages are failing. It + also improves efficiency, as we don't have to throw away the entire batch for a single failure. + """ + + @wraps(fn) + @metrics.log_metrics + @logger.inject_lambda_context + def process_messages(event, context: LambdaContext): # noqa: ARG001 unused-argument + records = event['Records'] + logger.info('Starting batch', batch_count=len(records)) + batch_failures = [] + for record in records: + try: + message = json.loads(record['body']) + logger.info( + 'Processing message', + message_id=record['messageId'], + message_attributes=record.get('messageAttributes'), + ) + # No exception here means success + fn(message) + # When we receive a batch of messages from SQS, letting an exception escape all the way back to AWS is + # really undesirable. Instead, we're going to catch _almost_ any exception raised, note what message we + # were processing, and report those item failures back to AWS. + except Exception as e: # noqa: BLE001 broad-exception-caught + logger.error('Failed to process message', exc_info=e) + batch_failures.append({'itemIdentifier': record['messageId']}) + logger.info('Completed batch', batch_failures=len(batch_failures)) + return {'batchItemFailures': batch_failures} + + return process_messages + + +def sqs_batch_handler(fn: Callable): + """Process a batch of messages from an SQS queue, passing all messages to the handler at once. + + This handler is similar to sqs_handler but passes ALL messages to the decorated function + at once, allowing for batch processing, deduplication, and bulk operations. The decorated + function is responsible for returning the batchItemFailures response directly. + + This handler uses batch item failure reporting: + https://docs.aws.amazon.com/lambda/latest/dg/example_serverless_SQS_Lambda_batch_item_failures_section.html + + The decorated function receives a list of records, where each record contains: + - 'messageId': The SQS message ID (used for batch item failure reporting) + - 'body': The parsed JSON body of the SQS message + + The decorated function must return: {'batchItemFailures': [{'itemIdentifier': messageId}, ...]} + """ + + @wraps(fn) + @metrics.log_metrics + @logger.inject_lambda_context + def process_messages(event, context: LambdaContext): # noqa: ARG001 unused-argument + sqs_records = event.get('Records', []) + logger.info('Starting batch processing', batch_count=len(sqs_records)) + + if not sqs_records: + logger.info('No records to process') + return {'batchItemFailures': []} + + # Parse all SQS message bodies and create records with messageId for failure tracking + records = [] + for sqs_record in sqs_records: + message_id = sqs_record['messageId'] + try: + body = json.loads(sqs_record['body']) + records.append({'messageId': message_id, 'body': body}) + except json.JSONDecodeError as e: + # If we can't parse the message body, log error but don't fail the whole batch + logger.error('Failed to parse SQS message body', message_id=message_id, exc_info=e) + # We can't process this message, but we also shouldn't retry it since it's malformed + # So we don't add it to failures - it will be deleted from the queue + + # Call the decorated function with all parsed records + # The function is responsible for returning {'batchItemFailures': [...]} + return fn(records) + + return process_messages + + +def sqs_handler_with_notification_tracking(fn: Callable): + """ + Process messages from SQS with notification tracking capabilities. + + This decorator provides a generic pattern for tracking notification delivery state + across SQS message retries. It creates a NotificationTracker and passes it as a + parameter to the handler function. + + The handler function should accept (message: dict, tracker: NotificationTracker) as parameters. + """ + + @wraps(fn) + @metrics.log_metrics + @logger.inject_lambda_context + def process_messages(event, context: LambdaContext): # noqa: ARG001 unused-argument + records = event['Records'] + logger.info('Starting batch with notification tracking', batch_count=len(records)) + batch_failures = [] + + for record in records: + try: + message = json.loads(record['body']) + message_id = record['messageId'] + + # Extract compact from message detail for notification tracking + compact = message.get('detail', {}).get('compact') + if not compact: + logger.warning('No compact found in message, skipping notification tracking', message_id=message_id) + # Still process the message but without tracking + fn(message, None) + continue + + # Create notification tracker and pass as parameter + from cc_common.event_state_client import NotificationTracker + + tracker = NotificationTracker(compact=compact, message_id=message_id) + + logger.info( + 'Processing message with notification tracking', + message_id=message_id, + compact=compact, + message_attributes=record.get('messageAttributes'), + ) + fn(message, tracker) + + # When we receive a batch of messages from SQS, letting an exception escape all the way back to AWS is + # really undesirable. Instead, we're going to catch _almost_ any exception raised, note what message we + # were processing, and report those item failures back to AWS. + except Exception as e: # noqa: BLE001 broad-exception-caught + logger.error( + 'Failed to process message with notification tracking', + exception=str(e), + message_id=record['messageId'], + ) + batch_failures.append({'itemIdentifier': record['messageId']}) + + logger.info('Completed batch', batch_failures=len(batch_failures)) + return {'batchItemFailures': batch_failures} + + return process_messages + + +def delayed_function(delay_seconds: float): + """ + Delay the result of the decorated function by the specified number of seconds. + + This decorator ensures consistent response times for security-sensitive endpoints, + helping to prevent timing attacks by making all responses take the same amount of time + regardless of the execution path taken. + + :param float delay_seconds: The minimum number of seconds the function should take to return + """ + + def decorator(fn: Callable): + @wraps(fn) + def wrapper(*args, **kwargs): + start_time = time.time() + try: + result = fn(*args, **kwargs) + except Exception as e: + # Even if an exception occurs, we still need to maintain consistent timing + elapsed_time = time.time() - start_time + remaining_time = delay_seconds - elapsed_time + if remaining_time > 0: + time.sleep(remaining_time) + raise e + + # Calculate how much time has elapsed and sleep for the remainder + elapsed_time = time.time() - start_time + remaining_time = delay_seconds - elapsed_time + if remaining_time > 0: + time.sleep(remaining_time) + + return result + + return wrapper + + return decorator + + +def get_allowed_jurisdictions(*, compact: str, scopes: set[str]) -> list[str] | None: + """Return a list of jurisdictions the user is allowed to access based on their scopes. If the scopes indicate + the user is a compact admin, the function will return None, as they will do no jurisdiction-based filtering. + :param str compact: The compact the user is trying to access. + :param set scopes: The user's scopes from the request. + :return: A list of jurisdictions the user is allowed to access, or None, if no filtering is needed. + :rtype: list + """ + if f'{compact}/admin' in scopes: + # The user has compact-level admin, so no jurisdiction filtering + return None + + compact_jurisdictions = [] + scope_pattern = f'([a-z]*)/{compact}.admin' + for scope in scopes: + if match_obj := match(scope_pattern, scope): + compact_jurisdictions.append(match_obj.group(1)) + return compact_jurisdictions + + +def get_event_scopes(event: dict): + """ + Get the scopes from the event object and return them as a list. + + :param dict event: The event object passed to the lambda function. + :return: The scopes from the event object. + """ + return set(event['requestContext']['authorizer']['claims']['scope'].split(' ')) + + +def collect_and_authorize_changes(*, path_compact: str, scopes: set, compact_changes: dict) -> dict: + """Transform PATCH user API changes to permissions into db operation changes. Operation changes are checked + against the provided scopes to ensure the user is allowed to make the requested changes. + :param str path_compact: The compact declared in the url path + :param set scopes: The scopes associated with the user making the request + :param dict compact_changes: Permissions changes in the request body + Example: + { + 'actions': { + 'admin': True, + 'read': False + }, + 'jurisdictions': { + 'oh': { + 'actions': { + 'admin': True, + 'write': False + } + } + } + } + :return: Changes to the User's underlying record + :rtype: dict + """ + compact_action_additions = set() + compact_action_removals = set() + jurisdiction_action_additions = {} + jurisdiction_action_removals = {} + + # Collect compact-wide permission changes + for action, value in compact_changes.get('actions', {}).items(): + if action == CCPermissionsAction.ADMIN and f'{path_compact}/{CCPermissionsAction.ADMIN}' not in scopes: + raise CCAccessDeniedException('Only compact admins can affect compact-level admin permissions') + if action == CCPermissionsAction.READ_PRIVATE and f'{path_compact}/{CCPermissionsAction.ADMIN}' not in scopes: + raise CCAccessDeniedException('Only compact admins can affect compact-level access to private information') + + # dropping the read action as this is now implicitly granted to all users + if action == CCPermissionsAction.READ: + logger.info('Dropping "read" action as this is implicitly granted to all users') + continue + # Any admin in the compact can affect read permissions, so no read-specific check is necessary here + if value: + compact_action_additions.add(action) + else: + compact_action_removals.add(action) + + # Collect jurisdiction-specific changes + for jurisdiction, jurisdiction_changes in compact_changes.get('jurisdictions', {}).items(): + if not { + f'{path_compact}/{CCPermissionsAction.ADMIN}', + f'{jurisdiction}/{path_compact}.{CCPermissionsAction.ADMIN}', + }.intersection(scopes): + raise CCAccessDeniedException( + f'Only {path_compact} or {jurisdiction}/{path_compact} admins can affect {jurisdiction}/{path_compact} ' + 'permissions', + ) + + # verify that the jurisdiction is in the list of active jurisdictions for the compact + active_jurisdictions = config.compact_configuration_client.get_active_compact_jurisdictions( + compact=path_compact + ) + active_jurisdictions_postal_abbreviations = [ + jurisdiction['postalAbbreviation'].lower() for jurisdiction in active_jurisdictions + ] + if jurisdiction.lower() not in active_jurisdictions_postal_abbreviations: + raise CCInvalidRequestException( + f"'{jurisdiction.upper()}' is not a valid jurisdiction for '{path_compact.upper()}' compact" + ) + + for action, value in jurisdiction_changes.get('actions', {}).items(): + # dropping the read action as this is now implicitly granted to all users + if action == CCPermissionsAction.READ: + logger.info('Dropping "read" action as this is implicitly granted to all users') + continue + + if value: + jurisdiction_action_additions.setdefault(jurisdiction, set()).add(action) + else: + jurisdiction_action_removals.setdefault(jurisdiction, set()).add(action) + + return { + 'compact_action_additions': compact_action_additions, + 'compact_action_removals': compact_action_removals, + 'jurisdiction_action_additions': jurisdiction_action_additions, + 'jurisdiction_action_removals': jurisdiction_action_removals, + } + + +def get_sub_from_user_attributes(attributes: list): + for attribute in attributes: + if attribute['Name'] == 'sub': + return attribute['Value'] + raise ValueError('Failed to find user sub!') + + +def caller_is_compact_admin(compact: str, caller_scopes: set[str]) -> bool: + if f'{compact}/{CCPermissionsAction.ADMIN}' in caller_scopes: + logger.debug('User has admin permission at compact level', compact=compact, scopes=caller_scopes) + return True + + return False + + +def _user_has_read_private_access_for_provider(compact: str, provider_information: dict, scopes: set[str]) -> bool: + return _user_has_permission_for_action_on_user( + action=CCPermissionsAction.READ_PRIVATE, + compact=compact, + provider_information=provider_information, + scopes=scopes, + ) + + +def _user_has_permission_for_action_on_user( + action: str, compact: str, provider_information: dict, scopes: set[str] +) -> bool: + if f'{compact}/{action}' in scopes: + logger.debug( + f'User has {action} permission at compact level', + compact=compact, + provider_id=provider_information['providerId'], + ) + return True + + # iterate through the users privileges and licenses and create a set out of all the jurisdictions + relevant_provider_jurisdictions = set() + for privilege in provider_information.get('privileges', []): + relevant_provider_jurisdictions.add(privilege['jurisdiction']) + for license_record in provider_information.get('licenses', []): + relevant_provider_jurisdictions.add(license_record['jurisdiction']) + + for jurisdiction in relevant_provider_jurisdictions: + if f'{jurisdiction}/{compact}.{action}' in scopes: + logger.debug( + f'User has {action} permission at jurisdiction level', + compact=compact, + provider_id=provider_information['providerId'], + jurisdiction=jurisdiction, + ) + return True + + logger.debug( + f'Caller does not have {action} permission at compact or jurisdiction level', + provider_id=provider_information['providerId'], + ) + return False + + +def sanitize_provider_data_based_on_caller_scopes(compact: str, provider: dict, scopes: set[str]) -> dict: + """ + Take a provider and a set of user scopes, then return a provider, with information sanitized based on what + the user is authorized to view. + + :param str compact: The compact the user is trying to access. + :param dict provider: The provider record to be sanitized. + :param set scopes: The caller's scopes from the request. + :return: The provider record, sanitized based on the user's scopes. + """ + + caller_is_admin = caller_is_compact_admin(compact, caller_scopes=scopes) + + # Currently, the UI bundles permissions for admins, granting them the readPrivate scope along with admin. Should + # this ever change, we will need to account for that here. This 'or' conditional is a precautionary measure to keep + # UI changes from unintentionally breaking existing functionality + if caller_is_admin or _user_has_read_private_access_for_provider( + compact=compact, provider_information=provider, scopes=scopes + ): + provider_read_private_schema = ProviderReadPrivateResponseSchema() + # we filter the record to ensure that we are only returning the desired fields + return provider_read_private_schema.load(provider) + + logger.debug( + 'Caller does not have readPrivate at compact or jurisdiction level, removing private information', + provider_id=provider['providerId'], + ) + provider_read_general_schema = ProviderGeneralResponseSchema() + # we filter the record to ensure that the schema is applied to the record to remove private fields + return provider_read_general_schema.load(provider) + + +def send_licenses_to_preprocessing_queue(licenses_data: list[dict], event_time: str) -> list[str]: + """ + Send license data to the preprocessing queue in batches. + + This function batches license data and sends it to the preprocessing queue using the SQS batch send_messages method. + It handles chunking the data into batches of 10 (SQS batch limit) and tracks failures. + + :param list[dict] licenses_data: List of SERIALIZED license data to send (must be serialized using the + dump method of the LicensePostRequestSchema) + :param str event_time: ISO formatted event time string + :return: list of license numbers that failed to be ingested (if any) + """ + # Track failures + failed_license_numbers = [] + + # Process in batches of 10 (SQS batch limit) + batch_size = 10 + for i in range(0, len(licenses_data), batch_size): + batch = licenses_data[i : i + batch_size] + + # Prepare batch entries + entries = [] + for idx, license_data in enumerate(batch): + message_body = json.dumps( + { + 'eventTime': event_time, + **license_data, + } + ) + entries.append( + { + 'Id': f'msg-{idx}', # Unique ID for each message in the batch + 'MessageBody': message_body, + } + ) + + try: + # Send batch to preprocessing queue + response = config.license_preprocessing_queue.send_messages(Entries=entries) + + # Check for failed messages + for failed in response.get('Failed', []): + failed_index = int(failed['Id'].split('-')[-1]) + failed_license_number = batch[failed_index].get('licenseNumber', 'unknown') + failed_license_numbers.append(failed_license_number) + logger.error(f'Failed to send message to preprocessing queue: {failed.get("Message", "Unknown error")}') + except ClientError as e: + # If the entire batch fails, count all messages as failed + failed_license_numbers.extend(license_data.get('licenseNumber', 'unknown') for license_data in batch) + logger.error(f'Error sending batch to preprocessing queue: {str(e)}') + + # Return success status and failure count + return failed_license_numbers + + +def load_records_into_schemas(records: list[dict]): + """Load records into their defined schema""" + try: + return [BaseRecordSchema.get_schema_by_type(item['type']).load(item) for item in records] + except ValidationError as e: + logger.error('Validation error', error=e) + raise CCInternalException('Data validation failure!') from e + except KeyError as e: + logger.error('Key error', error=e) + raise CCInternalException('Key error!') from e + + +def get_provider_user_attributes_from_authorizer_claims(event: dict) -> tuple[str, str]: + try: + # the two values for compact and providerId are stored as custom attributes in the user's cognito claims + # so we can access them directly from the event object + compact = event['requestContext']['authorizer']['claims']['custom:compact'] + provider_id = event['requestContext']['authorizer']['claims']['custom:providerId'] + except (KeyError, TypeError) as e: + # This shouldn't happen unless a provider user was created without these custom attributes. + logger.error(f'Missing custom provider attribute: {e}') + raise CCInvalidRequestException('Missing required user profile attribute') from e + + return compact, provider_id + + +# Module level variable for caching +_RECAPTCHA_SECRET = None + + +def _get_recaptcha_secret() -> str: + """Get the reCAPTCHA secret from Secrets Manager with module-level caching.""" + global _RECAPTCHA_SECRET + if _RECAPTCHA_SECRET is None: + logger.info('Loading reCAPTCHA secret') + try: + _RECAPTCHA_SECRET = json.loads( + config.secrets_manager_client.get_secret_value( + SecretId=f'compact-connect/env/{config.environment_name}/recaptcha/token' + )['SecretString'] + )['token'] + except Exception as e: + logger.error('Failed to load reCAPTCHA secret', error=str(e)) + raise CCInternalException('Failed to load reCAPTCHA secret') from e + return _RECAPTCHA_SECRET + + +def verify_recaptcha(token: str) -> bool: + """Verify the reCAPTCHA token with Google's API.""" + + # Sandbox environments don't always have recaptcha configured, but our persistent environments + # do. This checks if we are running in a sandbox environment. Else we call the Google verification endpoint + if config.environment_name.lower() not in ['test', 'beta', 'prod']: + return True + + try: + response = requests.post( + 'https://www.google.com/recaptcha/api/siteverify', + data={ + 'secret': _get_recaptcha_secret(), + 'response': token, + }, + timeout=5, + ) + return response.json().get('success', False) + except ClientError as e: + logger.error('Failed to verify reCAPTCHA token', error=str(e)) + return False + + +# Module level PasswordHasher instance for password/token hashing +_password_hasher = PasswordHasher() + + +def hash_password(password: str) -> str: + """ + Hash a password or sensitive token using Argon2. + + Uses the argon2-cffi library with recommended parameters for secure password hashing. + This provides protection against brute force and password hash recovery attacks + as required by OWASP ASVS v3.0 requirement 2.13. + + :param str password: The plaintext password or token to hash + :return: The Argon2 hash string + :rtype: str + """ + return _password_hasher.hash(password) + + +def verify_password(hashed_password: str, password: str) -> bool: + """ + Verify a plaintext password against an Argon2 hash. + + :param str hashed_password: The Argon2 hash to verify against + :param str password: The plaintext password to verify + :return: True if password matches the hash, False otherwise + :rtype: bool + """ + try: + _password_hasher.verify(hashed_password, password) + return True + except VerifyMismatchError: + # This is expected when passwords don't match + return False + except Exception as e: + logger.error('Failed to verify password', error=str(e)) + raise CCInternalException('Failed to verify password') from e + + +def to_uuid(uuid: str, on_error: str) -> UUID: + """ + Parse a str to a UUID, raising CCInvalidRequestException if invalid. + + This should be used for all UUID path parameters to validate and normalize + input before processing, preventing malformed UUIDs from causing unexpected + errors deeper in the application. + + :param str uuid: The string representation of a UUID to parse + :param str on_error: Custom error message to include in the exception + :return: A validated UUID object + :raises CCInvalidRequestException: If the string is not a valid UUID + """ + try: + return UUID(uuid) + except ValueError as e: + raise CCInvalidRequestException(on_error) from e diff --git a/backend/social-work-app/lambdas/python/common/common_test/__init__.py b/backend/social-work-app/lambdas/python/common/common_test/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/common_test/sign_request.py b/backend/social-work-app/lambdas/python/common/common_test/sign_request.py new file mode 100644 index 0000000000..1a9baa38b2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/common_test/sign_request.py @@ -0,0 +1,77 @@ +""" +Client Reference Implementation for Signature Request Signing + +This module provides a validated reference implementation for signing API requests +using ECDSA with SHA-256 as required by the CompactConnect signature authentication system. + +The sign_request function in this module is tested and validated against the actual +authentication system, making it a reliable reference for client implementations. + +""" + +import base64 +from urllib.parse import quote + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec + + +def sign_request( + method: str, + path: str, + query_params: dict, + timestamp: str, + nonce: str, + key_id: str, + private_key_pem: str, +) -> dict: + """ + Sign a request using ECDSA with SHA-256. + + This function provides a reference implementation for clients to understand + how to properly sign requests for the CompactConnect signature authentication system. + + The signature string is constructed as: + HTTP_METHOD\nREQUEST_PATH\nSORTED_QUERY_PARAMETERS\nTIMESTAMP\nNONCE\nKEY_ID + + Where query parameters are sorted alphabetically and URL-encoded. + + :param method: HTTP method (e.g., 'GET', 'POST') + :param path: Request path (e.g., '/v1/compacts/socw/jurisdictions/al/providers/query') + :param query_params: Dictionary of query parameters + :param timestamp: ISO 8601 timestamp in UTC (e.g., '2024-01-15T10:30:00Z' or '2024-01-15T10:30:00+00:00') + :param nonce: Unique nonce (e.g., UUID4 string) + :param key_id: Key identifier for the signing key + :param private_key_pem: PEM-encoded ECDSA private key + :return: Dictionary containing headers to add to request + """ + + signature_string = get_string_to_sign(method, path, query_params, timestamp, nonce, key_id) + + # Load private key + private_key = serialization.load_pem_private_key(private_key_pem.encode(), password=None) + + # Sign the string using ECDSA + signature = private_key.sign(signature_string.encode(), ec.ECDSA(hashes.SHA256())) + + # Return headers to add to request + return { + 'X-Algorithm': 'ECDSA-SHA256', + 'X-Timestamp': timestamp, + 'X-Nonce': nonce, + 'X-Key-Id': key_id, + 'X-Signature': base64.b64encode(signature).decode(), + } + + +def get_string_to_sign(method: str, path: str, query_params: dict, timestamp: str, nonce: str, key_id: str) -> str: + """ + Get the string to sign for a request. + """ + # Sort and URL-encode query parameters + sorted_params = '&'.join( + f'{quote(str(k), safe="")}={quote(str(v), safe="")}' for k, v in sorted(query_params.items()) + ) + + # Create signature string + return '\n'.join([method, path, sorted_params, timestamp, nonce, key_id]) diff --git a/backend/social-work-app/lambdas/python/common/common_test/test_constants.py b/backend/social-work-app/lambdas/python/common/common_test/test_constants.py new file mode 100644 index 0000000000..766ee46990 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/common_test/test_constants.py @@ -0,0 +1,112 @@ +# following timestamp can be used for consistent mocking of dateOfUpdate timestamps +DEFAULT_DATE_OF_UPDATE_TIMESTAMP = '2024-11-08T23:59:59+00:00' +DEFAULT_EFFECTIVE_DATE = '2024-11-08' + +# Default values used throughout tests +DEFAULT_PROVIDER_ID = '89a6377e-c3a5-40e5-bca5-317ec854c570' +DEFAULT_NPI = '0608337260' +DEFAULT_SSN_LAST_FOUR = '1234' +DEFAULT_GIVEN_NAME = 'Björk' +DEFAULT_MIDDLE_NAME = 'Gunnar' +DEFAULT_FAMILY_NAME = 'Guðmundsdóttir' +DEFAULT_DATE_OF_BIRTH = '1985-06-06' +DEFAULT_PROVIDER_UPDATE_DATETIME = '2024-07-08T23:59:59+00:00' +DEFAULT_LICENSE_UPDATE_DATETIME = '2024-06-06T12:59:59+00:00' +DEFAULT_LICENSE_EXPIRATION_DATE = '2025-04-04' +DEFAULT_LICENSE_ISSUANCE_DATE = '2010-06-06' +DEFAULT_LICENSE_RENEWAL_DATE = '2020-04-04' +DEFAULT_LICENSE_JURISDICTION = 'oh' +DEFAULT_PRIVILEGE_JURISDICTION = 'ne' +DEFAULT_COMPACT = 'socw' +DEFAULT_LICENSE_TYPE = 'cosmetologist' +DEFAULT_LICENSE_TYPE_ABBREVIATION = 'cos' +DEFAULT_LICENSE_NUMBER = 'A0608337260' +DEFAULT_LICENSE_STATUS = 'active' +DEFAULT_LICENSE_STATUS_NAME = 'DEFINITELY_A_HUMAN' +DEFAULT_COMPACT_ELIGIBILITY = 'eligible' +DEFAULT_PRIVILEGE_ISSUANCE_DATETIME = '2016-05-05T12:59:59+00:00' +DEFAULT_PRIVILEGE_RENEWAL_DATETIME = '2020-05-05T12:59:59+00:00' +DEFAULT_PRIVILEGE_EXPIRATION_DATE = '2025-04-04' +DEFAULT_PRIVILEGE_UPDATE_DATETIME = '2020-05-05T12:59:59+00:00' +DEFAULT_COMPACT_TRANSACTION_ID = '1234567890' +DEFAULT_COMPACT_TRANSACTION_BATCH = { + 'batchId': '67890', + 'settlementState': 'settledSuccessfully', + 'settlementTimeLocal': '2024-01-01T09:00:00', + 'settlementTimeUTC': '2024-01-01T13:00:00.000Z', +} +DEFAULT_COMPACT_TRANSACTION_PRIVILEGE_LINE_ITEM = { + 'description': 'Compact Privilege for Ohio', + 'itemId': 'priv:socw-oh', + 'name': 'Ohio Compact Privilege', + 'quantity': '1.0', + 'taxable': 'False', + 'unitPrice': '100.00', + 'privilegeId': 'mock-privilege-id-oh', +} + + +DEFAULT_HOME_SELECTION_DATE = '2024-01-01T00:00:00+00:00' +DEFAULT_HOME_UPDATE_DATE = '2024-01-01T00:00:00+00:00' + +# Default address values +DEFAULT_HOME_ADDRESS_STREET1 = '123 A St.' +DEFAULT_HOME_ADDRESS_STREET2 = 'Apt 321' +DEFAULT_HOME_ADDRESS_CITY = 'Columbus' +DEFAULT_HOME_ADDRESS_STATE = 'oh' +DEFAULT_HOME_ADDRESS_POSTAL_CODE = '43004' + +# Default contact information +DEFAULT_EMAIL_ADDRESS = 'björk@example.com' +DEFAULT_PHONE_NUMBER = '+13213214321' + +# record type constants +ADVERSE_ACTION_RECORD_TYPE = 'adverseAction' +LICENSE_RECORD_TYPE = 'license' +LICENSE_UPDATE_RECORD_TYPE = 'licenseUpdate' +PROVIDER_RECORD_TYPE = 'provider' +PROVIDER_UPDATE_RECORD_TYPE = 'providerUpdate' +TRANSACTION_RECORD_TYPE = 'transaction' + + +# Privilege update default values +DEFAULT_PRIVILEGE_UPDATE_TYPE = 'renewal' +DEFAULT_PRIVILEGE_UPDATE_DATE_OF_UPDATE = '2020-05-05T12:59:59+00:00' +DEFAULT_PRIVILEGE_UPDATE_PREVIOUS_DATE_OF_UPDATE = '2016-05-05T12:59:59+00:00' +DEFAULT_PRIVILEGE_UPDATE_PREVIOUS_DATE_OF_EXPIRATION = '2020-06-06' +DEFAULT_PRIVILEGE_UPDATE_PREVIOUS_DATE_OF_RENEWAL = '2016-05-05T12:59:59+00:00' + +# License update default values +DEFAULT_LICENSE_UPDATE_TYPE = 'renewal' +DEFAULT_LICENSE_UPDATE_CREATE_DATE = '2024-11-08T23:59:59+00:00' +DEFAULT_LICENSE_UPDATE_EFFECTIVE_DATETIME = '2024-11-08T23:59:59+00:00' +DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE = '2020-04-07T12:59:59+00:00' +DEFAULT_LICENSE_UPDATE_PREVIOUS_DATE_OF_UPDATE = '2020-06-06T12:59:59+00:00' +DEFAULT_LICENSE_UPDATE_PREVIOUS_DATE_OF_EXPIRATION = '2020-06-06' +DEFAULT_LICENSE_UPDATE_PREVIOUS_DATE_OF_RENEWAL = '2015-06-06' + +# Provider update default values +DEFAULT_PROVIDER_UPDATE_TYPE = 'registration' + +# Adverse Action defaults +DEFAULT_ACTION_AGAINST_PRIVILEGE = 'privilege' +DEFAULT_BLOCKS_FUTURE_PRIVILEGES = True +DEFAULT_ENCUMBRANCE_TYPE = 'suspension' +DEFAULT_CLINICAL_PRIVILEGE_ACTION_CATEGORY = 'fraud' +DEFAULT_CREATION_EFFECTIVE_DATE = '2024-02-15' +DEFAULT_CREATION_DATE = '2024-02-15T10:30:00+00:00' +DEFAULT_AA_SUBMITTING_USER_ID = '12a6377e-c3a5-40e5-bca5-317ec854c556' +DEFAULT_ADVERSE_ACTION_ID = '98765432-9876-9876-9876-987654321098' + +# Investigation defaults +DEFAULT_INVESTIGATION_AGAINST_PRIVILEGE = 'privilege' +DEFAULT_INVESTIGATION_AGAINST_LICENSE = 'license' +DEFAULT_INVESTIGATION_START_DATE = '2024-02-15' +DEFAULT_INVESTIGATION_ID = '98765432-9876-9876-9876-987654321098' + + +# Default administrator status +DEFAULT_ADMINISTRATOR_SET_STATUS = 'active' + +# Default Dynamo PK/SKs +DEFAULT_PROVIDER_PK = 'socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570' diff --git a/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py b/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py new file mode 100644 index 0000000000..396298547c --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py @@ -0,0 +1,451 @@ +# ruff: noqa: F403, F405 star import of test constants file +import json +from datetime import date, datetime + +from boto3.dynamodb.conditions import Key +from cc_common.data_model.schema.adverse_action import AdverseActionData +from cc_common.data_model.schema.common import CCDataClass +from cc_common.data_model.schema.compact import CompactConfigurationData +from cc_common.data_model.schema.investigation import InvestigationData +from cc_common.data_model.schema.jurisdiction import JurisdictionConfigurationData +from cc_common.data_model.schema.license import LicenseData, LicenseUpdateData +from cc_common.data_model.schema.provider import ProviderData +from cc_common.utils import ResponseEncoder + +from common_test.test_constants import * + + +class TestDataGenerator: + """ + This class provides a collection of methods for generating test data with options + for varying the data according to the needs of the tests. + """ + + @staticmethod + def convert_data_to_api_response_formatted_dict(data_class: CCDataClass) -> dict: + """Helper method used to convert data class data into a format that matches response formats from the API.""" + return json.loads(json.dumps(data_class.to_dict(), cls=ResponseEncoder)) + + @staticmethod + def generate_test_api_event( + sub_override: str | None = None, scope_override: str | None = None, value_overrides: dict | None = None + ) -> dict: + """Generate a test API event + + We separate the sub and scope overrides from the value overrides to avoid having to pass in the entire + request context for every test. + + :param sub_override: Optional override for the cognito sub + :param scope_override: Optional override for the cognito scopes + :param value_overrides: Optional overrides for the API event + :return: A test API event + """ + from pathlib import Path + + fixture_path = Path(__file__).parent.parent / 'tests' / 'resources' / 'api-event.json' + with open(fixture_path) as f: + api_event = json.load(f) + + if value_overrides: + api_event.update(value_overrides) + + if sub_override: + api_event['requestContext']['authorizer']['claims']['sub'] = sub_override + + if scope_override: + api_event['requestContext']['authorizer']['claims']['scope'] = scope_override + + return api_event + + @staticmethod + def load_provider_data_record_from_database(data_class: CCDataClass) -> dict: + """ + Helper method to load a data record from the database using the provider data class instance. + + This leverages the fact that your expected object should have the same pk/sk values as the actual record that + is stored in the database as a result of your test run. + """ + from cc_common.config import config + + serialized_record = data_class.serialize_to_database_record() + + try: + return config.provider_table.get_item(Key={'pk': serialized_record['pk'], 'sk': serialized_record['sk']})[ + 'Item' + ] + except KeyError as e: + raise Exception('Error loading test provider record from database') from e + + @staticmethod + def _query_records_by_pk_and_sk_prefix(pk: str, sk_prefix: str) -> list[dict]: + """ + Helper method to query records from the database using the provider data class instance. + """ + from cc_common.config import config + + try: + return config.provider_table.query( + KeyConditionExpression=Key('pk').eq(pk) & Key('sk').begins_with(sk_prefix) + )['Items'] + except KeyError as e: + raise Exception('Error querying update records from database') from e + + @staticmethod + def query_provider_update_records_for_given_record_from_database(provider_record: ProviderData) -> list[dict]: + """ + Helper method to query update records from the database using the provider data class instance. + + All of our update records use the same pk as the actual record that is being updated. The sk of the actual + record is the prefix for all the update records. Using this pattern, we can query for all of the update records + that have been written for the given record. + """ + serialized_record = provider_record.serialize_to_database_record() + + sk_prefix = f'{provider_record.compact}#UPDATE#2#provider' + + return TestDataGenerator._query_records_by_pk_and_sk_prefix(serialized_record['pk'], sk_prefix) + + @staticmethod + def query_license_update_records_for_given_record_from_database( + license_data: LicenseData, + ) -> list[LicenseUpdateData]: + """ + Helper method to query update records from the database using the license data class instance. + + All of our update records use the same pk as the actual record that is being updated. The sk prefix + for license updates follows the tier pattern: {compact}#UPDATE#3#license/{jurisdiction}/{license_type_abbr}/ + """ + serialized_record = license_data.serialize_to_database_record() + from cc_common.config import config + + license_type_abbr = config.license_type_abbreviations[license_data.compact][license_data.licenseType] + sk_prefix = f'{license_data.compact}#UPDATE#3#license/{license_data.jurisdiction}/{license_type_abbr}/' + + license_update_records = TestDataGenerator._query_records_by_pk_and_sk_prefix( + serialized_record['pk'], sk_prefix + ) + + return [LicenseUpdateData.from_database_record(update_record) for update_record in license_update_records] + + @staticmethod + def generate_default_adverse_action(value_overrides: dict | None = None) -> AdverseActionData: + """Generate a default adverse action""" + default_adverse_actions = { + 'providerId': DEFAULT_PROVIDER_ID, + 'compact': DEFAULT_COMPACT, + 'type': ADVERSE_ACTION_RECORD_TYPE, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'actionAgainst': DEFAULT_ACTION_AGAINST_PRIVILEGE, + 'encumbranceType': DEFAULT_ENCUMBRANCE_TYPE, + 'clinicalPrivilegeActionCategories': [DEFAULT_CLINICAL_PRIVILEGE_ACTION_CATEGORY], + 'effectiveStartDate': date.fromisoformat(DEFAULT_CREATION_EFFECTIVE_DATE), + 'submittingUser': DEFAULT_AA_SUBMITTING_USER_ID, + 'creationDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'adverseActionId': DEFAULT_ADVERSE_ACTION_ID, + } + if value_overrides: + default_adverse_actions.update(value_overrides) + + return AdverseActionData.create_new(default_adverse_actions) + + @staticmethod + def generate_default_investigation(value_overrides: dict | None = None) -> InvestigationData: + """Generate a default investigation""" + default_investigation = { + 'providerId': DEFAULT_PROVIDER_ID, + 'compact': DEFAULT_COMPACT, + 'type': 'investigation', + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'investigationAgainst': DEFAULT_INVESTIGATION_AGAINST_PRIVILEGE, + 'createDate': date.fromisoformat(DEFAULT_INVESTIGATION_START_DATE), + 'submittingUser': DEFAULT_AA_SUBMITTING_USER_ID, + 'creationDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'investigationId': DEFAULT_INVESTIGATION_ID, + } + if value_overrides: + default_investigation.update(value_overrides) + + return InvestigationData.create_new(default_investigation) + + @staticmethod + def put_default_investigation_record_in_provider_table(value_overrides: dict | None = None) -> InvestigationData: + investigation = TestDataGenerator.generate_default_investigation(value_overrides) + investigation_record = investigation.serialize_to_database_record() + + TestDataGenerator.store_record_in_provider_table(investigation_record) + + return investigation + + @staticmethod + def put_default_adverse_action_record_in_provider_table(value_overrides: dict | None = None) -> AdverseActionData: + adverse_action = TestDataGenerator.generate_default_adverse_action(value_overrides) + adverse_action_record = adverse_action.serialize_to_database_record() + + TestDataGenerator.store_record_in_provider_table(adverse_action_record) + + return adverse_action + + @staticmethod + def generate_default_license(value_overrides: dict | None = None) -> LicenseData: + """Generate a default license""" + default_license = { + 'providerId': DEFAULT_PROVIDER_ID, + 'compact': DEFAULT_COMPACT, + 'type': LICENSE_RECORD_TYPE, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'licenseNumber': DEFAULT_LICENSE_NUMBER, + 'ssnLastFour': DEFAULT_SSN_LAST_FOUR, + 'givenName': DEFAULT_GIVEN_NAME, + 'middleName': DEFAULT_MIDDLE_NAME, + 'familyName': DEFAULT_FAMILY_NAME, + 'dateOfUpdate': datetime.fromisoformat(DEFAULT_LICENSE_UPDATE_DATETIME), + 'dateOfIssuance': date.fromisoformat(DEFAULT_LICENSE_ISSUANCE_DATE), + 'dateOfRenewal': date.fromisoformat(DEFAULT_LICENSE_RENEWAL_DATE), + 'dateOfExpiration': date.fromisoformat(DEFAULT_LICENSE_EXPIRATION_DATE), + 'dateOfBirth': date.fromisoformat(DEFAULT_DATE_OF_BIRTH), + 'homeAddressStreet1': DEFAULT_HOME_ADDRESS_STREET1, + 'homeAddressStreet2': DEFAULT_HOME_ADDRESS_STREET2, + 'homeAddressCity': DEFAULT_HOME_ADDRESS_CITY, + 'homeAddressState': DEFAULT_HOME_ADDRESS_STATE, + 'homeAddressPostalCode': DEFAULT_HOME_ADDRESS_POSTAL_CODE, + 'emailAddress': DEFAULT_EMAIL_ADDRESS, + 'phoneNumber': DEFAULT_PHONE_NUMBER, + 'licenseStatusName': DEFAULT_LICENSE_STATUS_NAME, + 'jurisdictionUploadedLicenseStatus': DEFAULT_LICENSE_STATUS, + 'jurisdictionUploadedCompactEligibility': DEFAULT_COMPACT_ELIGIBILITY, + } + if value_overrides: + default_license.update(value_overrides) + + return LicenseData.create_new(default_license) + + @staticmethod + def put_default_license_record_in_provider_table( + value_overrides: dict | None = None, date_of_update_override: str = None + ) -> LicenseData: + license_data = TestDataGenerator.generate_default_license(value_overrides) + license_record = license_data.serialize_to_database_record() + if date_of_update_override: + license_record['dateOfUpdate'] = date_of_update_override + + TestDataGenerator.store_record_in_provider_table(license_record) + + return license_data + + @staticmethod + def generate_default_license_update( + value_overrides: dict | None = None, previous_license: LicenseData | None = None + ) -> LicenseUpdateData: + """Generate a default license update""" + if previous_license is None: + previous_license = TestDataGenerator.generate_default_license() + + license_update = { + 'updateType': DEFAULT_LICENSE_UPDATE_TYPE, + 'providerId': DEFAULT_PROVIDER_ID, + 'compact': DEFAULT_COMPACT, + 'type': LICENSE_UPDATE_RECORD_TYPE, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'createDate': datetime.fromisoformat(DEFAULT_LICENSE_UPDATE_CREATE_DATE), + 'effectiveDate': datetime.fromisoformat(DEFAULT_LICENSE_UPDATE_EFFECTIVE_DATETIME), + 'previous': previous_license.to_dict(), + 'updatedValues': { + 'dateOfRenewal': date.fromisoformat(DEFAULT_LICENSE_RENEWAL_DATE), + 'dateOfExpiration': date.fromisoformat(DEFAULT_LICENSE_EXPIRATION_DATE), + }, + } + if value_overrides: + license_update.update(value_overrides) + + return LicenseUpdateData.create_new(license_update) + + @staticmethod + def put_default_license_update_record_in_provider_table( + value_overrides: dict | None = None, + ) -> LicenseUpdateData: + """ + Creates a default license update and stores it in the provider table. + """ + update_data = TestDataGenerator.generate_default_license_update(value_overrides) + update_record = update_data.serialize_to_database_record() + + TestDataGenerator.store_record_in_provider_table(update_record) + + return update_data + + @staticmethod + def store_record_in_provider_table(record: dict) -> None: + from cc_common.config import config + + config.provider_table.put_item(Item=record) + + @staticmethod + def get_license_type_abbr_for_license_type(compact: str, license_type: str) -> str: + from cc_common.config import config + + return config.license_type_abbreviations[compact][license_type] + + @staticmethod + def generate_default_provider(value_overrides: dict | None = None) -> ProviderData: + """Generate a default provider""" + default_provider = { + 'providerId': DEFAULT_PROVIDER_ID, + 'compact': DEFAULT_COMPACT, + 'type': PROVIDER_RECORD_TYPE, + 'licenseJurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'jurisdictionUploadedLicenseStatus': DEFAULT_LICENSE_STATUS, + 'jurisdictionUploadedCompactEligibility': DEFAULT_COMPACT_ELIGIBILITY, + 'ssnLastFour': DEFAULT_SSN_LAST_FOUR, + 'givenName': DEFAULT_GIVEN_NAME, + 'middleName': DEFAULT_MIDDLE_NAME, + 'familyName': DEFAULT_FAMILY_NAME, + 'dateOfExpiration': date.fromisoformat(DEFAULT_LICENSE_EXPIRATION_DATE), + 'dateOfBirth': date.fromisoformat(DEFAULT_DATE_OF_BIRTH), + } + + if value_overrides: + default_provider.update(value_overrides) + + return ProviderData.create_new(default_provider) + + @staticmethod + def put_default_provider_record_in_provider_table( + value_overrides: dict | None = None, date_of_update_override: str = None + ) -> ProviderData: + """ + Creates a default provider record and stores it in the provider table. + + :param value_overrides: Optional dictionary to override default values + :param date_of_update_override: optional date for date of update to be shown on provider record + :return: The ProviderData instance that was stored + """ + provider_data = TestDataGenerator.generate_default_provider(value_overrides) + provider_record = provider_data.serialize_to_database_record() + if date_of_update_override: + provider_record['dateOfUpdate'] = date_of_update_override + # Also override providerDateOfUpdate since it's a computed field used by the GSI + provider_record['providerDateOfUpdate'] = date_of_update_override + + TestDataGenerator.store_record_in_provider_table(provider_record) + + return provider_data + + @staticmethod + def _override_date_of_update_for_record(data_class: CCDataClass, date_of_update: datetime): + # we have to access this here, as in runtime code dateOfUpdate is not to be modified + data_class._data['dateOfUpdate'] = date_of_update # noqa: SLF001 + + @staticmethod + def generate_default_compact_configuration(value_overrides: dict | None = None) -> CompactConfigurationData: + """Generate a default compact configuration""" + default_compact_config = { + 'compactAbbr': DEFAULT_COMPACT, + 'compactName': 'Social Work', + 'compactOperationsTeamEmails': ['ops@example.com'], + 'compactAdverseActionsNotificationEmails': ['adverse@example.com'], + 'licenseeRegistrationEnabled': True, + 'configuredStates': [], + } + if value_overrides: + default_compact_config.update(value_overrides) + + return CompactConfigurationData.create_new(default_compact_config) + + @staticmethod + def put_default_compact_configuration_in_configuration_table( + value_overrides: dict | None = None, + ) -> CompactConfigurationData: + """ + Creates a default compact configuration record and stores it in the configuration table. + + :param value_overrides: Optional dictionary to override default values + :return: The CompactConfigurationData instance that was stored + """ + compact_config = TestDataGenerator.generate_default_compact_configuration(value_overrides) + compact_config_record = compact_config.serialize_to_database_record() + + from cc_common.config import config + + config.compact_configuration_table.put_item(Item=compact_config_record) + + return compact_config + + @staticmethod + def generate_default_jurisdiction_configuration( + value_overrides: dict | None = None, + ) -> JurisdictionConfigurationData: + """Generate a default jurisdiction configuration""" + default_jurisdiction_config = { + 'compact': 'socw', + 'postalAbbreviation': 'ky', + 'jurisdictionName': 'Kentucky', + 'jurisdictionOperationsTeamEmails': ['state-ops@example.com'], + 'jurisdictionAdverseActionsNotificationEmails': ['state-adverse@example.com'], + 'licenseeRegistrationEnabled': True, + } + if value_overrides: + default_jurisdiction_config.update(value_overrides) + + return JurisdictionConfigurationData.create_new(default_jurisdiction_config) + + @staticmethod + def put_default_jurisdiction_configuration_in_configuration_table( + value_overrides: dict | None = None, + ) -> JurisdictionConfigurationData: + """ + Creates a default jurisdiction configuration record and stores it in the configuration table. + + :param value_overrides: Optional dictionary to override default values + :return: The JurisdictionConfigurationData instance that was stored + """ + jurisdiction_config = TestDataGenerator.generate_default_jurisdiction_configuration(value_overrides) + jurisdiction_config_record = jurisdiction_config.serialize_to_database_record() + + from cc_common.config import config + + config.compact_configuration_table.put_item(Item=jurisdiction_config_record) + + return jurisdiction_config + + @staticmethod + def put_compact_active_member_jurisdictions( + compact: str = DEFAULT_COMPACT, postal_abbreviations: list[str] = None + ) -> list[dict]: + """ + Creates and stores active member jurisdictions for a compact in the configuration table. + + :param compact: The compact abbreviation + :param postal_abbreviations: List of jurisdiction postal abbreviations + :return: The list of active member jurisdictions that was stored + """ + from cc_common.config import config + from cc_common.data_model.compact_configuration_utils import CompactConfigUtility + + if postal_abbreviations is None: + postal_abbreviations = ['ky', 'oh', 'ne'] # Default jurisdictions if none provided + + # Format member jurisdictions into the expected shape + formatted_jurisdictions = [] + for jurisdiction in postal_abbreviations: + jurisdiction_name = CompactConfigUtility.get_jurisdiction_name(postal_abbr=jurisdiction) + formatted_jurisdictions.append( + {'jurisdictionName': jurisdiction_name, 'postalAbbreviation': jurisdiction, 'compact': compact} + ) + + # Create the item to store + item = { + 'pk': f'COMPACT#{compact}#ACTIVE_MEMBER_JURISDICTIONS', + 'sk': f'COMPACT#{compact}#ACTIVE_MEMBER_JURISDICTIONS', + 'active_member_jurisdictions': formatted_jurisdictions, + } + + # Store in the table + config.compact_configuration_table.put_item(Item=item) + + return formatted_jurisdictions diff --git a/backend/social-work-app/lambdas/python/common/requirements-dev.in b/backend/social-work-app/lambdas/python/common/requirements-dev.in new file mode 100644 index 0000000000..1bc9e5c364 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/requirements-dev.in @@ -0,0 +1,6 @@ +# Keep attrs on 25.x to match root requirements.txt (jsii/cattrs); avoids pip-sync conflicts. +attrs>=25.4,<26 +moto[all]>=5.0.12, <6 +boto3-stubs[full] +Faker>=40, <41 +cryptography>=48, <49 diff --git a/backend/social-work-app/lambdas/python/common/requirements-dev.txt b/backend/social-work-app/lambdas/python/common/requirements-dev.txt new file mode 100644 index 0000000000..199aa276fe --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/requirements-dev.txt @@ -0,0 +1,185 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/common/requirements-dev.in +# +annotated-types==0.7.0 + # via pydantic +antlr4-python3-runtime==4.13.2 + # via moto +attrs==25.4.0 + # via + # -r lambdas/python/common/requirements-dev.in + # jsonschema + # referencing +aws-sam-translator==1.109.0 + # via cfn-lint +aws-xray-sdk==2.15.0 + # via moto +boto3==1.43.7 + # via + # aws-sam-translator + # moto +boto3-stubs[full]==1.43.7 + # via -r lambdas/python/common/requirements-dev.in +boto3-stubs-full==1.43.7 + # via boto3-stubs +botocore==1.43.7 + # via + # aws-xray-sdk + # boto3 + # moto + # s3transfer +botocore-stubs==1.42.41 + # via boto3-stubs +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +cfn-lint==1.51.0 + # via moto +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via + # -r lambdas/python/common/requirements-dev.in + # joserfc + # moto +docker==7.1.0 + # via moto +faker==40.15.0 + # via -r lambdas/python/common/requirements-dev.in +graphql-core==3.2.8 + # via moto +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +joserfc==1.6.5 + # via moto +jsonpatch==1.33 + # via cfn-lint +jsonpath-ng==1.8.0 + # via moto +jsonpointer==3.1.1 + # via jsonpatch +jsonschema==4.26.0 + # via + # aws-sam-translator + # moto + # openapi-schema-validator + # openapi-spec-validator +jsonschema-path==0.4.6 + # via openapi-spec-validator +jsonschema-specifications==2025.9.1 + # via + # jsonschema + # openapi-schema-validator +lazy-object-proxy==1.12.0 + # via openapi-spec-validator +markupsafe==3.0.3 + # via werkzeug +moto[all]==5.2.1 + # via -r lambdas/python/common/requirements-dev.in +mpmath==1.3.0 + # via sympy +multipart==1.3.1 + # via moto +networkx==3.6.1 + # via cfn-lint +openapi-schema-validator==0.8.1 + # via openapi-spec-validator +openapi-spec-validator==0.8.5 + # via moto +pathable==0.5.0 + # via jsonschema-path +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +pydantic==2.12.5 + # via + # aws-sam-translator + # openapi-schema-validator + # openapi-spec-validator + # pydantic-settings +pydantic-core==2.41.5 + # via pydantic +pydantic-settings==2.14.1 + # via + # openapi-schema-validator + # openapi-spec-validator +pyparsing==3.3.2 + # via moto +python-dateutil==2.9.0.post0 + # via botocore +python-dotenv==1.2.2 + # via pydantic-settings +pyyaml==6.0.3 + # via + # cfn-lint + # jsonschema-path + # moto + # responses +referencing==0.37.0 + # via + # jsonschema + # jsonschema-path + # jsonschema-specifications + # openapi-schema-validator +regex==2026.5.9 + # via cfn-lint +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +rfc3339-validator==0.1.4 + # via openapi-schema-validator +rpds-py==0.30.0 + # via + # jsonschema + # referencing +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via + # python-dateutil + # rfc3339-validator +sympy==1.14.0 + # via cfn-lint +types-awscrt==0.31.3 + # via botocore-stubs +types-s3transfer==0.16.0 + # via boto3-stubs +typing-extensions==4.15.0 + # via + # aws-sam-translator + # cfn-lint + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-settings +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +wrapt==2.1.2 + # via aws-xray-sdk +xmltodict==1.0.4 + # via moto + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/backend/social-work-app/lambdas/python/common/requirements.in b/backend/social-work-app/lambdas/python/common/requirements.in new file mode 100644 index 0000000000..dc017c3abc --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/requirements.in @@ -0,0 +1,6 @@ +argon2-cffi>=25.1.0, <26.0.0 +aws-lambda-powertools>=3.5.0, <4 +boto3>=1.34.33, <2 +cryptography>=48, <49 +marshmallow>=4.3.0, <5.0.0 +requests>=2.31.0, <3.0.0 diff --git a/backend/social-work-app/lambdas/python/common/requirements.txt b/backend/social-work-app/lambdas/python/common/requirements.txt new file mode 100644 index 0000000000..358ba0f181 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/requirements.txt @@ -0,0 +1,53 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/common/requirements.in +# +argon2-cffi==25.1.0 + # via -r lambdas/python/common/requirements.in +argon2-cffi-bindings==25.1.0 + # via argon2-cffi +aws-lambda-powertools==3.29.0 + # via -r lambdas/python/common/requirements.in +boto3==1.43.7 + # via -r lambdas/python/common/requirements.in +botocore==1.43.7 + # via + # boto3 + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via + # argon2-cffi-bindings + # cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via -r lambdas/python/common/requirements.in +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # aws-lambda-powertools + # boto3 + # botocore +marshmallow==4.3.0 + # via -r lambdas/python/common/requirements.in +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +requests==2.34.1 + # via -r lambdas/python/common/requirements.in +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +typing-extensions==4.15.0 + # via aws-lambda-powertools +urllib3==2.7.0 + # via + # botocore + # requests diff --git a/backend/social-work-app/lambdas/python/common/tests/__init__.py b/backend/social-work-app/lambdas/python/common/tests/__init__.py new file mode 100644 index 0000000000..0eeb9b053e --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/__init__.py @@ -0,0 +1,112 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'false', + 'API_BASE_URL': 'https://api.example.com', + 'ALLOWED_ORIGINS': '["https://example.org", "http://localhost:1234"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'BULK_BUCKET_NAME': 'cc-license-data-bulk-bucket', + 'EVENT_BUS_NAME': 'license-data-events', + 'EVENT_STATE_TABLE_NAME': 'event-state-table', + 'PROVIDER_TABLE_NAME': 'provider-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': 'email-notification-service', + 'TRANSACTION_HISTORY_TABLE_NAME': 'transaction-history-table', + 'ENVIRONMENT_NAME': 'test', + 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', + 'FAM_GIV_INDEX_NAME': 'famGiv', + 'USER_POOL_ID': 'us-east-1-12345', + 'USERS_TABLE_NAME': 'users-table', + 'SSN_TABLE_NAME': 'ssn-table', + 'SSN_INDEX_NAME': 'ssn-index', + 'LICENSE_PREPROCESSING_QUEUE_URL': 'license-preprocessing-queue-url', + 'RATE_LIMITING_TABLE_NAME': 'rate-limiting-table', + 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': json.dumps( + [ + 'al', + 'ak', + 'az', + 'ar', + 'ca', + 'co', + 'ct', + 'de', + 'dc', + 'fl', + 'ga', + 'hi', + 'id', + 'il', + 'in', + 'ia', + 'ks', + 'ky', + 'la', + 'me', + 'md', + 'ma', + 'mi', + 'mn', + 'ms', + 'mo', + 'mt', + 'ne', + 'nv', + 'nh', + 'nj', + 'nm', + 'ny', + 'nc', + 'nd', + 'oh', + 'ok', + 'or', + 'pa', + 'pr', + 'ri', + 'sc', + 'sd', + 'tn', + 'tx', + 'ut', + 'vt', + 'va', + 'vi', + 'wa', + 'wv', + 'wi', + 'wy', + ] + ), + 'LICENSE_TYPES': json.dumps( + { + 'socw': [ + {'name': 'cosmetologist', 'abbreviation': 'cos'}, + {'name': 'esthetician', 'abbreviation': 'esth'}, + ] + }, + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + from common_test.test_data_generator import TestDataGenerator + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) + cls.test_data_generator = TestDataGenerator diff --git a/backend/social-work-app/lambdas/python/common/tests/function/__init__.py b/backend/social-work-app/lambdas/python/common/tests/function/__init__.py new file mode 100644 index 0000000000..3a4bfa3ec1 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/function/__init__.py @@ -0,0 +1,367 @@ +import json +import logging +import os +from decimal import Decimal +from glob import glob + +import boto3 +from boto3.dynamodb.types import TypeDeserializer +from faker import Faker +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + + self.faker = Faker(['en_US', 'ja_JP', 'es_MX']) + self.build_resources() + + self.addCleanup(self.delete_resources) + + import cc_common.config + from common_test.test_data_generator import TestDataGenerator + + cc_common.config.config = cc_common.config._Config() # noqa: SLF001 protected-access + self.config = cc_common.config.config + self.test_data_generator = TestDataGenerator + + def build_resources(self): + self.create_compact_configuration_table() + self.create_provider_table() + self.create_ssn_table() + self.create_users_table() + self.create_transaction_history_table() + self.create_license_preprocessing_queue() + self.create_rate_limiting_table() + self.create_event_state_table() + + # Adding a waiter allows for testing against an actual AWS account, if needed + waiter = self._compact_configuration_table.meta.client.get_waiter('table_exists') + waiter.wait(TableName=self._compact_configuration_table.name) + waiter.wait(TableName=self._provider_table.name) + waiter.wait(TableName=self._users_table.name) + waiter.wait(TableName=self._transaction_history_table.name) + waiter.wait(TableName=self._rate_limiting_table.name) + waiter.wait(TableName=self._event_state_table.name) + # Create a new Cognito user pool + cognito_client = boto3.client('cognito-idp') + user_pool_name = 'TestUserPool' + user_pool_response = cognito_client.create_user_pool( + PoolName=user_pool_name, + AliasAttributes=['email'], + UsernameAttributes=['email'], + Policies={ + 'PasswordPolicy': { + 'MinimumLength': 12, + 'RequireUppercase': False, + 'RequireLowercase': True, + 'RequireNumbers': True, + 'RequireSymbols': False, + }, + }, + ) + os.environ['USER_POOL_ID'] = user_pool_response['UserPool']['Id'] + self._user_pool_id = user_pool_response['UserPool']['Id'] + + def create_compact_configuration_table(self): + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def create_event_state_table(self): + self._event_state_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerId', 'AttributeType': 'S'}, + {'AttributeName': 'eventTime', 'AttributeType': 'S'}, + ], + TableName=os.environ['EVENT_STATE_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': 'providerId-eventTime-index', + 'KeySchema': [ + {'AttributeName': 'providerId', 'KeyType': 'HASH'}, + {'AttributeName': 'eventTime', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + } + ], + ) + + def create_users_table(self): + self._users_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'famGiv', 'AttributeType': 'RANGE'}, + ], + TableName=os.environ['USERS_TABLE_NAME'], + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['FAM_GIV_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'famGiv', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + ) + + def create_ssn_table(self): + self._ssn_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerIdGSIpk', 'AttributeType': 'S'}, + ], + TableName=os.environ['SSN_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['SSN_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'providerIdGSIpk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + + def create_provider_table(self): + self._provider_table = boto3.resource('dynamodb').create_table( + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerFamGivMid', 'AttributeType': 'S'}, + {'AttributeName': 'providerDateOfUpdate', 'AttributeType': 'S'}, + ], + TableName=os.environ['PROVIDER_TABLE_NAME'], + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['PROV_FAM_GIV_MID_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerFamGivMid', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerDateOfUpdate', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + + def create_transaction_history_table(self): + self._transaction_history_table = boto3.resource('dynamodb').create_table( + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['TRANSACTION_HISTORY_TABLE_NAME'], + BillingMode='PAY_PER_REQUEST', + ) + + def create_license_preprocessing_queue(self): + self._license_preprocessing_queue = boto3.resource('sqs').create_queue(QueueName='workflow-queue') + os.environ['LICENSE_PREPROCESSING_QUEUE_URL'] = self._license_preprocessing_queue.url + + def delete_resources(self): + self._compact_configuration_table.delete() + self._provider_table.delete() + self._ssn_table.delete() + self._users_table.delete() + self._transaction_history_table.delete() + self._license_preprocessing_queue.delete() + self._rate_limiting_table.delete() + self._event_state_table.delete() + + waiter = self._users_table.meta.client.get_waiter('table_not_exists') + waiter.wait(TableName=self._compact_configuration_table.name) + waiter.wait(TableName=self._provider_table.name) + waiter.wait(TableName=self._users_table.name) + waiter.wait(TableName=self._transaction_history_table.name) + waiter.wait(TableName=self._ssn_table.name) + waiter.wait(TableName=self._rate_limiting_table.name) + waiter.wait(TableName=self._event_state_table.name) + + # Delete the Cognito user pool + cognito_client = boto3.client('cognito-idp') + cognito_client.delete_user_pool(UserPoolId=self._user_pool_id) + + def _load_compact_configuration_data(self): + """Use the canned test resources to load compact and jurisdiction information into the DB""" + test_resources = [ + 'tests/resources/dynamo/compact.json', + 'tests/resources/dynamo/jurisdiction.json', + ] + + for resource in test_resources: + with open(resource) as f: + record = json.load(f, parse_float=Decimal) + + logger.debug('Loading resource, %s: %s', resource, str(record)) + # compact and jurisdiction records go in the compact configuration table + self._compact_configuration_table.put_item(Item=record) + + def _load_provider_data(self) -> str: + """Use the canned test resources to load a basic provider to the DB""" + test_resources = glob('../common/tests/resources/dynamo/provider.json') + + for resource in test_resources: + with open(resource) as f: + record = json.load(f, parse_float=Decimal) + + logger.debug('Loading resource, %s: %s', resource, str(record)) + self._provider_table.put_item(Item=record) + return record['providerId'] + + def _load_license_data(self, status: str = 'active', expiration_date: str = None): + """Use the canned test resources to load a basic provider to the DB""" + license_test_resources = ['../common/tests/resources/dynamo/license.json'] + + for resource in license_test_resources: + with open(resource) as f: + record = json.load(f, parse_float=Decimal) + record['jurisdictionStatus'] = status + if expiration_date: + record['dateOfExpiration'] = expiration_date + + logger.debug('Loading resource, %s: %s', resource, str(record)) + self._provider_table.put_item(Item=record) + + def _load_user_data(self) -> str: + with open('tests/resources/dynamo/user.json') as f: + # This item is saved in its serialized form, so we have to deserialize it first + item = TypeDeserializer().deserialize({'M': json.load(f)}) + + logger.info('Loading user: %s', item) + self._users_table.put_item(Item=item) + return item['userId'] + + def _create_compact_staff_user(self, compacts: list[str]): + """Create a compact-staff style user for each jurisdiction in the provided compact.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + schema = UserRecordSchema() + + email = self.faker.unique.email() + sub = self._create_cognito_user(email=email) + for compact in compacts: + logger.info('Writing compact %s permissions for %s', compact, email) + self._users_table.put_item( + Item=schema.dump( + { + 'userId': sub, + 'compact': compact, + 'status': StaffUserStatus.INACTIVE.value, + 'attributes': { + 'email': email, + 'familyName': self.faker.unique.last_name(), + 'givenName': self.faker.unique.first_name(), + }, + 'permissions': {'actions': {'read'}, 'jurisdictions': {}}, + }, + ), + ) + return sub + + def _create_board_staff_users(self, compacts: list[str], jurisdiction_list: list[str] = None): + """Create a board-staff style user for each jurisdiction in the provided compact. + + :param compacts: List of compact abbreviations + :param jurisdiction_list: Optional list of jurisdictions to use, defaults to ['oh', 'ne', 'ky'] + """ + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + schema = UserRecordSchema() + + # Use default jurisdictions if none provided + jurisdictions = jurisdiction_list or ['oh', 'ne', 'ky'] + + for jurisdiction in jurisdictions: + email = self.faker.unique.email() + sub = self._create_cognito_user(email=email) + for compact in compacts: + logger.info('Writing board %s/%s permissions for %s', compact, jurisdiction, email) + self._users_table.put_item( + Item=schema.dump( + { + 'userId': sub, + 'compact': compact, + 'status': StaffUserStatus.INACTIVE.value, + 'attributes': { + 'email': email, + 'familyName': self.faker.unique.last_name(), + 'givenName': self.faker.unique.first_name(), + }, + 'permissions': self._create_write_permissions(jurisdiction), + }, + ), + ) + + def _create_cognito_user(self, *, email: str): + from cc_common.utils import get_sub_from_user_attributes + + user_data = self.config.cognito_client.admin_create_user( + UserPoolId=self.config.user_pool_id, + Username=email, + UserAttributes=[{'Name': 'email', 'Value': email}, {'Name': 'email_verified', 'Value': 'True'}], + DesiredDeliveryMediums=['EMAIL'], + ) + return get_sub_from_user_attributes(user_data['User']['Attributes']) + + @staticmethod + def _create_write_permissions(jurisdiction: str): + return {'actions': {'read'}, 'jurisdictions': {jurisdiction: {'write'}}} + + def create_rate_limiting_table(self): + """Create the rate limiting table for testing.""" + self._rate_limiting_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['RATE_LIMITING_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_client.py new file mode 100644 index 0000000000..43cf02be88 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_client.py @@ -0,0 +1,920 @@ +import json +from datetime import datetime +from unittest.mock import ANY, patch +from uuid import UUID, uuid4 + +from boto3.dynamodb.conditions import Key +from cc_common.data_model.update_tier_enum import UpdateTierEnum +from moto import mock_aws + +from tests.function import TstFunction + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestDataClient(TstFunction): + def setUp(self): + super().setUp() + self.maxDiff = None + + def test_get_provider(self): + from cc_common.data_model.data_client import DataClient + + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + resp = client.get_provider( + compact='socw', + provider_id=provider_id, + ) + self.assertEqual(2, len(resp['items'])) + # Should be one each of provider and license + self.assertEqual({'provider', 'license'}, {record['type'] for record in resp['items']}) + + def test_get_provider_garbage_in_db(self): + """Because of the risk of exposing sensitive data to the public if we manage to get corrupted + data into our database, we'll specifically validate data coming _out_ of the database + and throw an error if it doesn't look as expected. + """ + from cc_common.data_model.data_client import DataClient + + provider_id = self._load_provider_data() + + with open('tests/resources/dynamo/license.json') as f: + license_record = json.load(f) + + self._provider_table.put_item( + Item={ + # Oh, no! We've somehow put somebody's full SSN in the wrong place! + 'something_unexpected': '123-12-1234', + **license_record, + }, + ) + + client = DataClient(self.config) + + # The field should not be allowed out via API + resp = client.get_provider( + compact='socw', + provider_id=provider_id, + ) + for item in resp['items']: + self.assertNotIn('something_unexpected', item) + + def _load_provider_data(self) -> UUID: + with open('tests/resources/dynamo/provider.json') as f: + provider_record = json.load(f) + provider_id = UUID(provider_record['providerId']) + self._provider_table.put_item(Item=provider_record) + + with open('tests/resources/dynamo/license.json') as f: + license_record = json.load(f) + self._provider_table.put_item(Item=license_record) + + with open('tests/resources/dynamo/provider-ssn.json') as f: + provider_ssn_record = json.load(f) + self._ssn_table.put_item(Item=provider_ssn_record) + + return provider_id + + def test_get_ssn_by_provider_id_returns_ssn_if_provider_id_exists(self): + """Test that get_ssn_by_provider_id returns the SSN if the provider ID exists""" + from cc_common.data_model.data_client import DataClient + + client = DataClient(self.config) + + # Create a provider record with an SSN + self._load_provider_data() + + ssn = client.get_ssn_by_provider_id(compact='socw', provider_id='89a6377e-c3a5-40e5-bca5-317ec854c570') + self.assertEqual('123-12-1234', ssn) + + def test_get_ssn_by_provider_id_raises_exception_if_provider_id_does_not_exist(self): + """Test that get_ssn_by_provider_id returns the SSN if the provider ID exists""" + from cc_common.data_model.data_client import DataClient + from cc_common.exceptions import CCNotFoundException + + client = DataClient(self.config) + + # We didn't create the provider this time, so this won't exist + with self.assertRaises(CCNotFoundException): + client.get_ssn_by_provider_id(compact='socw', provider_id='89a6377e-c3a5-40e5-bca5-317ec854c570') + + def test_get_ssn_by_provider_id_raises_exception_multiple_records_found(self): + """Test that get_ssn_by_provider_id returns the SSN if the provider ID exists""" + from cc_common.data_model.data_client import DataClient + from cc_common.exceptions import CCInternalException + + client = DataClient(self.config) + + self._load_provider_data() + # Put a duplicate record into the table, so this provider id has two SSNs associated with it + self.config.ssn_table.put_item( + Item={ + 'pk': 'socw#SSN#123-12-5678', + 'sk': 'socw#SSN#123-12-5678', + 'providerIdGSIpk': 'socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570', + 'compact': 'socw', + 'ssn': '123-12-5678', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + } + ) + + with self.assertRaises(CCInternalException): + client.get_ssn_by_provider_id(compact='socw', provider_id='89a6377e-c3a5-40e5-bca5-317ec854c570') + + def test_get_provider_user_records_correctly_handles_pagination(self): + """Test that get_provider_user_records correctly handles pagination by returning all records. + + This test ensures the fix for a bug where only the last page of results was being returned, + discarding everything collected in previous iterations. + """ + from cc_common.data_model.data_client import DataClient + + # Create a client + client = DataClient(self.config) + + # Create a provider record + provider_uuid = str(uuid4()) + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'providerId': provider_uuid, + 'compact': 'socw', + } + ) + + # Creating 30 license records, to test pagination with 10 records at a time. + jurisdictions = self.config.jurisdictions[:30] + # Create license records for each jurisdiction + for jurisdiction in jurisdictions: + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'providerId': provider_uuid, + 'compact': 'socw', + 'jurisdiction': jurisdiction, + 'licenseType': 'esthetician', + } + ) + + # Override the DynamoDB query method to force pagination with a small limit + original_query = self.config.provider_table.query + + def mock_query(**kwargs): + # Force a small page size to ensure pagination + kwargs['Limit'] = 10 + return original_query(**kwargs) + + self.config.provider_table.query = mock_query + + try: + # Call the method that should handle pagination correctly + provider_records = client.get_provider_user_records(compact='socw', provider_id=provider_uuid) + + # Verify that we got all the records + # We expect 1 provider record + 30 license records = 31 total + self.assertEqual(31, len(provider_records.provider_records)) + + # Check that we have all the different record types + record_types = {record['type'] for record in provider_records.provider_records} + self.assertEqual({'provider', 'license'}, record_types) + + # Verify we have all license records + license_records = provider_records.get_license_records() + self.assertEqual(30, len(license_records)) + license_jurisdictions = {lic.jurisdiction for lic in license_records} + self.assertEqual(set(jurisdictions), license_jurisdictions) + + finally: + # Restore the original query method + self.config.provider_table.query = original_query + + def test_create_privilege_investigation_success(self): + """Test successful creation of privilege investigation""" + from cc_common.data_model.data_client import DataClient + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # Create investigation data using test data generator + investigation = self.test_data_generator.generate_default_investigation( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'ne', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + } + ) + + # Call the method + client.create_investigation(investigation) + + # Verify investigation record was created + provider_user_records = self.config.data_client.get_provider_user_records( + compact='socw', provider_id=provider_id, include_update_tier=UpdateTierEnum.TIER_THREE + ) + investigation_records = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction='ne', + privilege_license_type_abbreviation='cos', + ) + + self.assertEqual(1, len(investigation_records)) + investigation_record = investigation_records[0] + + # Verify the complete investigation record structure + expected_investigation = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': f'socw#PROVIDER#privilege/ne/cos#INVESTIGATION#{investigation.investigationId}', + 'type': 'investigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'ne', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + 'investigationId': str(investigation.investigationId), + 'submittingUser': str(investigation.submittingUser), + 'creationDate': investigation.creationDate.isoformat(), + 'dateOfUpdate': ANY, + } + # Pop dynamic fields that we don't want to assert on + self.assertEqual(expected_investigation, investigation_record.serialize_to_database_record()) + + def test_create_license_investigation_success(self): + """Test successful creation of license investigation""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.investigation import InvestigationData + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # Create investigation data + investigation = InvestigationData.create_new( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': str(uuid4()), + } + ) + + # Call the method + client.create_investigation(investigation) + + # Verify investigation record was created + provider_user_records = self.config.data_client.get_provider_user_records( + compact='socw', provider_id=provider_id, include_update_tier=UpdateTierEnum.TIER_THREE + ) + investigation_records = provider_user_records.get_investigation_records_for_license( + license_jurisdiction='oh', + license_type_abbreviation='cos', + ) + + self.assertEqual(1, len(investigation_records)) + investigation_record = investigation_records[0] + + # Verify the complete investigation record structure + expected_investigation = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': f'socw#PROVIDER#license/oh/cos#INVESTIGATION#{investigation.investigationId}', + 'type': 'investigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'investigationId': str(investigation.investigationId), + 'submittingUser': str(investigation.submittingUser), + 'creationDate': investigation.creationDate.isoformat(), + 'dateOfUpdate': ANY, + } + + self.assertEqual(expected_investigation, investigation_record.serialize_to_database_record()) + + # Verify license record was updated with investigation status + license_records = provider_user_records.get_license_records() + + self.assertEqual(1, len(license_records)) + license_record = license_records[0] + self.assertEqual('underInvestigation', license_record.investigationStatus) + + # Verify update record was created + update_records = provider_user_records.get_update_records_for_license( + jurisdiction=license_record.jurisdiction, + license_type=license_record.licenseType, + ) + + self.assertEqual(1, len(update_records)) + update_record = update_records[0] + + # Verify the complete update record structure + expected_update = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': ANY, + 'type': 'licenseUpdate', + 'updateType': 'investigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'createDate': investigation.creationDate.isoformat(), + 'effectiveDate': investigation.creationDate.isoformat(), + 'previous': { + 'licenseNumber': 'A0608337260', + 'ssnLastFour': '1234', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'dateOfUpdate': '2024-06-06T12:59:59+00:00', + 'dateOfIssuance': '2010-06-06', + 'dateOfRenewal': '2020-04-04', + 'dateOfExpiration': '2025-04-04', + 'dateOfBirth': '1985-06-06', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + }, + 'updatedValues': { + 'investigationStatus': 'underInvestigation', + }, + 'investigationDetails': { + 'investigationId': str(investigation.investigationId), + }, + 'dateOfUpdate': ANY, + } + + self.assertEqual(expected_update, update_record.serialize_to_database_record()) + + def test_create_license_investigation_license_not_found(self): + """Test creation of license investigation when license doesn't exist""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.investigation import InvestigationData + from cc_common.exceptions import CCNotFoundException + + # Load test data, privilege in Nebraska, license in Ohio + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # Create investigation data for non-existent license (no license in Nebraska) + investigation = InvestigationData.create_new( + { + 'providerId': str(provider_id), + 'compact': 'socw', + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': str(uuid4()), + } + ) + + # Call the method and expect exception + with self.assertRaises(CCNotFoundException) as context: + client.create_investigation(investigation) + + self.assertIn('License not found', str(context.exception)) + + def test_close_privilege_investigation_success(self): + """Test successful closing of privilege investigation""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.data_model.schema.investigation import InvestigationData + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # First create an investigation + investigation = InvestigationData.create_new( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': uuid4(), + } + ) + + client.create_investigation(investigation) + + # Now close the investigation + closing_user = str(uuid4()) + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + ) + + # Verify investigation record was updated with close information + provider_user_records = self.config.data_client.get_provider_user_records( + compact='socw', provider_id=provider_id, include_update_tier=UpdateTierEnum.TIER_THREE + ) + investigation_records = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction='ne', privilege_license_type_abbreviation='cos', include_closed=True + ) + + self.assertEqual(1, len(investigation_records)) + investigation_record = investigation_records[0] + + # Verify the investigation record was updated with close information + expected_investigation_close = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': f'socw#PROVIDER#privilege/ne/cos#INVESTIGATION#{investigation.investigationId}', + 'type': 'investigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'ne', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + 'investigationId': str(investigation.investigationId), + 'submittingUser': str(investigation.submittingUser), + 'creationDate': investigation.creationDate.isoformat(), + 'closeDate': investigation.creationDate.isoformat(), + 'closingUser': closing_user, + 'dateOfUpdate': ANY, + } + self.assertEqual(expected_investigation_close, investigation_record.serialize_to_database_record()) + + def test_close_license_investigation_success(self): + """Test successful closing of license investigation""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.data_model.schema.investigation import InvestigationData + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # First create an investigation + investigation = InvestigationData.create_new( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': str(uuid4()), + } + ) + + client.create_investigation(investigation) + + # Now close the investigation + closing_user = str(uuid4()) + close_date = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='oh', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=close_date, + investigation_against=InvestigationAgainstEnum.LICENSE, + ) + + # grab all provider records to make assertions + provider_user_records = self.config.data_client.get_provider_user_records( + compact='socw', provider_id=provider_id, include_update_tier=UpdateTierEnum.TIER_THREE + ) + + # Verify investigation record was updated with close information + investigation_records = provider_user_records.get_investigation_records_for_license( + license_jurisdiction='oh', license_type_abbreviation='cos', include_closed=True + ) + + self.assertEqual(1, len(investigation_records)) + investigation_record = investigation_records[0] + + # Verify the investigation record was updated with close information + expected_investigation_close = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': f'socw#PROVIDER#license/oh/cos#INVESTIGATION#{investigation.investigationId}', + 'type': 'investigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'investigationId': str(investigation.investigationId), + 'submittingUser': str(investigation.submittingUser), + 'creationDate': investigation.creationDate.isoformat(), + 'closeDate': close_date.isoformat(), + 'closingUser': closing_user, + 'dateOfUpdate': ANY, + } + + self.assertEqual(expected_investigation_close, investigation_record.serialize_to_database_record()) + + # Verify license record no longer has investigation status + license_records = provider_user_records.get_license_records() + + self.assertEqual(1, len(license_records)) + license_record = license_records[0] + self.assertNotIn('investigationStatus', license_record.to_dict()) + + # Verify update record was created for closure + update_records = provider_user_records.get_update_records_for_license( + jurisdiction=license_record.jurisdiction, license_type=license_record.licenseType + ) + + # Should have 2 update records: one for creation, one for closure + self.assertEqual(2, len(update_records)) + + # Find the closure update record + closure_update = None + for update_record in update_records: + if update_record.updateType == 'closingInvestigation': + closure_update = update_record + break + + self.assertIsNotNone(closure_update, 'Closure update not found!') + + # Verify the complete closure update record structure + expected_closure_update = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': ANY, + 'type': 'licenseUpdate', + 'updateType': 'closingInvestigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'createDate': investigation.creationDate.isoformat(), + 'effectiveDate': investigation.creationDate.isoformat(), + 'previous': { + 'licenseNumber': 'A0608337260', + 'ssnLastFour': '1234', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'dateOfUpdate': '2024-11-08T23:59:59+00:00', + 'dateOfIssuance': '2010-06-06', + 'dateOfRenewal': '2020-04-04', + 'dateOfExpiration': '2025-04-04', + 'dateOfBirth': '1985-06-06', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'investigationStatus': 'underInvestigation', + }, + 'updatedValues': {}, + 'removedValues': ['investigationStatus'], + 'dateOfUpdate': ANY, + } + + self.assertEqual(expected_closure_update, closure_update.serialize_to_database_record()) + + def test_close_privilege_investigation_not_found(self): + """Test closing privilege investigation when investigation doesn't exist""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.exceptions import CCNotFoundException + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # Try to close a non-existent investigation + with self.assertRaises(CCNotFoundException) as context: + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + investigation_id=uuid4(), + closing_user=str(uuid4()), + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + ) + + self.assertIn('Investigation not found', str(context.exception)) + + def test_close_license_investigation_not_found(self): + """Test closing license investigation when investigation doesn't exist""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.exceptions import CCNotFoundException + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # Try to close a non-existent investigation + with self.assertRaises(CCNotFoundException) as context: + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='oh', + license_type_abbreviation='cos', + investigation_id=uuid4(), + closing_user=str(uuid4()), + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.LICENSE, + ) + + self.assertIn('Investigation not found', str(context.exception)) + + def test_close_privilege_investigation_already_closed(self): + """Test closing privilege investigation when investigation was already closed""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.data_model.schema.investigation import InvestigationData + from cc_common.exceptions import CCNotFoundException + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # First create an investigation + investigation = InvestigationData.create_new( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': uuid4(), + } + ) + + client.create_investigation(investigation) + + # Now close the investigation + closing_user = str(uuid4()) + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + ) + with self.assertRaises(CCNotFoundException) as context: + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + ) + + self.assertIn('Investigation not found', str(context.exception)) + + def test_close_license_investigation_already_closed(self): + """Test closing license investigation when investigation was already closed""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.data_model.schema.investigation import InvestigationData + from cc_common.exceptions import CCNotFoundException + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # First create an investigation + investigation = InvestigationData.create_new( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': uuid4(), + } + ) + + client.create_investigation(investigation) + + # Now close the investigation + closing_user = str(uuid4()) + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='oh', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.LICENSE, + ) + with self.assertRaises(CCNotFoundException) as context: + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='oh', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.LICENSE, + ) + + self.assertIn('Investigation not found', str(context.exception)) + + def test_close_privilege_investigation_with_encumbrance(self): + """Test closing privilege investigation with encumbrance creation""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.data_model.schema.investigation import InvestigationData + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # First create an investigation + investigation = InvestigationData.create_new( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': uuid4(), + } + ) + + client.create_investigation(investigation) + + # Now close the investigation with encumbrance creation + closing_user = str(uuid4()) + resulting_encumbrance_id = uuid4() + + close_date = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + resulting_encumbrance_id=resulting_encumbrance_id, + ) + + # Verify investigation record was updated with close information and encumbrance reference + investigation_records = self.config.provider_table.query( + KeyConditionExpression=Key('pk').eq(f'socw#PROVIDER#{provider_id}') + & Key('sk').begins_with('socw#PROVIDER#privilege/ne/cos#INVESTIGATION#') + )['Items'] + + self.assertEqual(1, len(investigation_records)) + investigation_record = investigation_records[0] + + # Verify the investigation record was updated with close information and encumbrance reference + expected_investigation_close = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': f'socw#PROVIDER#privilege/ne/cos#INVESTIGATION#{investigation.investigationId}', + 'type': 'investigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'ne', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + 'investigationId': str(investigation.investigationId), + 'submittingUser': str(investigation.submittingUser), + 'creationDate': investigation.creationDate.isoformat(), + 'closeDate': close_date.isoformat(), + 'closingUser': closing_user, + 'resultingEncumbranceId': str(resulting_encumbrance_id), + } + # Pop dynamic fields that we don't want to assert on + investigation_record.pop('dateOfUpdate') + + self.assertEqual(expected_investigation_close, investigation_record) + + def test_close_license_investigation_with_encumbrance(self): + """Test closing license investigation with encumbrance creation""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.common import InvestigationAgainstEnum + from cc_common.data_model.schema.investigation import InvestigationData + + # Load test data + provider_id = self._load_provider_data() + + client = DataClient(self.config) + + # First create an investigation + investigation = InvestigationData.create_new( + { + 'providerId': provider_id, + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'submittingUser': str(uuid4()), + 'creationDate': datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + 'investigationId': uuid4(), + } + ) + + client.create_investigation(investigation) + + # Now close the investigation with encumbrance creation + closing_user = str(uuid4()) + resulting_encumbrance_id = uuid4() + + close_date = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + client.close_investigation( + compact='socw', + provider_id=provider_id, + jurisdiction='oh', + license_type_abbreviation='cos', + investigation_id=investigation.investigationId, + closing_user=closing_user, + close_date=datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + investigation_against=InvestigationAgainstEnum.LICENSE, + resulting_encumbrance_id=resulting_encumbrance_id, + ) + + # Verify investigation record was updated with close information and encumbrance reference + investigation_records = self.config.provider_table.query( + KeyConditionExpression=Key('pk').eq(f'socw#PROVIDER#{provider_id}') + & Key('sk').begins_with('socw#PROVIDER#license/oh/cos#INVESTIGATION#') + )['Items'] + + self.assertEqual(1, len(investigation_records)) + investigation_record = investigation_records[0] + + # Verify the investigation record was updated with close information and encumbrance reference + expected_investigation_close = { + 'pk': f'socw#PROVIDER#{provider_id}', + 'sk': f'socw#PROVIDER#license/oh/cos#INVESTIGATION#{investigation.investigationId}', + 'type': 'investigation', + 'compact': 'socw', + 'providerId': str(provider_id), + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'license', + 'investigationId': str(investigation.investigationId), + 'submittingUser': str(investigation.submittingUser), + 'creationDate': investigation.creationDate.isoformat(), + 'closeDate': close_date.isoformat(), + 'closingUser': closing_user, + 'resultingEncumbranceId': str(resulting_encumbrance_id), + } + # Pop dynamic fields that we don't want to assert on + investigation_record.pop('dateOfUpdate') + + self.assertEqual(expected_investigation_close, investigation_record) diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/__init__.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_paginated.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_paginated.py new file mode 100644 index 0000000000..762a5dbc49 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_paginated.py @@ -0,0 +1,96 @@ +import json +from base64 import b64decode + +from common_test.test_constants import DEFAULT_COMPACT +from moto import mock_aws + +from .. import TstFunction + +MOCK_PROVIDER_ID_PREFIX = '89a6377e-c3a5-40e5-bca5-317ec854c5' + + +@mock_aws +class TestPaginated(TstFunction): + def test_pagination_returns_pagination_key_if_more_items_than_page_size_on_first_query(self): + """Test edge case: page size 5, 10 providers total, 8 providers match filter. Only 5 should be returned within + the response. The call should handle generating the expected last key since there are more matching providers + that were truncated due to page size. So even though DynamoDB itself won't return a last key from the initial + query, we ensure that a valid last key is returned for the caller to get all records. + """ + # Create 10 provider records with different jurisdictions + # 8 will have 'ky' jurisdiction (match filter), 2 will have 'oh' jurisdiction (don't match filter) + provider_ids = [] + for i in range(1, 11): + provider_id = f'{MOCK_PROVIDER_ID_PREFIX}{i:02d}' + provider_ids.append(provider_id) + + # Providers 3 and 7 will have 'oh' jurisdiction (won't match filter) + jurisdiction = 'oh' if i in [3, 7] else 'ky' + + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'providerId': provider_id, + 'licenseJurisdiction': jurisdiction, + 'familyName': f'Provider{i:02d}', # For consistent sorting + 'givenName': f'Test{i:02d}', + } + ) + + # Query with page size 5, filtering for 'ky' jurisdiction only + resp = self.config.data_client.get_providers_sorted_by_family_name( + compact='socw', + pagination={'pageSize': 5}, + jurisdiction='ky', # This will filter out providers 3 and 7 + ) + + # Should return 5 providers (page size) + self.assertEqual(len(resp['items']), 5) + + # Should have a last key since there are more matching providers available (3 more) + self.assertIsNotNone(resp['pagination']['lastKey']) + + # Decode the last key to verify it's for the 5th matching provider (number 6 in this case, since 3 did not match + # the filter) + last_key = json.loads(b64decode(resp['pagination']['lastKey']).decode('utf-8')) + self.assertEqual(f'{DEFAULT_COMPACT}#PROVIDER#{MOCK_PROVIDER_ID_PREFIX}06', last_key['pk']) + self.assertEqual(f'{DEFAULT_COMPACT}#PROVIDER', last_key['sk']) + self.assertIn('providerFamGivMid', last_key) + + # now we call again with the last key and ensure we get the remaining 3 providers + resp = self.config.data_client.get_providers_sorted_by_family_name( + compact='socw', pagination={'pageSize': 5, 'lastKey': resp['pagination']['lastKey']}, jurisdiction='ky' + ) + self.assertEqual(len(resp['items']), 3) + self.assertIsNone(resp['pagination']['lastKey']) + + def test_pagination_does_not_return_pagination_key_if_number_of_items_is_exact_match_to_page_size(self): + """Test edge case: page size 5, 5 providers total, all 5 providers match filter. + Verify the code does not return a last key since all available providers fit in the response page size. + """ + # Create exactly 5 provider records - all will match the filter + provider_ids = [] + for i in range(1, 6): + provider_id = f'{MOCK_PROVIDER_ID_PREFIX}{i:02d}' + provider_ids.append(provider_id) + + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'providerId': provider_id, + 'licenseJurisdiction': 'ky', # All will match the jurisdiction filter + 'familyName': f'Provider{i:02d}', # For consistent sorting + 'givenName': f'Test{i:02d}', + } + ) + + # Query with page size 5, filtering for 'ky' jurisdiction + resp = self.config.data_client.get_providers_sorted_by_family_name( + compact='socw', + pagination={'pageSize': 5}, + jurisdiction='ky', # All 5 providers will match + ) + + # Should return exactly 5 providers (all available) + self.assertEqual(len(resp['items']), 5) + + # Should have NO last key since we've seen all available data + self.assertIsNone(resp['pagination']['lastKey']) diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py new file mode 100644 index 0000000000..e2f501d68a --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -0,0 +1,374 @@ +import json +from datetime import UTC, datetime +from unittest.mock import patch +from uuid import UUID, uuid4 + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestClient(TstFunction): + def _get_email_from_user_attributes(self, user_data: dict) -> str: + for attribute in user_data['UserAttributes']: + if attribute['Name'] == 'email': + return attribute['Value'] + raise ValueError('No email found in user attributes') + + def test_get_user_in_compact(self): + user_id = self._load_user_data() + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + user = client.get_user_in_compact(compact='socw', user_id=user_id) + + # Verify that we're getting the expected fields + self.assertEqual( + {'type', 'userId', 'attributes', 'permissions', 'dateOfUpdate', 'compact', 'status'}, user.keys() + ) + self.assertEqual(UUID(user_id), user['userId']) + + def test_get_user_in_compact_not_found(self): + """User ID not found should raise an exception""" + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCNotFoundException + + client = UserClient(self.config) + + # This user isn't in the DB, so it should raise an exception + with self.assertRaises(CCNotFoundException): + client.get_user_in_compact(compact='socw', user_id='123') + + def test_get_one_jurisdiction_users_by_family_name(self): + # One user with compact-staff-like permissions in cosm + self._create_compact_staff_user(compacts=['socw']) + # One user with board-staff-like permissions in socw in each jurisdiction + self._create_board_staff_users(compacts=['socw']) + # One user with board-staff-like permissions in socw in each jurisdiction + self._create_board_staff_users(compacts=['socw']) + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + resp = client.get_users_sorted_by_family_name( + compact='socw', + # Only oh this time + jurisdictions=['oh'], + ) + + # We created two board users that have socw permissions in oh so those are what we should get back + self.assertEqual(2, len(resp['items'])) + + # Verify that we're getting the expected fields + for user in resp['items']: + self.assertEqual( + {'type', 'userId', 'compact', 'attributes', 'permissions', 'dateOfUpdate', 'status'}, user.keys() + ) + + # Verify we're seeing the expected sorting + family_names = [user['attributes']['familyName'] for user in resp['items']] + sorted_family_names = sorted(family_names) + self.assertEqual(sorted_family_names, family_names) + + def test_update_user_permissions_not_found(self): + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCNotFoundException + + client = UserClient(self.config) + + with self.assertRaises(CCNotFoundException): + client.update_user_permissions( + compact='socw', + user_id='does-not-exist', + jurisdiction_action_additions={'oh': {'admin'}}, + jurisdiction_action_removals={'oh': {'write'}}, + ) + + def test_update_user_permissions_jurisdiction_actions(self): + user_id = UUID(self._load_user_data()) + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + resp = client.update_user_permissions( + compact='socw', + user_id=user_id, + jurisdiction_action_additions={'oh': {'admin'}, 'ky': {'write'}}, + jurisdiction_action_removals={'oh': {'write'}}, + ) + + self.assertEqual(user_id, resp['userId']) + self.assertEqual( + {'actions': {'readPrivate'}, 'jurisdictions': {'oh': {'admin'}, 'ky': {'write'}}}, + resp['permissions'], + ) + # Just checking that we're getting the whole object, not just changes + self.assertFalse( + {'type', 'userId', 'compact', 'attributes', 'permissions', 'dateOfUpdate', 'status'} - resp.keys() + ) + + def test_update_user_permissions_board_to_compact_admin(self): + # The sample user looks like board staff in socw/oh + user_id = UUID(self._load_user_data()) + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + resp = client.update_user_permissions( + compact='socw', + user_id=user_id, + compact_action_additions={'admin'}, + jurisdiction_action_removals={'oh': {'write'}}, + ) + + self.assertEqual(user_id, resp['userId']) + self.assertEqual({'actions': {'readPrivate', 'admin'}, 'jurisdictions': {}}, resp['permissions']) + # Checking that we're getting the whole object, not just changes + self.assertFalse( + {'type', 'userId', 'compact', 'attributes', 'permissions', 'dateOfUpdate', 'status'} - resp.keys() + ) + + def test_update_user_permissions_compact_to_board_admin(self): + from boto3.dynamodb.types import TypeDeserializer + + with open('tests/resources/dynamo/user.json') as f: + user_data = TypeDeserializer().deserialize({'M': json.load(f)}) + + user_id = UUID(user_data['userId']) + # Convert our canned user into a compact admin + user_data['permissions'] = {'actions': {'read', 'admin'}, 'jurisdictions': {}} + self._users_table.put_item(Item=user_data) + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + resp = client.update_user_permissions( + compact='socw', + user_id=user_id, + compact_action_removals={'admin'}, + jurisdiction_action_additions={'oh': {'write', 'admin'}}, + ) + + self.assertEqual(user_id, resp['userId']) + self.assertEqual({'actions': {'read'}, 'jurisdictions': {'oh': {'write', 'admin'}}}, resp['permissions']) + # Checking that we're getting the whole object, not just changes + self.assertFalse( + {'type', 'userId', 'compact', 'attributes', 'permissions', 'dateOfUpdate', 'status'} - resp.keys() + ) + + def test_update_user_permissions_no_change(self): + from boto3.dynamodb.types import TypeDeserializer + from cc_common.exceptions import CCInvalidRequestException + + with open('tests/resources/dynamo/user.json') as f: + user_data = TypeDeserializer().deserialize({'M': json.load(f)}) + + user_id = UUID(user_data['userId']) + # Convert our canned user into a compact admin + user_data['permissions'] = {'actions': {'read', 'admin'}, 'jurisdictions': {}} + self._users_table.put_item(Item=user_data) + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + with self.assertRaises(CCInvalidRequestException): + client.update_user_permissions( + compact='socw', + user_id=str(user_id), + compact_action_removals=set(), + jurisdiction_action_additions={}, + ) + + def test_update_user_attributes(self): + # The sample user looks like board staff in socw/oh + user_id = UUID(self._load_user_data()) + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + resp = client.update_user_attributes(user_id=user_id, attributes={'givenName': 'Bob', 'familyName': 'Smith'}) + self.assertEqual(1, len(resp)) + user = resp[0] + + self.assertEqual(user_id, user['userId']) + self.assertEqual({'givenName': 'Bob', 'familyName': 'Smith', 'email': 'justin@example.org'}, user['attributes']) + # Checking that we're getting the whole object, not just changes + self.assertFalse( + {'type', 'userId', 'compact', 'attributes', 'permissions', 'dateOfUpdate', 'status'} - user.keys() + ) + + def test_update_user_attributes_not_found(self): + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCNotFoundException + + client = UserClient(self.config) + + with self.assertRaises(CCNotFoundException): + client.update_user_attributes( + user_id='does-not-exist', + attributes={'givenName': 'Bob', 'familyName': 'Smith'}, + ) + + def test_create_new_user(self): + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + resp = client.create_user( + compact='socw', + attributes={'givenName': 'Bob', 'familyName': 'Smith', 'email': 'bob@example.org'}, + permissions={'actions': {'read'}, 'jurisdictions': {'oh': {'write', 'admin'}}}, + ) + + self.assertEqual( + {'type', 'userId', 'compact', 'attributes', 'permissions', 'dateOfUpdate', 'status'}, + resp.keys(), + ) + self.assertEqual(StaffUserStatus.INACTIVE.value, resp['status']) + self.assertEqual({'givenName': 'Bob', 'familyName': 'Smith', 'email': 'bob@example.org'}, resp['attributes']) + self.assertEqual({'actions': {'read'}, 'jurisdictions': {'oh': {'write', 'admin'}}}, resp['permissions']) + + def test_create_existing_user_same_compact(self): + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + # Create an oh board admin + first_user = client.create_user( + compact='socw', + attributes={'givenName': 'Bob', 'familyName': 'Smith', 'email': 'bob@example.org'}, + permissions={'actions': {'read'}, 'jurisdictions': {'oh': {'write', 'admin'}}}, + ) + + # Create them again in the same compact + second_user = client.create_user( + compact='socw', + attributes={'givenName': 'Bob', 'familyName': 'Smith', 'email': 'bob@example.org'}, + permissions={'actions': {'read'}, 'jurisdictions': {'ne': {'write', 'admin'}}}, + ) + + # The second user should now have permissions in both jurisdictions + self.assertEqual('socw', second_user['compact']) + self.assertEqual(first_user['userId'], second_user['userId']) + self.assertEqual( + {'actions': {'read'}, 'jurisdictions': {'oh': {'write', 'admin'}, 'ne': {'write', 'admin'}}}, + second_user['permissions'], + ) + + def test_delete_user_in_compact(self): + user_id = self._load_user_data() + + from cc_common.data_model.user_client import UserClient + + client = UserClient(self.config) + + client.delete_user(compact='socw', user_id=user_id) + + def test_delete_user_in_compact_not_found(self): + """User ID not found should raise an exception""" + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCNotFoundException + + client = UserClient(self.config) + + # This user isn't in the DB, so it should raise an exception + with self.assertRaises(CCNotFoundException): + client.get_user_in_compact(compact='socw', user_id='123') + + def test_reinvite_new_user(self): + user_id = self._create_compact_staff_user(compacts=['socw']) + + from cc_common.data_model.user_client import UserClient + + # Check the status of our new user in Cognito + user_data = self.config.cognito_client.admin_get_user( + UserPoolId=self.config.user_pool_id, + Username=user_id, + ) + self.assertEqual('FORCE_CHANGE_PASSWORD', user_data['UserStatus']) + + client = UserClient(self.config) + + client.reinvite_user(email=self._get_email_from_user_attributes(user_data)) + + # Check the status of our new user in Cognito + user_data = self.config.cognito_client.admin_get_user( + UserPoolId=self.config.user_pool_id, + Username=user_id, + ) + self.assertEqual('FORCE_CHANGE_PASSWORD', user_data['UserStatus']) + + def test_reinvite_existing_user(self): + user_id = self._create_compact_staff_user(compacts=['socw']) + + from cc_common.data_model.user_client import UserClient + + # Force the user to CONFIRMED status in Cognito + self.config.cognito_client.admin_set_user_password( + UserPoolId=self.config.user_pool_id, + Username=user_id, + # This is not a real user, not even in a sandbox, so hard-coding a 'password' is not an issue + Password='!@#$%^&*()asaAAAW;oiawfo;uihaohwa103', # noqa: S106 + Permanent=True, + ) + # Check the status of our new user in Cognito + user_data = self.config.cognito_client.admin_get_user( + UserPoolId=self.config.user_pool_id, + Username=user_id, + ) + self.assertEqual('CONFIRMED', user_data['UserStatus']) + + client = UserClient(self.config) + + client.reinvite_user(email=self._get_email_from_user_attributes(user_data)) + + # Check the status of our new user in Cognito + user_data = self.config.cognito_client.admin_get_user( + UserPoolId=self.config.user_pool_id, + Username=user_id, + ) + self.assertEqual('FORCE_CHANGE_PASSWORD', user_data['UserStatus']) + + @patch('cc_common.config._Config.cognito_client') + def test_reinvite_existing_user_unexpected_status(self, mock_cognito_client): + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCInternalException + + # Set up our mock client to return a user with UNCONFIRMED status, which is unexpected + user_id = str(uuid4()) + mock_cognito_client.admin_get_user.return_value = { + 'Username': user_id, + 'UserAttributes': [ + {'Name': 'email', 'Value': 'new_user@example.org'}, + {'Name': 'email_verified', 'Value': 'True'}, + {'Name': 'sub', 'Value': user_id}, + ], + 'UserCreateDate': datetime(2015, 1, 1, tzinfo=UTC), + 'UserLastModifiedDate': datetime(2015, 1, 1, tzinfo=UTC), + 'Enabled': True, + 'UserStatus': 'UNCONFIRMED', + } + + client = UserClient(self.config) + + with self.assertRaises(CCInternalException): + client.reinvite_user(email='new_user@example.org') + + def test_reinvite_user_not_found(self): + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCNotFoundException + + client = UserClient(self.config) + + with self.assertRaises(CCNotFoundException): + client.reinvite_user(email='does-not-exist@example.com') diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_event_state_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_event_state_client.py new file mode 100644 index 0000000000..ac8c546c2e --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_event_state_client.py @@ -0,0 +1,338 @@ +from datetime import datetime +from unittest.mock import ANY, patch +from uuid import UUID + +from cc_common.event_state_client import ( + EventStateClient, + EventType, + NotificationStatus, + NotificationTracker, + RecipientType, +) +from moto import mock_aws + +from tests.function import TstFunction + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestEventStateClient(TstFunction): + """Test suite for EventStateClient.""" + + def test_record_notification_attempt_creates_item_with_all_fields(self): + """Test that record_notification_attempt creates an item with all expected fields.""" + compact = 'socw' + message_id = 'test-message-123' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + event_type = EventType.LICENSE_ENCUMBRANCE + event_time = '2024-01-15T10:30:00Z' + jurisdiction = 'oh' + + self.config.event_state_client.record_notification_attempt( + compact=compact, + message_id=message_id, + recipient_type=RecipientType.STATE, + status=NotificationStatus.SUCCESS, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction=jurisdiction, + ) + + # Query the table to verify the item was created + pk = f'COMPACT#{compact}#SQS_MESSAGE#{message_id}' + response = self._event_state_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + + self.assertEqual(1, len(response['Items'])) + item = response['Items'][0] + + # Verify all expected fields + self.assertEqual( + { + 'eventTime': '2024-01-15T10:30:00Z', + 'eventType': 'license.encumbrance', + 'jurisdiction': 'oh', + 'pk': 'COMPACT#socw#SQS_MESSAGE#test-message-123', + 'providerId': '12345678-1234-1234-1234-123456789abc', + 'sk': 'NOTIFICATION#state#oh', + 'status': 'SUCCESS', + 'ttl': ANY, + }, + item, + ) + + def test_record_notification_attempt_failed_status_includes_error_message(self): + """Test that failed notifications include error messages.""" + client = EventStateClient(self.config) + + compact = 'socw' + message_id = 'test-message-789' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + event_type = EventType.LICENSE_ENCUMBRANCE_LIFTED + event_time = '2024-01-15T10:30:00Z' + error_message = 'SES service unavailable' + + client.record_notification_attempt( + compact=compact, + message_id=message_id, + recipient_type=RecipientType.STATE, + status=NotificationStatus.FAILED, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction='ne', + error_message=error_message, + ) + + # Query the table to verify the item was created + pk = f'COMPACT#{compact}#SQS_MESSAGE#{message_id}' + response = self._event_state_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + + self.assertEqual(1, len(response['Items'])) + item = response['Items'][0] + + self.assertEqual(NotificationStatus.FAILED, item['status']) + self.assertEqual(error_message, item['errorMessage']) + + def test_get_notification_attempts_returns_all_attempts_for_message(self): + """Test that get_notification_attempts returns all notification attempts for a message.""" + client = EventStateClient(self.config) + + compact = 'socw' + message_id = 'test-message-multi' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + event_type = EventType.LICENSE_ENCUMBRANCE + event_time = '2024-01-15T10:30:00Z' + + # Record multiple notification attempts + client.record_notification_attempt( + compact=compact, + message_id=message_id, + recipient_type=RecipientType.STATE, + status=NotificationStatus.SUCCESS, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction='oh', + ) + + client.record_notification_attempt( + compact=compact, + message_id=message_id, + recipient_type=RecipientType.STATE, + status=NotificationStatus.FAILED, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction='ne', + error_message='Test error', + ) + + # Get all attempts + attempts = client._get_notification_attempts(compact=compact, message_id=message_id) # noqa SLF001 + + # Should have 2 attempts + self.assertEqual(2, len(attempts)) + + # Verify keys + expected_keys = { + f'NOTIFICATION#{RecipientType.STATE}#oh', + f'NOTIFICATION#{RecipientType.STATE}#ne', + } + self.assertEqual(expected_keys, set(attempts.keys())) + + # Verify statuses + self.assertEqual(NotificationStatus.SUCCESS, attempts[f'NOTIFICATION#{RecipientType.STATE}#oh']['status']) + self.assertEqual(NotificationStatus.FAILED, attempts[f'NOTIFICATION#{RecipientType.STATE}#ne']['status']) + + +@mock_aws +class TestNotificationTracker(TstFunction): + """Test suite for NotificationTracker.""" + + def test_should_send_state_notification_returns_true_when_no_previous_attempt(self): + """Test that should_send_state_notification returns True when no previous attempt exists.""" + tracker = NotificationTracker(compact='socw', message_id='new-message') + + self.assertTrue(tracker.should_send_state_notification('oh')) + + def test_should_send_state_notification_returns_false_when_previous_success(self): + """Test that should_send_state_notification returns False when previous attempt succeeded.""" + compact = 'socw' + message_id = 'test-message-state-success' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + jurisdiction = 'oh' + + # Record a successful state notification + tracker = NotificationTracker(compact=compact, message_id=message_id) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=EventType.LICENSE_ENCUMBRANCE, + event_time='2024-01-15T10:30:00Z', + jurisdiction=jurisdiction, + ) + + # Create new tracker to simulate retry + retry_tracker = NotificationTracker(compact=compact, message_id=message_id) + + self.assertFalse(retry_tracker.should_send_state_notification(jurisdiction)) + + def test_should_send_state_notification_returns_true_when_previous_failure(self): + """Test that should_send_state_notification returns True when previous attempt failed.""" + compact = 'socw' + message_id = 'test-message-state-fail' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + jurisdiction = 'ne' + + # Record a failed state notification + tracker = NotificationTracker(compact=compact, message_id=message_id) + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=EventType.LICENSE_ENCUMBRANCE, + event_time='2024-01-15T10:30:00Z', + error_message='Test error', + jurisdiction=jurisdiction, + ) + + # Create new tracker to simulate retry + retry_tracker = NotificationTracker(compact=compact, message_id=message_id) + + self.assertTrue(retry_tracker.should_send_state_notification(jurisdiction)) + + def test_should_send_state_notification_independent_per_jurisdiction(self): + """Test that state notification checks are independent per jurisdiction.""" + compact = 'socw' + message_id = 'test-message-multi-state' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + + # Record successful notification to 'oh' but not 'ne' + tracker = NotificationTracker(compact=compact, message_id=message_id) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=EventType.LICENSE_ENCUMBRANCE, + event_time='2024-01-15T10:30:00Z', + jurisdiction='oh', + ) + + # Create new tracker to check + retry_tracker = NotificationTracker(compact=compact, message_id=message_id) + + # 'oh' should not be sent (already succeeded) + self.assertFalse(retry_tracker.should_send_state_notification('oh')) + + # 'ne' should be sent (no previous attempt) + self.assertTrue(retry_tracker.should_send_state_notification('ne')) + + def test_record_success_creates_success_record(self): + """Test that record_success creates a SUCCESS record in the table.""" + compact = 'socw' + message_id = 'test-record-success' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + event_type = EventType.PRIVILEGE_ENCUMBRANCE + event_time = '2024-01-15T10:30:00Z' + jurisdiction = 'ky' + + tracker = NotificationTracker(compact=compact, message_id=message_id) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction=jurisdiction, + ) + + # Query the table directly + pk = f'COMPACT#{compact}#SQS_MESSAGE#{message_id}' + response = self._event_state_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + + self.assertEqual(1, len(response['Items'])) + item = response['Items'][0] + + self.assertEqual(NotificationStatus.SUCCESS, item['status']) + self.assertEqual(str(provider_id), item['providerId']) + self.assertEqual(event_type, item['eventType']) + self.assertEqual(jurisdiction, item['jurisdiction']) + + def test_record_failure_creates_failed_record_with_error_message(self): + """Test that record_failure creates a FAILED record with error message.""" + compact = 'socw' + message_id = 'test-record-failure' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + event_type = EventType.LICENSE_ENCUMBRANCE_LIFTED + event_time = '2024-01-15T10:30:00Z' + error_message = 'Network timeout' + + tracker = NotificationTracker(compact=compact, message_id=message_id) + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + error_message=error_message, + jurisdiction='ne', + ) + + # Query the table directly + pk = f'COMPACT#{compact}#SQS_MESSAGE#{message_id}' + response = self._event_state_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': pk} + ) + + self.assertEqual(1, len(response['Items'])) + item = response['Items'][0] + + self.assertEqual(NotificationStatus.FAILED, item['status']) + self.assertEqual(error_message, item['errorMessage']) + + def test_tracker_handles_mixed_success_and_failure_states(self): + """Test that tracker correctly handles a mix of success and failure states.""" + compact = 'socw' + message_id = 'test-mixed-states' + provider_id = UUID('12345678-1234-1234-1234-123456789abc') + event_type = EventType.PRIVILEGE_ENCUMBRANCE_LIFTED + event_time = '2024-01-15T10:30:00Z' + + # Record various states + tracker = NotificationTracker(compact=compact, message_id=message_id) + + # State OH: SUCCESS + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction='oh', + ) + + # State NE: FAILED + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + error_message='SES error', + jurisdiction='ne', + ) + + # State KY: not attempted yet + + # Create new tracker to check states + retry_tracker = NotificationTracker(compact=compact, message_id=message_id) + + # OH should not be sent (SUCCESS) + self.assertFalse(retry_tracker.should_send_state_notification('oh')) + + # NE should be sent (FAILED) + self.assertTrue(retry_tracker.should_send_state_notification('ne')) + + # KY should be sent (no attempt) + self.assertTrue(retry_tracker.should_send_state_notification('ky')) diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_signature_auth.py b/backend/social-work-app/lambdas/python/common/tests/function/test_signature_auth.py new file mode 100644 index 0000000000..40479a80a2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_signature_auth.py @@ -0,0 +1,357 @@ +# ruff: noqa: ARG001 unused-argument +import json +import uuid +from copy import deepcopy +from datetime import UTC, datetime + +from common_test.sign_request import sign_request +from moto import mock_aws + +from tests.function import TstFunction + + +@mock_aws +class TestSignatureAuthFunctional(TstFunction): + """Functional tests for signature authentication using real database interactions.""" + + def setUp(self): + super().setUp() + self._load_compact_configuration_data() + + with open('tests/resources/api-client-event.json') as f: + self.base_event = json.load(f) + + # Load test keys + with open('tests/resources/client_private_key.pem') as f: + self.private_key_pem = f.read() + + with open('tests/resources/client_public_key.pem') as f: + self.public_key_pem = f.read() + + def test_required_signature_auth_success_with_public_key_in_database(self): + """Test successful authentication when public key is in database.""" + from cc_common.signature_auth import required_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @required_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create signed event + event = self._create_signed_event() + + # Test successful authentication + result = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK', 'authenticated': True}, result) + + def test_required_signature_auth_signature_missing_access_denied(self): + """Test access denied when signature is missing for required signature auth.""" + from cc_common.exceptions import CCUnauthorizedException + from cc_common.signature_auth import required_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @required_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create event without signature headers + event = deepcopy(self.base_event) + + # Test access denied + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Missing required X-Key-Id header', str(cm.exception)) + + def test_required_signature_auth_public_key_not_in_database_access_denied(self): + """Test access denied when public key is not in database for required signature auth.""" + from cc_common.exceptions import CCUnauthorizedException + from cc_common.signature_auth import required_signature_auth + + # Don't add public key to database + + @required_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create signed event + event = self._create_signed_event() + + # Test access denied + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Public key not found for this compact/jurisdiction/key-id', str(cm.exception)) + + def test_required_signature_auth_invalid_signature_access_denied(self): + """Test access denied with invalid signature.""" + from cc_common.exceptions import CCUnauthorizedException + from cc_common.signature_auth import required_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @required_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create signed event + event = self._create_signed_event() + + # Corrupt the signature + event['headers']['X-Signature'] = 'invalid_signature' + + # Test access denied + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Invalid request signature', str(cm.exception)) + + def test_optional_signature_auth_success_with_public_key_in_database(self): + """Test successful authentication when public key is in database for optional signature auth.""" + from cc_common.signature_auth import optional_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @optional_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create signed event + event = self._create_signed_event() + + # Test successful authentication + result = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK', 'authenticated': True}, result) + + def test_optional_signature_auth_signature_missing_with_public_key_access_denied(self): + """Test access denied when signature is missing but public key exists for optional signature auth.""" + from cc_common.exceptions import CCUnauthorizedException + from cc_common.signature_auth import optional_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @optional_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create event without signature headers + event = deepcopy(self.base_event) + + # Test access denied + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('X-Key-Id header required when signature keys are configured', str(cm.exception)) + + def test_optional_signature_auth_no_public_key_no_signature_success(self): + """Test successful access when no public key in database and no signature for optional signature auth.""" + from cc_common.signature_auth import optional_signature_auth + + # Don't add public key to database + + @optional_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': False} + + # Create event without signature headers + event = deepcopy(self.base_event) + + # Test successful access (no authentication required) + result = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK', 'authenticated': False}, result) + + def test_optional_signature_auth_no_public_key_with_signature_success(self): + """Test successful access when no public key in database but signature provided for optional signature auth.""" + from cc_common.signature_auth import optional_signature_auth + + # Don't add public key to database + + @optional_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': False} + + # Create signed event + event = self._create_signed_event() + + # Test successful access (no authentication required, signature ignored) + result = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK', 'authenticated': False}, result) + + def test_optional_signature_auth_invalid_signature_access_denied(self): + """Test access denied with invalid signature.""" + from cc_common.exceptions import CCUnauthorizedException + from cc_common.signature_auth import optional_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @optional_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create signed event + event = self._create_signed_event() + + # Corrupt the signature + event['headers']['X-Signature'] = 'invalid_signature' + + # Test access denied + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Invalid request signature', str(cm.exception)) + + def test_required_signature_auth_nonce_reuse_rejected(self): + """Test that nonce reuse is rejected for required signature auth.""" + from cc_common.exceptions import CCUnauthorizedException + from cc_common.signature_auth import required_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @required_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create a signed event + event = self._create_signed_event() + + # First request should succeed + result = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK', 'authenticated': True}, result) + + # Second request with the same nonce should fail (replay attack simulation) + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Nonce has already been used', str(cm.exception)) + + def test_optional_signature_auth_nonce_reuse_rejected(self): + """Test that nonce reuse is rejected for optional signature auth.""" + from cc_common.exceptions import CCUnauthorizedException + from cc_common.signature_auth import optional_signature_auth + + # Add public key to database + self._compact_configuration_table.put_item( + Item={ + 'pk': 'socw#SIGNATURE_KEYS#al', + 'sk': 'socw#JURISDICTION#al#test-key-001', + 'publicKey': self.public_key_pem, + 'compact': 'socw', + 'jurisdiction': 'al', + 'keyId': 'test-key-001', + } + ) + + @optional_signature_auth + def lambda_handler(event: dict, context): + return {'message': 'OK', 'authenticated': True} + + # Create a signed event + event = self._create_signed_event() + + # First request should succeed + result = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK', 'authenticated': True}, result) + + # Second request with the same nonce should fail (replay attack simulation) + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Nonce has already been used', str(cm.exception)) + + def _create_signed_event(self) -> dict: + """Create a properly signed event for testing.""" + # Create base event + event = deepcopy(self.base_event) + + # Generate current timestamp and nonce + timestamp = datetime.now(UTC).isoformat() + nonce = str(uuid.uuid4()) + key_id = 'test-key-001' + + # Import and use the sign_request function + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp, + nonce=nonce, + key_id=key_id, + private_key_pem=self.private_key_pem, + ) + + # Add signature headers to event + event['headers'].update(headers) + + return event diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_utils.py b/backend/social-work-app/lambdas/python/common/tests/function/test_utils.py new file mode 100644 index 0000000000..14364685ff --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_utils.py @@ -0,0 +1,33 @@ +import json + +from moto import mock_aws + +from tests.function import TstFunction + + +@mock_aws +class TestUtils(TstFunction): + def test_send_licenses_to_preprocessing_queue_handles_batches(self): + from cc_common.utils import send_licenses_to_preprocessing_queue + + with open('tests/resources/api/license-post.json') as f: + license_record = json.load(f) + license_record['compact'] = 'socw' + license_record['jurisdiction'] = 'oh' + + # generate 100 records and ensure the system processes all of them. + licenses_data = [license_record] * 100 + + failed_license_numbers = send_licenses_to_preprocessing_queue( + licenses_data=licenses_data, event_time='2024-12-04T08:08:08+00:00' + ) + + self.assertEqual([], failed_license_numbers) + + # now get all the messages and make sure all ids are in the list + # we can only get 10 messages at a time, so we iterate over the range and get all the messages + messages = [] + for _i in range(10): + messages.extend(self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10)) + + self.assertEqual(100, len(messages)) diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/api-client-event.json b/backend/social-work-app/lambdas/python/common/tests/resources/api-client-event.json new file mode 100644 index 0000000000..5bac9f2657 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/api-client-event.json @@ -0,0 +1,108 @@ +{ + "resource": "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/providers/query", + "path": "/v1/compacts/socw/jurisdictions/al/providers/query", + "httpMethod": "POST", + "headers": { + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate, br", + "Authorization": "Bearer ", + "Cache-Control": "no-cache", + "Content-Type": "application/json", + "Host": "state-api.justin.jcc.iaapi.io", + "Postman-Token": "6abca03f-897e-40a4-86fd-9b89bd9185b2", + "User-Agent": "PostmanRuntime/7.45.0", + "X-Amzn-Trace-Id": "Root=1-689b712b-288ca4441a564698563d2d8e", + "X-Forwarded-For": "192.0.2.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "multiValueHeaders": { + "Accept": [ + "application/json" + ], + "Accept-Encoding": [ + "gzip, deflate, br" + ], + "Cache-Control": [ + "no-cache" + ], + "Content-Type": [ + "application/json" + ], + "Host": [ + "state-api.justin.jcc.iaapi.io" + ], + "Postman-Token": [ + "6abca03f-897e-40a4-86fd-9b89bd9185b2" + ], + "User-Agent": [ + "PostmanRuntime/7.45.0" + ], + "X-Amzn-Trace-Id": [ + "Root=1-689b712b-288ca4441a564698563d2d8e" + ], + "X-Forwarded-For": [ + "192.0.2.1" + ], + "X-Forwarded-Port": [ + "443" + ], + "X-Forwarded-Proto": [ + "https" + ] + }, + "queryStringParameters": null, + "multiValueQueryStringParameters": null, + "pathParameters": { + "compact": "socw", + "jurisdiction": "al" + }, + "stageVariables": null, + "requestContext": { + "resourceId": "54n3vn", + "authorizer": { + "claims": { + "sub": "5ve0e281b3fqndjl67cucu684c", + "token_use": "access", + "scope": "ne/socw.write socw/readGeneral ne/socw.readPrivate", + "auth_time": "1755017510", + "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_gxBtau7Di", + "exp": "Tue Aug 12 17:06:50 UTC 2025", + "iat": "Tue Aug 12 16:51:50 UTC 2025", + "version": "2", + "jti": "aa274fc1-e499-4bf7-bc9a-e375fdc81a17", + "client_id": "5ve0e281b3fqndjl67cucu684c" + } + }, + "resourcePath": "/v1/compacts/{compact}/jurisdictions/{jurisdiction}/providers/query", + "httpMethod": "POST", + "extendedRequestId": "PM6e4GcZoAMEnTA=", + "requestTime": "12/Aug/2025:16:51:55 +0000", + "path": "/v1/compacts/socw/jurisdictions/al/providers/query", + "accountId": "992382587219", + "protocol": "HTTP/1.1", + "stage": "justin-blue", + "domainPrefix": "state-api", + "requestTimeEpoch": 1755017515698, + "requestId": "84065b67-2bce-4d15-b711-3eb10bc535cd", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "sourceIp": "192.0.2.1", + "principalOrgId": null, + "accessKey": null, + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "PostmanRuntime/7.45.0", + "user": null + }, + "domainName": "state-api.justin.jcc.iaapi.io", + "deploymentId": "fmpo3s", + "apiId": "h1gi77rn55" + }, + "body": "{\n \"query\": {\n \"endDateTime\": \"2025-04-01T22:58:09.38Z\",\n \"startDateTime\": \"2025-03-28T21:19:04Z\"\n }\n}", + "isBase64Encoded": false +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/api-event.json b/backend/social-work-app/lambdas/python/common/tests/resources/api-event.json new file mode 100644 index 0000000000..47d2142571 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/api-event.json @@ -0,0 +1,129 @@ +{ + "resource": "/v1/staff-users/me", + "path": "/v1/staff-users/me", + "httpMethod": "GET", + "headers": { + "accept": "application/json", + "content-type": "application/json", + "accept-encoding": "gzip, deflate, br, zstd", + "accept-language": "en-US,en;q=0.5", + "Authorization": "Bearer ", + "cache-control": "no-cache", + "Host": "api.justin.jcc.iaapi.io", + "origin": "https://app.justin.jcc.iaapi.io", + "pragma": "no-cache", + "referer": "https://app.justin.jcc.iaapi.io/", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-site", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0", + "X-Amzn-Trace-Id": "Root=1-689b64af-42fcf052722c8040376585b5", + "X-Forwarded-For": "192.0.2.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "multiValueHeaders": { + "accept": [ + "application/json" + ], + "accept-encoding": [ + "gzip, deflate, br, zstd" + ], + "accept-language": [ + "en-US,en;q=0.5" + ], + "cache-control": [ + "no-cache" + ], + "Host": [ + "api.justin.jcc.iaapi.io" + ], + "origin": [ + "https://app.justin.jcc.iaapi.io" + ], + "pragma": [ + "no-cache" + ], + "referer": [ + "https://app.justin.jcc.iaapi.io/" + ], + "sec-fetch-dest": [ + "empty" + ], + "sec-fetch-mode": [ + "cors" + ], + "sec-fetch-site": [ + "same-site" + ], + "User-Agent": [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0" + ], + "X-Amzn-Trace-Id": [ + "Root=1-689b64af-42fcf052722c8040376585b5" + ], + "X-Forwarded-For": [ + "192.0.2.1" + ], + "X-Forwarded-Port": [ + "443" + ], + "X-Forwarded-Proto": [ + "https" + ] + }, + "queryStringParameters": null, + "multiValueQueryStringParameters": null, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "resourceId": "niuigd", + "authorizer": { + "claims": { + "sub": "8498f498-c0c1-70c0-8bb2-421024760fe9", + "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_gxBtau7Di", + "version": "2", + "client_id": "40bm38npn9s9f94dk8dmhdead6", + "origin_jti": "5ba4d34e-2a7e-4ac2-81e8-08f8f031d045", + "event_id": "36a7c146-0aee-46c7-9e73-f3faf3ace673", + "token_use": "access", + "scope": "openid email", + "auth_time": "1754940768", + "exp": "Tue Aug 12 16:44:09 UTC 2025", + "iat": "Tue Aug 12 15:44:13 UTC 2025", + "jti": "edd0699c-ba51-4d8b-981b-3164839f5ef8", + "username": "8498f498-c0c1-70c0-8bb2-421024760fe9" + } + }, + "resourcePath": "/v1/staff-users/me", + "httpMethod": "GET", + "extendedRequestId": "ZAmXuH9bIAMEjZA=", + "requestTime": "07/Jun/2024:18:27:09 +0000", + "path": "/sandbox/v0/boards/al/licenses/bulk-upload", + "accountId": "058264452476", + "protocol": "HTTP/1.1", + "stage": "sandbox", + "domainPrefix": "j9hs0bq9i9", + "requestTimeEpoch": 1717784829944, + "requestId": "6003a0c9-c61c-4d83-a2df-963e51f1a82f", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "sourceIp": "192.0.2.1", + "principalOrgId": null, + "accessKey": null, + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:141.0) Gecko/20100101 Firefox/141.0", + "user": null + }, + "domainName": "api.justin.jcc.iaapi.io", + "deploymentId": "okxjb3", + "apiId": "j9hs0bq9i9" + }, + "body": null, + "isBase64Encoded": false +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/api/adverse-action-post.json b/backend/social-work-app/lambdas/python/common/tests/resources/api/adverse-action-post.json new file mode 100644 index 0000000000..d7ecff1b8c --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/api/adverse-action-post.json @@ -0,0 +1,5 @@ +{ + "encumbranceEffectiveDate": "2023-01-15", + "encumbranceType": "suspension", + "clinicalPrivilegeActionCategories": ["fraud"] +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/api/license-post.json b/backend/social-work-app/lambdas/python/common/tests/resources/api/license-post.json new file mode 100644 index 0000000000..afe3aa1507 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/api/license-post.json @@ -0,0 +1,22 @@ +{ + "ssn": "123-12-1234", + "licenseNumber": "A0608337260", + "givenName": "Björk", + "middleName": "Gunnar", + "familyName": "Guðmundsdóttir", + "licenseType": "cosmetologist", + "licenseStatus": "active", + "licenseStatusName": "DEFINITELY_A_HUMAN", + "compactEligibility": "eligible", + "homeAddressStreet1": "123 A St.", + "homeAddressStreet2": "Apt 321", + "homeAddressCity": "Columbus", + "homeAddressState": "oh", + "homeAddressPostalCode": "43004", + "dateOfIssuance": "2010-06-06", + "dateOfRenewal": "2020-04-04", + "dateOfExpiration": "2025-04-04", + "dateOfBirth": "1985-06-06", + "emailAddress": "björk@example.com", + "phoneNumber": "+13213214321" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/api/provider-detail-response.json b/backend/social-work-app/lambdas/python/common/tests/resources/api/provider-detail-response.json new file mode 100644 index 0000000000..9ffd0970be --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/api/provider-detail-response.json @@ -0,0 +1,67 @@ +{ + "type": "provider", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "ssnLastFour": "1234", + "givenName": "Björk", + "middleName": "Gunnar", + "familyName": "Guðmundsdóttir", + "jurisdictionUploadedLicenseStatus": "active", + "jurisdictionUploadedCompactEligibility": "eligible", + "licenseStatus": "active", + "compactEligibility": "eligible", + "compact": "socw", + "licenseJurisdiction": "oh", + "dateOfBirth": "1985-06-06", + "dateOfUpdate": "2024-07-08T23:59:59+00:00", + "dateOfExpiration": "2025-04-04", + "birthMonthDay": "06-06", + "adverseActions": [], + "licenses": [ + { + "type": "license", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "socw", + "jurisdiction": "oh", + "ssnLastFour": "1234", + "licenseNumber": "A0608337260", + "licenseType": "cosmetologist", + "jurisdictionUploadedLicenseStatus": "active", + "jurisdictionUploadedCompactEligibility": "eligible", + "licenseStatus": "active", + "licenseStatusName": "DEFINITELY_A_HUMAN", + "compactEligibility": "eligible", + "givenName": "Björk", + "middleName": "Gunnar", + "familyName": "Guðmundsdóttir", + "dateOfIssuance": "2010-06-06", + "dateOfRenewal": "2020-04-04", + "dateOfExpiration": "2025-04-04", + "dateOfBirth": "1985-06-06", + "dateOfUpdate": "2024-06-06T12:59:59+00:00", + "homeAddressStreet1": "123 A St.", + "homeAddressStreet2": "Apt 321", + "homeAddressCity": "Columbus", + "homeAddressState": "oh", + "homeAddressPostalCode": "43004", + "emailAddress": "björk@example.com", + "phoneNumber": "+13213214321", + "adverseActions": [], + "investigations": [] + } + ], + "privileges": [ + { + "type": "privilege", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "socw", + "jurisdiction": "ne", + "licenseType": "cosmetologist", + "licenseJurisdiction": "oh", + "status": "active", + "dateOfExpiration": "2025-04-04", + "administratorSetStatus": "active", + "adverseActions": [], + "investigations": [] + } + ] +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/api/provider-response.json b/backend/social-work-app/lambdas/python/common/tests/resources/api/provider-response.json new file mode 100644 index 0000000000..66d67cac50 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/api/provider-response.json @@ -0,0 +1,16 @@ +{ + "type": "provider", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "givenName": "Bj\u00f6rk", + "middleName": "Gunnar", + "familyName": "Gu\u00f0mundsd\u00f3ttir", + "licenseStatus": "active", + "compactEligibility": "eligible", + "jurisdictionUploadedLicenseStatus": "active", + "jurisdictionUploadedCompactEligibility": "eligible", + "compact": "socw", + "licenseJurisdiction": "oh", + "dateOfUpdate": "2024-07-08T23:59:59+00:00", + "dateOfExpiration": "2025-04-04", + "birthMonthDay": "06-06" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/bad.csv b/backend/social-work-app/lambdas/python/common/tests/resources/bad.csv new file mode 100644 index 0000000000..44643c6cee --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/bad.csv @@ -0,0 +1 @@ + diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/client_private_key.pem b/backend/social-work-app/lambdas/python/common/tests/resources/client_private_key.pem new file mode 100644 index 0000000000..9ca323d822 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/client_private_key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIA1lNFiQEHfGg0NlHEzosQbZKSpRzSwCfDAo0pEduvExoAoGCCqGSM49 +AwEHoUQDQgAEHp15YYpGnAwjakWPUzjn9ahXq0uOI4mIcevO8XmiSSku5+5hyQ/l +ub5NnpvJe4VZFH4cpvgjg5ig1Phkmb+DHg== +-----END EC PRIVATE KEY----- diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/client_public_key.pem b/backend/social-work-app/lambdas/python/common/tests/resources/client_public_key.pem new file mode 100644 index 0000000000..f8e5d3c77c --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/client_public_key.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEHp15YYpGnAwjakWPUzjn9ahXq0uO +I4mIcevO8XmiSSku5+5hyQ/lub5NnpvJe4VZFH4cpvgjg5ig1Phkmb+DHg== +-----END PUBLIC KEY----- diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/compact.json b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/compact.json new file mode 100644 index 0000000000..e6d3937745 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/compact.json @@ -0,0 +1,25 @@ +{ + "pk": "socw#CONFIGURATION", + "sk": "socw#CONFIGURATION", + "type": "compact", + "compactAbbr": "socw", + "compactName": "Social Work", + "compactOperationsTeamEmails": [""], + "compactAdverseActionsNotificationEmails": [""], + "licenseeRegistrationEnabled": true, + "configuredStates": [ + { + "postalAbbreviation": "ky", + "isLive": true + }, + { + "postalAbbreviation": "oh", + "isLive": true + }, + { + "postalAbbreviation": "ne", + "isLive": true + } + ], + "dateOfUpdate": "2024-10-04T12:34:56+00:00" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/jurisdiction.json b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/jurisdiction.json new file mode 100644 index 0000000000..714cf51769 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/jurisdiction.json @@ -0,0 +1,12 @@ +{ + "pk": "socw#CONFIGURATION", + "sk": "socw#JURISDICTION#oh", + "type": "jurisdiction", + "compact": "socw", + "jurisdictionName": "ohio", + "postalAbbreviation": "oh", + "jurisdictionOperationsTeamEmails": ["some-operations-team@test.com"], + "jurisdictionAdverseActionsNotificationEmails": ["some-adverse-actions-notification-team@test.com"], + "licenseeRegistrationEnabled": true, + "dateOfUpdate": "2024-10-04T12:34:56+00:00" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/license-update.json b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/license-update.json new file mode 100644 index 0000000000..29f1337f5a --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/license-update.json @@ -0,0 +1,41 @@ +{ + "pk": "socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", + "sk": "socw#UPDATE#3#license/oh/cos/2024-11-08T23:59:59+00:00/6dc76fbfc9426d23fdff44f260747b74", + "licenseUploadDateGSIPK": "C#socw#J#oh#D#2024-11", + "licenseUploadDateGSISK": "TIME#1731110399#LT#cos#PID#89a6377e-c3a5-40e5-bca5-317ec854c570", + "type": "licenseUpdate", + "updateType": "renewal", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "socw", + "jurisdiction": "oh", + "createDate": "2024-11-08T23:59:59+00:00", + "effectiveDate": "2024-11-08T23:59:59+00:00", + "licenseType": "cosmetologist", + "dateOfUpdate": "2020-04-07T12:59:59+00:00", + "previous": { + "licenseNumber": "A0608337260", + "ssnLastFour": "1234", + "givenName": "Björk", + "middleName": "Gunnar", + "familyName": "Guðmundsdóttir", + "dateOfIssuance": "2010-06-06", + "dateOfRenewal": "2015-06-06", + "dateOfExpiration": "2020-06-06", + "dateOfBirth": "1985-06-06", + "dateOfUpdate": "2020-06-06T12:59:59+00:00", + "homeAddressStreet1": "123 A St.", + "homeAddressStreet2": "Apt 321", + "homeAddressCity": "Columbus", + "homeAddressState": "oh", + "homeAddressPostalCode": "43004", + "emailAddress": "björk@example.com", + "phoneNumber": "+13213214321", + "jurisdictionUploadedLicenseStatus": "active", + "licenseStatusName": "DEFINITELY_A_HUMAN", + "jurisdictionUploadedCompactEligibility": "eligible" + }, + "updatedValues": { + "dateOfRenewal": "2020-04-04", + "dateOfExpiration": "2025-04-04" + } +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/license.json b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/license.json new file mode 100644 index 0000000000..4bece6fc72 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/license.json @@ -0,0 +1,31 @@ +{ + "pk": "socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", + "sk": "socw#PROVIDER#license/oh/cos#", + "type": "license", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "socw", + "jurisdiction": "oh", + "ssnLastFour": "1234", + "licenseNumber": "A0608337260", + "licenseType": "cosmetologist", + "givenName": "Björk", + "middleName": "Gunnar", + "familyName": "Guðmundsdóttir", + "dateOfIssuance": "2010-06-06", + "dateOfRenewal": "2020-04-04", + "dateOfExpiration": "2025-04-04", + "dateOfBirth": "1985-06-06", + "dateOfUpdate": "2024-06-06T12:59:59+00:00", + "homeAddressStreet1": "123 A St.", + "homeAddressStreet2": "Apt 321", + "homeAddressCity": "Columbus", + "homeAddressState": "oh", + "homeAddressPostalCode": "43004", + "emailAddress": "björk@example.com", + "phoneNumber": "+13213214321", + "jurisdictionUploadedLicenseStatus": "active", + "licenseStatusName": "DEFINITELY_A_HUMAN", + "jurisdictionUploadedCompactEligibility": "eligible", + "licenseGSIPK": "C#socw#J#oh", + "licenseGSISK": "FN#gu%C3%B0mundsd%C3%B3ttir#GN#bj%C3%B6rk" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/provider-ssn.json b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/provider-ssn.json new file mode 100644 index 0000000000..06870ed9f5 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/provider-ssn.json @@ -0,0 +1,9 @@ +{ + "pk": "socw#SSN#123-12-1234", + "sk": "socw#SSN#123-12-1234", + "providerIdGSIpk": "socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", + "type": "provider-ssn", + "compact": "socw", + "ssn": "123-12-1234", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/provider.json b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/provider.json new file mode 100644 index 0000000000..ce6627cf51 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/provider.json @@ -0,0 +1,20 @@ +{ + "pk": "socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", + "sk": "socw#PROVIDER", + "providerFamGivMid": "gu%C3%B0mundsd%C3%B3ttir#bj%C3%B6rk#gunnar", + "providerDateOfUpdate": "2024-07-08T23:59:59+00:00", + "type": "provider", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "socw", + "ssnLastFour": "1234", + "givenName": "Bj\u00f6rk", + "middleName": "Gunnar", + "familyName": "Gu\u00f0mundsd\u00f3ttir", + "licenseJurisdiction": "oh", + "jurisdictionUploadedLicenseStatus": "active", + "jurisdictionUploadedCompactEligibility": "eligible", + "dateOfExpiration": "2025-04-04", + "dateOfBirth": "1985-06-06", + "dateOfUpdate": "2024-07-08T23:59:59+00:00", + "birthMonthDay": "06-06" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/user.json b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/user.json new file mode 100644 index 0000000000..5e8cd3385b --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/dynamo/user.json @@ -0,0 +1,57 @@ +{ + "pk": { + "S": "USER#a4182428-d061-701c-82e5-a3d1d547d797" + }, + "sk": { + "S": "COMPACT#socw" + }, + "famGiv": { + "S": "Williams#Justin" + }, + "compact": { + "S": "socw" + }, + "dateOfUpdate": { + "S": "2024-09-12T23:59:59+00:00" + }, + "type": { + "S": "user" + }, + "userId": { + "S": "a4182428-d061-701c-82e5-a3d1d547d797" + }, + "status": { + "S": "inactive" + }, + "attributes": { + "M": { + "email": { + "S": "justin@example.org" + }, + "givenName": { + "S": "Justin" + }, + "familyName": { + "S": "Williams" + } + } + }, + "permissions": { + "M": { + "actions": { + "SS": [ + "readPrivate" + ] + }, + "jurisdictions": { + "M": { + "oh": { + "SS": [ + "write" + ] + } + } + } + } + } +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/ingest/event-bridge-message.json b/backend/social-work-app/lambdas/python/common/tests/resources/ingest/event-bridge-message.json new file mode 100644 index 0000000000..41db4a1a7b --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/ingest/event-bridge-message.json @@ -0,0 +1,36 @@ +{ + "version": "0", + "id": "44ec3255-8d59-a6ae-0783-5563a9318a58", + "detail-type": "license.ingest", + "source": "org.compactconnect.manual-test", + "account": "992382587219", + "time": "2024-07-11T19:57:45Z", + "region": "us-east-1", + "resources": [], + "detail": { + "eventTime": "2024-07-11T19:57:45Z", + "ssnLastFour": "1234", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "licenseNumber": "A0608337260", + "givenName": "Björk", + "middleName": "Gunnar", + "familyName": "Guðmundsdóttir", + "licenseType": "cosmetologist", + "licenseStatus": "active", + "compactEligibility": "eligible", + "licenseStatusName": "DEFINITELY_A_HUMAN", + "homeAddressStreet1": "123 A St.", + "homeAddressStreet2": "Apt 321", + "homeAddressCity": "Columbus", + "homeAddressState": "oh", + "homeAddressPostalCode": "43004", + "emailAddress": "björk@example.com", + "phoneNumber": "+13213214321", + "dateOfIssuance": "2010-06-06", + "dateOfBirth": "1985-06-06", + "dateOfExpiration": "2025-04-04", + "dateOfRenewal": "2020-04-04", + "compact": "socw", + "jurisdiction": "oh" + } +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/ingest/preprocessor-sqs-message.json b/backend/social-work-app/lambdas/python/common/tests/resources/ingest/preprocessor-sqs-message.json new file mode 100644 index 0000000000..1c3fb9c79b --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/ingest/preprocessor-sqs-message.json @@ -0,0 +1,25 @@ +{ + "eventTime": "2024-07-11T19:57:45Z", + "ssn": "123-12-1234", + "licenseNumber": "A0608337260", + "givenName": "Björk", + "middleName": "Gunnar", + "familyName": "Guðmundsdóttir", + "licenseType": "cosmetologist", + "licenseStatus": "active", + "licenseStatusName": "DEFINITELY_A_HUMAN", + "compactEligibility": "eligible", + "homeAddressStreet1": "123 A St.", + "homeAddressStreet2": "Apt 321", + "homeAddressCity": "Columbus", + "homeAddressState": "oh", + "homeAddressPostalCode": "43004", + "emailAddress": "björk@example.com", + "phoneNumber": "+13213214321", + "dateOfIssuance": "2010-06-06", + "dateOfBirth": "1985-06-06", + "dateOfExpiration": "2025-04-04", + "dateOfRenewal": "2020-04-04", + "compact": "socw", + "jurisdiction": "oh" +} diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/licenses-invalid-records.csv b/backend/social-work-app/lambdas/python/common/tests/resources/licenses-invalid-records.csv new file mode 100644 index 0000000000..a146da8f29 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/licenses-invalid-records.csv @@ -0,0 +1,6 @@ +dateOfIssuance,licenseNumber,foo,dateOfBirth,licenseType,familyName,homeAddressCity,middleName,licenseStatus,compactEligibility,ssn,homeAddressStreet1,homeAddressStreet2,dateOfExpiration,homeAddressState,homeAddressPostalCode,givenName,dateOfRenewal +2024-06-30,A0608337260,1,2024-06-30,cosmetologist,Guðmundsdóttir,Birmingham,Gunnar,active,eligible,529-31-5408,123 A St.,Apt 321,2024-06-30,oh,35004,Björk,2024-06-30 +2024-06-30,A0608337260,,2024-06-30,esthetician,Scott,Huntsville,Patricia,active,eligible,529-31-5409,321 B St.,,2024-06-30,oh,35005,Elizabeth,2024-06-30 +2024-06-30,A0608337260,,2024-06-30,plumber,毛,Hoover,泽,active,eligible,529-31-5410,10101 Binary Ave.,,2024-06-30,oh,35006,覃,2024-06-30 +2024-06-30,A0608337260,,2024-06-30,esthetician,Adams,Tuscaloosa,Michael,translucent,eligible,529-31-5411,1AB3 Hex Blvd.,,2024-06-31,oh,35007,John,2024-06-30 +2024-06-30,A0608337260,,2024-06-30,cosmetologist,Carreño Quiñones,Montgomery,José,active,eligible,529-31-5412,10 Main St.,,2024-06-30,oh,35008,María,2024-13-32 diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/licenses.csv b/backend/social-work-app/lambdas/python/common/tests/resources/licenses.csv new file mode 100644 index 0000000000..f3ff9f9fd7 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/licenses.csv @@ -0,0 +1,6 @@ +dateOfIssuance,licenseNumber,dateOfBirth,licenseType,familyName,homeAddressCity,middleName,licenseStatus,licenseStatusName,compactEligibility,ssn,homeAddressStreet1,homeAddressStreet2,dateOfExpiration,homeAddressState,homeAddressPostalCode,givenName,dateOfRenewal +2024-06-30,A0608337260,2024-06-30,cosmetologist,Guðmundsdóttir,Birmingham,Gunnar,active,ACTIVE,eligible,529-31-5408,123 A St.,Apt 321,2024-06-30,oh,35004,Björk,2024-06-30 +2024-06-30,B0608337260,2024-06-30,esthetician,Scott,Huntsville,Patricia,active,ACTIVE,eligible,529-31-5409,321 B St.,,2024-06-30,oh,35005,Elizabeth,2024-06-30 +2024-06-30,C0608337260,2024-06-30,cosmetologist,毛,Hoover,泽,active,ACTIVE,eligible,529-31-5410,10101 Binary Ave.,,2024-06-30,oh,35006,覃,2024-06-30 +2024-06-30,D0608337260,2024-06-30,cosmetologist,Adams,Tuscaloosa,Michael,inactive,EXPIRED,ineligible,529-31-5411,1AB3 Hex Blvd.,,2024-06-30,oh,35007,John,2024-06-30 +2024-06-30,E0608337260,2024-06-30,cosmetologist,Carreño Quiñones,Montgomery,José,active,ACTIVE_IN_RENEWAL,eligible,529-31-5412,10 Main St.,,2024-06-30,oh,35008,María,2024-06-30 diff --git a/backend/social-work-app/lambdas/python/common/tests/resources/put-event.json b/backend/social-work-app/lambdas/python/common/tests/resources/put-event.json new file mode 100644 index 0000000000..cde0eb6f22 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/resources/put-event.json @@ -0,0 +1,39 @@ +{ + "Records":[ + { + "eventVersion":"2.1", + "eventSource":"aws:s3", + "awsRegion":"us-west-2", + "eventTime":"1970-01-01T00:00:00.000Z", + "eventName":"ObjectCreated:Put", + "userIdentity":{ + "principalId":"AIDAJDPLRKLG7UEXAMPLE" + }, + "requestParameters":{ + "sourceIPAddress":"127.0.0.1" + }, + "responseElements":{ + "x-amz-request-id":"C3D13FE58DE4C810", + "x-amz-id-2":"FMyUVURIY8/IgAtTv8xRjskZQpcIZ9KG4V5Wp6S7S/JRWeUWerMUE5JgHvANOjpD" + }, + "s3":{ + "s3SchemaVersion":"1.0", + "configurationId":"testConfigRule", + "bucket":{ + "name":"mybucket", + "ownerIdentity":{ + "principalId":"A3NL1KOZZKExample" + }, + "arn":"arn:aws:s3:::mybucket" + }, + "object":{ + "key":"socw/oh/abcde", + "size":1024, + "eTag":"d41d8cd98f00b204e9800998ecf8427e", + "versionId":"096fKKXTRTtl3on89fVO.nfljtsv6qko", + "sequencer":"0055AED6DCD90281E5" + } + } + } + ] + } diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/__init__.py b/backend/social-work-app/lambdas/python/common/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_api_handler.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_api_handler.py new file mode 100644 index 0000000000..32df56a81e --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_api_handler.py @@ -0,0 +1,179 @@ +# ruff: noqa: ARG001 unused-argument +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError + +from tests import TstLambdas + + +class TestApiHandler(TstLambdas): + """Testing that the api_handler decorator is working as expected.""" + + def test_happy_path(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual('{"message": "OK"}', resp['body']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + + def test_unauthorized(self): + from cc_common.exceptions import CCUnauthorizedException + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise CCUnauthorizedException("You can't do that") + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(401, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + + def test_invalid_request(self): + from cc_common.exceptions import CCInvalidRequestException + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise CCInvalidRequestException("You can't do that") + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(400, resp['statusCode']) + self.assertEqual({'message': "You can't do that"}, json.loads(resp['body'])) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + + def test_client_error(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise ClientError(error_response={'Error': {'Code': 'CantDoThatException'}}, operation_name='DoAWSThing') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with self.assertRaises(ClientError): + lambda_handler(event, self.mock_context) + + def test_runtime_error(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise RuntimeError('Egads!') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with self.assertRaises(RuntimeError): + lambda_handler(event, self.mock_context) + + def test_null_headers(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['headers'] = None + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual('{"message": "OK"}', resp['body']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + + def test_local_ui(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['headers']['origin'] = 'http://localhost:1234' + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + self.assertEqual('http://localhost:1234', resp['headers']['Access-Control-Allow-Origin']) + + def test_disallowed_origin(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['headers']['origin'] = 'https://example.com' # not in ALLOWED_ORIGINS + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + + def test_no_origin(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + del event['headers']['origin'] + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + + def test_unsupported_media_type(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # We only accept json + event['headers']['Content-Type'] = 'text/plain' + event['body'] = 'not json' + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(415, resp['statusCode']) + + def test_json_decode_error(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return json.loads(event['body']) + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['headers']['Content-Type'] = 'application/json' + event['body'] = 'not json' + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(400, resp['statusCode']) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py new file mode 100644 index 0000000000..75cc0b822e --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py @@ -0,0 +1,150 @@ +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext + +from tests import TstLambdas + + +class TestAuthorizeCompactJurisdiction(TstLambdas): + def test_scope_by_path(self): + from cc_common.utils import authorize_compact_jurisdiction + + @authorize_compact_jurisdiction(action='write') + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff oh/socw.write' + event['pathParameters'] = { + 'compact': 'socw', + 'jurisdiction': 'oh', + } + + self.assertEqual({'body': 'Hurray!'}, example_entrypoint(event, self.mock_context)) + + def test_no_path_param(self): + from cc_common.exceptions import CCInvalidRequestException + from cc_common.utils import authorize_compact_jurisdiction + + @authorize_compact_jurisdiction(action='write') + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff oh/socw.write' + event['pathParameters'] = {} + + with self.assertRaises(CCInvalidRequestException): + example_entrypoint(event, self.mock_context) + + def test_no_authorizer(self): + from cc_common.exceptions import CCUnauthorizedException + from cc_common.utils import authorize_compact_jurisdiction + + @authorize_compact_jurisdiction(action='write') + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + del event['requestContext']['authorizer'] + event['pathParameters'] = { + 'compact': 'socw', + 'jurisdiction': 'oh', + } + + with self.assertRaises(CCUnauthorizedException): + example_entrypoint(event, self.mock_context) + + def test_missing_scope(self): + from cc_common.exceptions import CCAccessDeniedException + from cc_common.utils import authorize_compact_jurisdiction + + @authorize_compact_jurisdiction(action='write') + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff' + event['pathParameters'] = { + 'compact': 'socw', + 'jurisdiction': 'oh', + } + + with self.assertRaises(CCAccessDeniedException): + example_entrypoint(event, self.mock_context) + + +class TestAuthorizeCompact(TstLambdas): + def test_authorize_compact(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff socw/readGeneral' + event['pathParameters'] = { + 'compact': 'socw', + } + + self.assertEqual({'body': 'Hurray!'}, example_entrypoint(event, self.mock_context)) + + def test_no_path_param(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.exceptions import CCInvalidRequestException + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff socw/readGeneral' + event['pathParameters'] = {} + + with self.assertRaises(CCInvalidRequestException): + example_entrypoint(event, self.mock_context) + + def test_no_authorizer(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.exceptions import CCUnauthorizedException + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + del event['requestContext']['authorizer'] + event['pathParameters'] = {'compact': 'socw'} + + with self.assertRaises(CCUnauthorizedException): + example_entrypoint(event, self.mock_context) + + def test_missing_scope(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.exceptions import CCAccessDeniedException + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff' + event['pathParameters'] = {'compact': 'socw'} + + with self.assertRaises(CCAccessDeniedException): + example_entrypoint(event, self.mock_context) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_config.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_config.py new file mode 100644 index 0000000000..745e526323 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_config.py @@ -0,0 +1,89 @@ +""" +Unit tests for _Config, including live_compact_jurisdictions cache. + +Tests mock the compact configuration client (DynamoDB/table) responses. +""" + +import os +from unittest import TestCase +from unittest.mock import MagicMock, patch + + +class TestConfigLiveCompactJurisdictions(TestCase): + """Tests for _Config.live_compact_jurisdictions cached_property.""" + + def test_returns_dict_of_compact_to_list_of_jurisdiction_codes(self): + """live_compact_jurisdictions returns dict[str, list[str]] (postal abbreviations).""" + mock_jurisdictions = ['al', 'ky'] + with patch.dict(os.environ, {'COMPACTS': '["socw"]'}, clear=False): + from cc_common.config import _Config + + config = _Config() + mock_client = MagicMock() + mock_client.get_live_compact_jurisdictions.return_value = mock_jurisdictions + config.compact_configuration_client = mock_client + + result = config.live_compact_jurisdictions + + self.assertIsInstance(result, dict) + self.assertIn('socw', result) + self.assertIsInstance(result['socw'], list) + self.assertEqual(result['socw'], mock_jurisdictions) + + def test_calls_get_live_compact_jurisdictions_for_each_compact(self): + """For each compact in compacts, calls get_live_compact_jurisdictions(compact).""" + with patch.dict(os.environ, {'COMPACTS': '["socw", "other"]'}, clear=False): + from cc_common.config import _Config + + config = _Config() + mock_client = MagicMock() + mock_client.get_live_compact_jurisdictions.side_effect = [ + ['al', 'oh'], + ['tx'], + ] + config.compact_configuration_client = mock_client + + result = config.live_compact_jurisdictions + + self.assertEqual(mock_client.get_live_compact_jurisdictions.call_count, 2) + mock_client.get_live_compact_jurisdictions.assert_any_call('socw') + mock_client.get_live_compact_jurisdictions.assert_any_call('other') + self.assertEqual(result['socw'], ['al', 'oh']) + self.assertEqual(result['other'], ['tx']) + + def test_on_exception_logs_error_and_reraises(self): + """On exception from get_live_compact_jurisdictions, log error and re-raise.""" + with patch.dict(os.environ, {'COMPACTS': '["socw", "failing"]'}, clear=False): + with patch('cc_common.config.logger') as mock_logger: + from cc_common.config import _Config + + config = _Config() + mock_client = MagicMock() + config.compact_configuration_client = mock_client + mock_client.get_live_compact_jurisdictions.side_effect = [ + ['al', 'oh'], + Exception('Table not found'), + ] + + with self.assertRaises(Exception) as ctx: + _ = config.live_compact_jurisdictions + + self.assertEqual(str(ctx.exception), 'Table not found') + mock_logger.error.assert_called_once() + self.assertIn('live jurisdictions', str(mock_logger.error.call_args).lower()) + + def test_value_is_cached_after_first_access(self): + """live_compact_jurisdictions is cached; second access does not call client again.""" + with patch.dict(os.environ, {'COMPACTS': '["socw"]'}, clear=False): + from cc_common.config import _Config + + config = _Config() + mock_client = MagicMock() + mock_client.get_live_compact_jurisdictions.return_value = ['al', 'oh'] + config.compact_configuration_client = mock_client + + first = config.live_compact_jurisdictions + second = config.live_compact_jurisdictions + + self.assertEqual(first, second) + mock_client.get_live_compact_jurisdictions.assert_called_once_with('socw') diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/__init__.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_data_client.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_data_client.py new file mode 100644 index 0000000000..3604a867ef --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_data_client.py @@ -0,0 +1,47 @@ +from unittest.mock import MagicMock + +from botocore.exceptions import ClientError +from cc_common.exceptions import CCNotFoundException + +from tests import TstLambdas + + +class TestDataClient(TstLambdas): + def setUp(self): + from cc_common.config import _Config + from cc_common.data_model.data_client import DataClient + + self.mock_provider_table = MagicMock(name='provider-table') + self.mock_ssn_table = MagicMock(name='ssn-table') + self.mock_batch_writer = MagicMock(name='batch_writer') + + # Ensure the context manager returns the mock_batch_writer + self.mock_provider_table.batch_writer.return_value.__enter__.return_value = self.mock_batch_writer + + self.mock_config = MagicMock(spec=_Config) # noqa: SLF001 protected-access + self.mock_config.provider_table = self.mock_provider_table + self.mock_config.ssn_table = self.mock_ssn_table + + self.client = DataClient(self.mock_config) + + def test_get_or_create_provider_id_existing(self): + # Mock ClientError for existing provider + error_response = { + 'Error': {'Code': 'ConditionalCheckFailedException'}, + 'Item': {'providerId': {'S': 'existing_provider_id'}}, + } + self.mock_ssn_table.put_item.side_effect = ClientError(error_response, 'PutItem') + + # Call the method + provider_id = self.client.get_or_create_provider_id(compact='socw', ssn='123456789') + + # Verify the result + self.assertEqual(provider_id, 'existing_provider_id') + + def test_get_provider_not_found(self): + # Mock response from DynamoDB for non-existent provider + self.mock_provider_table.query.return_value = {'Items': []} + + # Verify it raises CCNotFoundException + with self.assertRaises(CCNotFoundException): + self.client.get_provider(compact='socw', provider_id='test_id', detail=True, consistent_read=False) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_paginated.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_paginated.py new file mode 100644 index 0000000000..3dd95378f7 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_paginated.py @@ -0,0 +1,442 @@ +# ruff: noqa: ARG001, SLF001 unused-function-argument private-access +import json +from base64 import b64encode + +from botocore.exceptions import ClientError + +from tests import TstLambdas + + +class TestPaginated(TstLambdas): + def setUp(self): # noqa: N801 invalid-name + with open('tests/resources/dynamo/provider.json') as f: + self._item = json.load(f) + + def test_pagination_parameters(self): + from cc_common.data_model.query_paginator import paginated_query + from cc_common.data_model.schema import ProviderRecordSchema + + calls = [] + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_something(*args, **kwargs): + calls.append((args, kwargs)) + return { + 'Items': [self._item], + 'Count': 5, + 'LastEvaluatedKey': {'pk': self._item['pk'], 'sk': self._item['sk']}, + } + + last_key = b64encode(json.dumps({'pk': '안녕하세요', 'sk': '2'}).encode('utf-8')) + resp = get_something('arg1', 'arg2', pagination={'lastKey': last_key, 'pageSize': 5}, kwarg1='baf') + + self.assertEqual( + { + 'items': [ProviderRecordSchema().load(self._item)], + 'pagination': { + 'pageSize': 5, + 'lastKey': b64encode( + json.dumps({'pk': self._item['pk'], 'sk': self._item['sk']}).encode('utf-8'), + ).decode('ascii'), + 'prevLastKey': last_key, + }, + }, + resp, + ) + # Check that the decorated function was called with the expected args + self.assertEqual( + [ + ( + ('arg1', 'arg2'), + { + 'kwarg1': 'baf', + 'dynamo_pagination': {'ExclusiveStartKey': {'pk': '안녕하세요', 'sk': '2'}, 'Limit': 5}, + }, + ), + ], + calls, + ) + + def test_multiple_internal_pages(self): + """In the case of server-side filtering, DynamoDB scans the Limit number of records but only returns records + that match filter criteria, which can be fewer. In this case, paginated_query should automatically query + multiple times to fill out the requested page size. + """ + from cc_common.data_model.query_paginator import paginated_query + from cc_common.data_model.schema import ProviderRecordSchema + + calls = [] + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_something(*args, **kwargs): + calls.append((args, kwargs)) + + last_key = int(kwargs['dynamo_pagination'].get('ExclusiveStartKey', {}).get('pk', 0)) + resp = {'Items': [], 'Count': 3} + # 3 items, starting after last_key + for i in range(last_key + 1, last_key + 4): + item = self._item.copy() + # Number users to give us something simple to inspect + item['pk'] = str(i) + resp['Items'].append(item) + + resp['LastEvaluatedKey'] = {'pk': resp['Items'][-1]['pk']} + return resp + + last_key = b64encode(json.dumps({'pk': '1'}).encode('utf-8')) + resp = get_something('arg1', 'arg2', pagination={'lastKey': last_key, 'pageSize': 10}, kwarg1='baf') + + # We are requesting 10 users, starting with exclusive key 1. This should result in three queries to the DB, + # with the last record included in the DB response having a pk of 13: + # + # | Query | DB sequence | PK | Ret Sequence | Filter | last_key | + # |-------|-------------|----|--------------|-----------|----------| + # | 1 | 1 | 2 | 1 | | 1 | + # | 1 | 2 | 3 | 2 | | | + # | 1 | 3 | 4 | 3 | | | + # | 2 | 1 | 5 | 4 | | 4 | + # | 2 | 2 | 6 | 5 | | | + # | 2 | 3 | 7 | 6 | | | + # | 3 | 1 | 8 | 7 | | 7 | + # | 3 | 2 | 9 | 8 | | | + # | 3 | 3 | 10 | 9 | | | + # | 4 | 1 | 11 | 10 | | 10 | + # |-------|-------------|----|--------------|-----------|----------| + # | 4 | 2 | 12 | | truncated | | + # | 4 | 3 | 13 | | truncated | | + # |-------|-------------|----|--------------|-----------|----------| + + # We'll need at least 12 items from the DB to produce a 10-item page. If each DB query returns 3 items, that + # means 4 queries. + self.assertEqual( + [ + ( + ('arg1', 'arg2'), + {'kwarg1': 'baf', 'dynamo_pagination': {'ExclusiveStartKey': {'pk': '1'}, 'Limit': 10}}, + ), + ( + ('arg1', 'arg2'), + { + 'kwarg1': 'baf', + 'dynamo_pagination': { + 'ExclusiveStartKey': { + 'pk': '4', + }, + 'Limit': 10, + }, + }, + ), + ( + ('arg1', 'arg2'), + { + 'kwarg1': 'baf', + 'dynamo_pagination': { + 'ExclusiveStartKey': { + 'pk': '7', + }, + 'Limit': 10, + }, + }, + ), + ( + ('arg1', 'arg2'), + { + 'kwarg1': 'baf', + 'dynamo_pagination': { + 'ExclusiveStartKey': { + 'pk': '10', + }, + 'Limit': 10, + }, + }, + ), + ], + calls, + ) + self.assertEqual( + { + # 3*4=12 items will have been returned from queries internally, but only 10 make it out to fill out the + # pageSize + 'items': [ProviderRecordSchema().load(self._item)] * 10, + 'pagination': { + 'pageSize': 10, + 'lastKey': b64encode(json.dumps({'pk': '11'}).encode('utf-8')).decode('utf-8'), + 'prevLastKey': last_key, + }, + }, + resp, + ) + + def test_multiple_internal_pages_client_filter(self): + """In the case of client-side filtering, DynamoDB may return the Limit of records, but a client-side filter may + trim that number back below the Limit. In this case, paginated_query should automatically query multiple times + to fill out the requested page size. Because of the complexity of this flow, we'll go out of our way to look + closely at the last_key behavior between each query. + """ + from cc_common.data_model.query_paginator import paginated_query + from cc_common.data_model.schema import ProviderRecordSchema + + calls = [] + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_something(*args, **kwargs): + calls.append((args, kwargs)) + + last_key = int(kwargs['dynamo_pagination'].get('ExclusiveStartKey', {}).get('pk', 0)) + resp = { + 'Items': [], + 'Count': 8, + } + # 8 items, starting after last_key + for i in range(last_key + 1, last_key + 9): + item = self._item.copy() + # Number users to give us something simple to filter by + item['pk'] = str(i) + resp['Items'].append(item) + + resp['LastEvaluatedKey'] = {'pk': resp['Items'][-1]['pk']} + return resp + + def filter_odd_users(item: dict) -> bool: + # True for even numbers + return int(item['pk']) % 2 == 0 + + last_key = b64encode(json.dumps({'pk': '1'}).encode('utf-8')) + resp = get_something( + 'arg1', + 'arg2', + pagination={'lastKey': last_key, 'pageSize': 10}, + client_filter=filter_odd_users, + kwarg1='baf', + ) + + # We are requesting 10 users, starting with exclusive key 1, and filtering out all odds client-side. This + # should result in three queries to the DB, with the last record included in the response having a pk of 20: + # + # | Query | DB sequence | PK | Ret Sequence | Filter | last_key | + # |-------|-------------|----|--------------|-----------|----------| + # | 1 | 1 | 2 | 1 | | 1 | + # | 1 | 2 | 3 | | odd | | + # | 1 | 3 | 4 | 2 | | | + # | 1 | 4 | 5 | | odd | | + # | 1 | 5 | 6 | 3 | | | + # | 1 | 6 | 7 | | odd | | + # | 1 | 7 | 8 | 4 | | | + # | 1 | 8 | 9 | | odd | | + # | 2 | 1 | 10 | 5 | | 9 | + # | 2 | 2 | 11 | | odd | | + # | 2 | 3 | 12 | 6 | | | + # | 2 | 4 | 13 | | odd | | + # | 2 | 5 | 14 | 7 | | | + # | 2 | 6 | 15 | | odd | | + # | 2 | 7 | 16 | 8 | | | + # | 2 | 8 | 17 | | odd | | + # | 3 | 1 | 18 | 9 | | 17 | + # | 3 | 2 | 19 | | odd | | + # | 3 | 3 | 20 | 10 | | | + # |-------|-------------|----|--------------|-----------|----------| + # | 3 | 4 | 21 | | truncated | | + # | 3 | 5 | 22 | | truncated | | + # | 3 | 6 | 23 | | truncated | | + # | 3 | 7 | 24 | | truncated | | + # | 3 | 8 | 25 | | truncated | | + + # With client-side filtering every other item, we'll need at least 19 items from the DB to produce a 10-item + # page. If each DB query returns 9 items, that means 3 queries. + self.assertEqual( + [ + ( + ('arg1', 'arg2'), + {'kwarg1': 'baf', 'dynamo_pagination': {'ExclusiveStartKey': {'pk': '1'}, 'Limit': 10}}, + ), + ( + ('arg1', 'arg2'), + { + 'kwarg1': 'baf', + 'dynamo_pagination': { + 'ExclusiveStartKey': { + 'pk': '9', + }, + 'Limit': 10, + }, + }, + ), + ( + ('arg1', 'arg2'), + { + 'kwarg1': 'baf', + 'dynamo_pagination': { + 'ExclusiveStartKey': { + 'pk': '17', + }, + 'Limit': 10, + }, + }, + ), + ], + calls, + ) + self.assertEqual( + { + # 3*8=24 items will have been returned from queries internally, but only 10 make it out to fill out the + # pageSize + 'items': [ProviderRecordSchema().load(self._item)] * 10, + 'pagination': { + 'pageSize': 10, + 'lastKey': b64encode( + # Because we are mucking with pk for our filtering, the pk here should be the last value that + # passed through the client filter + json.dumps({'pk': '20'}).encode('utf-8'), + ).decode('utf-8'), + 'prevLastKey': last_key, + }, + }, + resp, + ) + + def test_no_pagination_parameters(self): + from cc_common.data_model.query_paginator import paginated_query + from cc_common.data_model.schema import ProviderRecordSchema + + calls = [] + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_something(*args, **kwargs): + calls.append((args, kwargs)) + return {'Items': [self._item], 'Count': 1} + + resp = get_something() + + self.assertEqual( + { + 'items': [ProviderRecordSchema().load(self._item)], + 'pagination': {'pageSize': 100, 'lastKey': None, 'prevLastKey': None}, + }, + resp, + ) + self.assertEqual( + [ + ( + (), + { + 'dynamo_pagination': { + # Should fall back to default from cc_common.config + 'Limit': 100, + }, + }, + ), + ], + calls, + ) + + def test_invalid_key(self): + from cc_common.data_model.query_paginator import paginated_query + from cc_common.exceptions import CCInvalidRequestException + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_something(*args, **kwargs): # noqa: ARG001 unused-argument + return {'Items': [], 'Count': 1} + + with self.assertRaises(CCInvalidRequestException): + get_something(pagination={'lastKey': 'not-b64-string'}) + + def test_db_invalid_key(self): + from cc_common.data_model.query_paginator import paginated_query + from cc_common.exceptions import CCInvalidRequestException + + @paginated_query(set_query_limit_to_match_page_size=True) + def throw_an_error(*args, **kwargs): + # This is what dynamodb rejecting the ExclusiveStartKey looks like for boto3 + raise ClientError( + error_response={ + 'Error': {'Message': 'The provided starting key is invalid', 'Code': 'ValidationException'}, + 'ResponseMetadata': { + 'RequestId': 'AQ43F939QGII7PJFDUT7K7K67RVV4KQNSO5AEMVJF66Q9ASUAAJG', + 'HTTPStatusCode': 400, + 'HTTPHeaders': { + 'server': 'Server', + 'date': 'Tue, 27 Jun 2024 22:06:20 GMT', + 'content-type': 'application/x-amz-json-1.0', + 'content-length': '107', + 'connection': 'keep-alive', + 'x-amzn-requestid': 'AQ43F939QGII7PJFDUT7K7K67RVV4KQNSO5AEMVJF66Q9ASUAAJG', + 'x-amz-crc32': '1281463594', + }, + 'RetryAttempts': 0, + }, + }, + operation_name='Query', + ) + + with self.assertRaises(CCInvalidRequestException): + throw_an_error() + + def test_db_other_error(self): + from cc_common.data_model.query_paginator import paginated_query + + @paginated_query(set_query_limit_to_match_page_size=True) + def throw_an_error(*args, **kwargs): + # An AccessDeniedException, for example, should be re-raised + raise ClientError( + error_response={ + 'Error': { + 'Message': 'User: arn:aws:sts::000011112222:assumed-role/SomeRole/session-id ' + 'is not authorized to perform: dynamodb:GetItem on resource: ' + 'arn:aws:dynamodb:us-east-1:000011112222:table/some-table with an explicit deny in' + ' a resource-based policy', + 'Code': 'AccessDeniedException', + }, + 'ResponseMetadata': { + 'RequestId': 'EJFUNRLG2GF7OTHTFVO8P3ODBRVV4KQNSO5AEMVJF66Q9ASUAAJG', + 'HTTPStatusCode': 400, + 'HTTPHeaders': { + 'server': 'Server', + 'date': 'Mon, 01 Jul 2024 16:00:55 GMT', + 'content-type': 'application/x-amz-json-1.0', + 'content-length': '379', + 'connection': 'keep-alive', + 'x-amzn-requestid': 'EJFUNRLG2GF7OTHTFVO8P3ODBRVV4KQNSO5AEMVJF66Q9ASUAAJG', + 'x-amz-crc32': '1682830636', + }, + 'RetryAttempts': 0, + }, + }, + operation_name='Query', + ) + + with self.assertRaises(ClientError): + throw_an_error() + + def test_instance_method(self): + """Decorating instance methods works slightly differently than functions, so we'll make sure our decorator works + for both. + """ + from cc_common.data_model.query_paginator import paginated_query + + calls = [] + + class SomeClient: + def __init__(self, test_inst): + self._provider = test_inst._item + + @paginated_query(set_query_limit_to_match_page_size=True) + def get_something(self, *args, **kwargs): + calls.append((args, kwargs)) + return {'Items': [self._provider], 'Count': 5} + + last_key = b64encode(json.dumps({'pk': '안녕하세요', 'sk': '2'}).encode('utf-8')) + SomeClient(self).get_something('arg1', 'arg2', pagination={'lastKey': last_key, 'pageSize': 5}, kwarg1='baf') + + # Check that the decorated method was called with the expected args + self.assertEqual( + [ + ( + ('arg1', 'arg2'), + { + 'kwarg1': 'baf', + 'dynamo_pagination': {'ExclusiveStartKey': {'pk': '안녕하세요', 'sk': '2'}, 'Limit': 5}, + }, + ), + ], + calls, + ) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/__init__.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_adverse_action.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_adverse_action.py new file mode 100644 index 0000000000..fbc211fdc1 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_adverse_action.py @@ -0,0 +1,148 @@ +import json + +from marshmallow import ValidationError + +from tests import TstLambdas + + +class TestAdverseActionRecordSchema(TstLambdas): + def setUp(self): + from common_test.test_data_generator import TestDataGenerator + + self.test_data_generator = TestDataGenerator + + def test_serde(self): + """Test round-trip deserialization/serialization""" + from cc_common.data_model.schema.adverse_action.record import AdverseActionRecordSchema + + expected_adverse_action = ( + self.test_data_generator.generate_default_adverse_action().serialize_to_database_record() + ) + + schema = AdverseActionRecordSchema() + loaded_schema = schema.load(expected_adverse_action.copy()) + + adverse_action_data = schema.dump(loaded_schema) + + # Drop dynamic fields + del expected_adverse_action['dateOfUpdate'] + del adverse_action_data['dateOfUpdate'] + + self.assertEqual(expected_adverse_action, adverse_action_data) + + def test_invalid(self): + from cc_common.data_model.schema.adverse_action.record import AdverseActionRecordSchema + + adverse_action_data = self.test_data_generator.generate_default_adverse_action().to_dict() + adverse_action_data.pop('providerId') + + with self.assertRaises(ValidationError): + AdverseActionRecordSchema().load(adverse_action_data) + + def test_invalid_action_against(self): + from cc_common.data_model.schema.adverse_action import AdverseActionData + from cc_common.data_model.schema.common import CompactEligibilityStatus + + adverse_action_data = self.test_data_generator.generate_default_adverse_action() + + # setting to an invalid value from another enum + adverse_action_data.actionAgainst = CompactEligibilityStatus.ELIGIBLE + + with self.assertRaises(ValidationError): + AdverseActionData.from_database_record(adverse_action_data.serialize_to_database_record()) + + def test_invalid_license_type(self): + from cc_common.data_model.schema.adverse_action import AdverseActionData + + adverse_action_data = self.test_data_generator.generate_default_adverse_action() + + # setting to an invalid license type name, with a valid abbreviation + adverse_action_data.licenseType = 'foobar' + adverse_action_data.license_abbreviation = 'cos' + + with self.assertRaises(ValidationError): + AdverseActionData.from_database_record(adverse_action_data.serialize_to_database_record()) + + def test_invalid_license_type_abbreviation(self): + from cc_common.data_model.schema.adverse_action import AdverseActionData + + adverse_action_data = self.test_data_generator.generate_default_adverse_action() + + # setting to a valid license type name, and an invalid abbreviation + adverse_action_data.licenseType = 'cosmetologist' + adverse_action_data.licenseTypeAbbreviation = 'foo' + + with self.assertRaises(ValidationError): + AdverseActionData.from_database_record(adverse_action_data.serialize_to_database_record()) + + +class TestAdverseActionDataClass(TstLambdas): + def setUp(self): + from common_test.test_data_generator import TestDataGenerator + + self.test_data_generator = TestDataGenerator + + def test_adverse_action_data_class_getters_return_expected_values(self): + from cc_common.data_model.schema.adverse_action import AdverseActionData + + adverse_action_data = self.test_data_generator.generate_default_adverse_action().serialize_to_database_record() + + adverse_action = AdverseActionData.from_database_record(adverse_action_data) + self.assertEqual(str(adverse_action.providerId), adverse_action_data['providerId']) + self.assertEqual(adverse_action.jurisdiction, adverse_action_data['jurisdiction']) + self.assertEqual(adverse_action.licenseTypeAbbreviation, adverse_action_data['licenseTypeAbbreviation']) + self.assertEqual(adverse_action.actionAgainst, adverse_action_data['actionAgainst']) + self.assertEqual( + adverse_action.clinicalPrivilegeActionCategories, adverse_action_data['clinicalPrivilegeActionCategories'] + ) + self.assertEqual(adverse_action.effectiveStartDate.isoformat(), adverse_action_data['effectiveStartDate']) + self.assertEqual(str(adverse_action.submittingUser), adverse_action_data['submittingUser']) + self.assertEqual(adverse_action.creationDate.isoformat(), adverse_action_data['creationDate']) + self.assertEqual(str(adverse_action.adverseActionId), adverse_action_data['adverseActionId']) + + def test_adverse_action_data_class_outputs_expected_database_object(self): + # check final snapshot of expected data + adverse_action_data = self.test_data_generator.generate_default_adverse_action().serialize_to_database_record() + # remove dynamic field + del adverse_action_data['dateOfUpdate'] + + self.assertEqual( + { + 'actionAgainst': 'privilege', + 'adverseActionId': '98765432-9876-9876-9876-987654321098', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['fraud'], + 'compact': 'socw', + 'creationDate': '2024-11-08T23:59:59+00:00', + 'effectiveStartDate': '2024-02-15', + 'jurisdiction': 'ne', + 'licenseType': 'cosmetologist', + 'licenseTypeAbbreviation': 'cos', + 'pk': 'socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'sk': 'socw#PROVIDER#privilege/ne/cos#ADVERSE_ACTION#98765432-9876-9876-9876-987654321098', + 'submittingUser': '12a6377e-c3a5-40e5-bca5-317ec854c556', + 'type': 'adverseAction', + }, + adverse_action_data, + ) + + +class TestAdverseActionPostRequestSchema(TstLambdas): + def test_validate_post(self): + """Test validation of a POST request""" + from cc_common.data_model.schema.adverse_action.api import AdverseActionPostRequestSchema + + with open('tests/resources/api/adverse-action-post.json') as f: + AdverseActionPostRequestSchema().load(json.load(f)) + + def test_invalid_post(self): + """Test validation error when required field is missing""" + from cc_common.data_model.schema.adverse_action.api import AdverseActionPostRequestSchema + + with open('tests/resources/api/adverse-action-post.json') as f: + adverse_action_data = json.load(f) + adverse_action_data.pop('encumbranceEffectiveDate') + + with self.assertRaises(ValidationError): + AdverseActionPostRequestSchema().load(adverse_action_data) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_base_record.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_base_record.py new file mode 100644 index 0000000000..de43d8149d --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_base_record.py @@ -0,0 +1,28 @@ +import json + +from tests import TstLambdas + + +class TestRegistration(TstLambdas): + def test_license_privilege_lookup(self): + from cc_common.data_model.schema import LicenseRecordSchema, ProviderRecordSchema + from cc_common.data_model.schema.base_record import BaseRecordSchema + + with open('tests/resources/dynamo/license.json') as f: + license_data = json.load(f) + + with open('tests/resources/dynamo/provider.json') as f: + provider_data = json.load(f) + + license_schema = BaseRecordSchema.get_schema_by_type(license_data['type']) + self.assertIsInstance(license_schema, LicenseRecordSchema) + + provider_schema = BaseRecordSchema.get_schema_by_type(provider_data['type']) + self.assertIsInstance(provider_schema, ProviderRecordSchema) + + def test_invalid_type(self): + from cc_common.data_model.schema.base_record import BaseRecordSchema + from cc_common.exceptions import CCInternalException + + with self.assertRaises(CCInternalException): + BaseRecordSchema.get_schema_by_type('some-unsupported-type') diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_compact.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_compact.py new file mode 100644 index 0000000000..650adc3952 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_compact.py @@ -0,0 +1,95 @@ +import json +from decimal import Decimal + +from marshmallow import ValidationError + +from tests import TstLambdas + + +class TestCompactRecordSchema(TstLambdas): + def test_serde(self): + """Test round-trip deserialization/serialization""" + from cc_common.data_model.schema.compact.record import CompactRecordSchema + + with open('tests/resources/dynamo/compact.json') as f: + expected_compact = json.load(f, parse_float=Decimal) + + schema = CompactRecordSchema() + loaded_schema = schema.load(expected_compact.copy()) + + compact_data = schema.dump(loaded_schema) + + # remove dynamic update fields + expected_compact.pop('dateOfUpdate') + compact_data.pop('dateOfUpdate') + + self.assertEqual(expected_compact, compact_data) + + def test_compact_config_removes_unknown_fields_gracefully(self): + """ + We need these config files to use a forgiving schema, so that adding new fields + in the future does not cause an outage during deployment. + """ + from cc_common.data_model.schema.compact.record import CompactRecordSchema + + with open('tests/resources/dynamo/compact.json') as f: + expected_compact = json.load(f, parse_float=Decimal) + expected_compact['someNewValue'] = 'Will this break something?' + + schema = CompactRecordSchema() + loaded_schema = schema.load(expected_compact.copy()) + + compact_data = schema.dump(loaded_schema) + + self.assertNotIn('someNewValue', compact_data) + + def test_compact_config_raises_validation_error_if_missing_required_field(self): + from cc_common.data_model.schema.compact.record import CompactRecordSchema + + with open('tests/resources/dynamo/compact.json') as f: + expected_compact = json.load(f, parse_float=Decimal) + del expected_compact['compactAbbr'] + + with self.assertRaises(ValidationError): + CompactRecordSchema().load(expected_compact.copy()) + + def test_compact_config_raises_validation_error_for_invalid_configured_state_jurisdiction(self): + """Test that an invalid jurisdiction postal abbreviation in configuredStates raises a ValidationError""" + from cc_common.data_model.schema.compact.record import CompactRecordSchema + + with open('tests/resources/dynamo/compact.json') as f: + expected_compact = json.load(f, parse_float=Decimal) + expected_compact['configuredStates'][0]['postalAbbreviation'] = 'invalid' + + with self.assertRaises(ValidationError) as context: + CompactRecordSchema().load(expected_compact.copy()) + + self.assertIn("{'configuredStates': {0: {'postalAbbreviation': ['Must be one of:", str(context.exception)) + + def test_compact_config_raises_validation_error_for_missing_configured_state_fields(self): + """Test that missing required fields in configuredStates raises a ValidationError""" + from cc_common.data_model.schema.compact.record import CompactRecordSchema + + with open('tests/resources/dynamo/compact.json') as f: + expected_compact = json.load(f, parse_float=Decimal) + del expected_compact['configuredStates'][0]['isLive'] + + with self.assertRaises(ValidationError) as context: + CompactRecordSchema().load(expected_compact.copy()) + + self.assertIn('configuredStates', str(context.exception)) + self.assertIn('isLive', str(context.exception)) + + def test_compact_config_allows_empty_configured_states(self): + """Test that an empty configuredStates list is valid""" + from cc_common.data_model.schema.compact.record import CompactRecordSchema + + with open('tests/resources/dynamo/compact.json') as f: + expected_compact = json.load(f, parse_float=Decimal) + expected_compact['configuredStates'] = [] + + schema = CompactRecordSchema() + loaded_schema = schema.load(expected_compact.copy()) + + compact_data = schema.dump(loaded_schema) + self.assertEqual([], compact_data['configuredStates']) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_investigation.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_investigation.py new file mode 100644 index 0000000000..df7c63e1ec --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_investigation.py @@ -0,0 +1,161 @@ +from marshmallow import ValidationError + +from tests import TstLambdas + + +class TestInvestigationRecordSchema(TstLambdas): + def setUp(self): + from common_test.test_data_generator import TestDataGenerator + + self.test_data_generator = TestDataGenerator + + def test_serde(self): + """Test round-trip deserialization/serialization""" + from cc_common.data_model.schema.investigation.record import InvestigationRecordSchema + + expected_investigation = ( + self.test_data_generator.generate_default_investigation().serialize_to_database_record() + ) + + schema = InvestigationRecordSchema() + loaded_schema = schema.load(expected_investigation.copy()) + + investigation_data = schema.dump(loaded_schema) + + # Pop dynamic fields + expected_investigation.pop('dateOfUpdate') + investigation_data.pop('dateOfUpdate') + + self.assertEqual(expected_investigation, investigation_data) + + def test_invalid(self): + from cc_common.data_model.schema.investigation.record import InvestigationRecordSchema + + investigation_data = self.test_data_generator.generate_default_investigation().to_dict() + investigation_data.pop('providerId') + + with self.assertRaises(ValidationError): + InvestigationRecordSchema().load(investigation_data) + + def test_invalid_investigation_against(self): + from cc_common.data_model.schema.common import CompactEligibilityStatus + from cc_common.data_model.schema.investigation import InvestigationData + + investigation_data = self.test_data_generator.generate_default_investigation() + + # setting to an invalid value from another enum + investigation_data.investigationAgainst = CompactEligibilityStatus.ELIGIBLE + + with self.assertRaises(ValidationError): + InvestigationData.from_database_record(investigation_data.serialize_to_database_record()) + + def test_invalid_license_type(self): + from cc_common.data_model.schema.investigation import InvestigationData + + investigation_data = self.test_data_generator.generate_default_investigation() + + # setting to an invalid license type name + investigation_data.licenseType = 'foobar' + + with self.assertRaises(ValidationError): + InvestigationData.from_database_record(investigation_data.serialize_to_database_record()) + + +class TestInvestigationDataClass(TstLambdas): + def setUp(self): + from common_test.test_data_generator import TestDataGenerator + + self.test_data_generator = TestDataGenerator + + def test_investigation_data_class_getters_return_expected_values(self): + from cc_common.data_model.schema.investigation import InvestigationData + + investigation_data = self.test_data_generator.generate_default_investigation() + + investigation = InvestigationData.from_database_record(investigation_data.serialize_to_database_record()) + + # Use to_dict() method to get expected values + expected_investigation = investigation.to_dict() + + # Create actual object with all fields from database record + actual_investigation = { + 'providerId': investigation_data.providerId, + 'jurisdiction': investigation_data.jurisdiction, + 'investigationAgainst': investigation_data.investigationAgainst, + 'submittingUser': investigation_data.submittingUser, + 'investigationId': investigation_data.investigationId, + 'compact': investigation_data.compact, + 'creationDate': investigation_data.creationDate, + 'licenseType': investigation_data.licenseType, + 'type': investigation_data.type, + } + + # Pop dynamic fields from expected object + expected_investigation.pop('dateOfUpdate') + + self.assertEqual(expected_investigation, actual_investigation) + + def test_investigation_data_class_outputs_expected_database_object(self): + # check final snapshot of expected data + investigation = self.test_data_generator.generate_default_investigation() + investigation_data = investigation.serialize_to_database_record() + pk = 'socw#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570' + sk = 'socw#PROVIDER#privilege/ne/cos#INVESTIGATION#98765432-9876-9876-9876-987654321098' + # Pop dynamic field + investigation_data.pop('dateOfUpdate') + + self.assertEqual( + { + 'investigationAgainst': 'privilege', + 'investigationId': '98765432-9876-9876-9876-987654321098', + 'compact': 'socw', + 'creationDate': '2024-11-08T23:59:59+00:00', + 'jurisdiction': 'ne', + 'licenseType': 'cosmetologist', + 'pk': pk, + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'sk': sk, + 'submittingUser': '12a6377e-c3a5-40e5-bca5-317ec854c556', + 'type': 'investigation', + }, + investigation_data, + ) + + # Properties should be consistent with db record + self.assertEqual(pk, investigation.pk) + self.assertEqual(sk, investigation.sk) + + +class TestInvestigationPatchRequestSchema(TstLambdas): + def test_validate_patch(self): + """Test validation of a PATCH request (empty body is valid)""" + from cc_common.data_model.schema.investigation.api import InvestigationPatchRequestSchema + + # PATCH schema has no required fields + result = InvestigationPatchRequestSchema().load({}) + self.assertIsInstance(result, dict) + + def test_validate_patch_with_encumbrance(self): + """Test validation of a PATCH request with encumbrance""" + from cc_common.data_model.schema.investigation.api import InvestigationPatchRequestSchema + + investigation_data = { + 'encumbrance': { + 'encumbranceEffectiveDate': '2024-03-15', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['consumer harm'], + } + } + result = InvestigationPatchRequestSchema().load(investigation_data) + self.assertIsInstance(result, dict) + + def test_validate_patch_with_unknown_fields(self): + """Test validation passes even with unknown fields (ForgivingSchema)""" + from cc_common.data_model.schema.investigation.api import InvestigationPatchRequestSchema + + # ForgivingSchema allows unknown fields + investigation_data = {'unsupportedField': 'bad'} + + # This should not raise an error + result = InvestigationPatchRequestSchema().load(investigation_data) + self.assertIsInstance(result, dict) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_jurisdiction.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_jurisdiction.py new file mode 100644 index 0000000000..0b7e6e2d27 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_jurisdiction.py @@ -0,0 +1,54 @@ +import json +from decimal import Decimal + +from marshmallow import ValidationError + +from tests import TstLambdas + + +class TestJurisdictionRecordSchema(TstLambdas): + def test_serde(self): + """Test round-trip deserialization/serialization""" + from cc_common.data_model.schema.jurisdiction.record import JurisdictionRecordSchema + + with open('tests/resources/dynamo/jurisdiction.json') as f: + expected_jurisdiction_config = json.load(f, parse_float=Decimal) + + schema = JurisdictionRecordSchema() + loaded_schema = schema.load(expected_jurisdiction_config.copy()) + + jurisdiction_config_data = schema.dump(loaded_schema) + + # remove dynamic update fields + expected_jurisdiction_config.pop('dateOfUpdate') + jurisdiction_config_data.pop('dateOfUpdate') + + self.assertEqual(expected_jurisdiction_config, jurisdiction_config_data) + + def test_jurisdiction_config_removes_unknown_fields_gracefully(self): + """ + We need these config files to use a forgiving schema, so that adding new fields + in the future does not cause an outage during deployment. + """ + from cc_common.data_model.schema.jurisdiction.record import JurisdictionRecordSchema + + with open('tests/resources/dynamo/jurisdiction.json') as f: + expected_jurisdiction_config = json.load(f, parse_float=Decimal) + expected_jurisdiction_config['someNewValue'] = 'Will this break something?' + + schema = JurisdictionRecordSchema() + loaded_schema = schema.load(expected_jurisdiction_config.copy()) + + jurisdiction_config_data = schema.dump(loaded_schema) + + self.assertNotIn('someNewValue', jurisdiction_config_data) + + def test_jurisdiction_config_raises_validation_error_if_missing_required_field(self): + from cc_common.data_model.schema.jurisdiction.record import JurisdictionRecordSchema + + with open('tests/resources/dynamo/jurisdiction.json') as f: + expected_jurisdiction_config = json.load(f, parse_float=Decimal) + del expected_jurisdiction_config['postalAbbreviation'] + + with self.assertRaises(ValidationError): + JurisdictionRecordSchema().load(expected_jurisdiction_config.copy()) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py new file mode 100644 index 0000000000..d021fc40cd --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py @@ -0,0 +1,489 @@ +import json +from datetime import UTC, datetime +from unittest.mock import patch +from uuid import UUID, uuid4 + +from marshmallow import ValidationError + +from tests import TstLambdas + + +class TestLicensePostSchema(TstLambdas): + def test_validate_post(self): + from cc_common.data_model.schema.license.api import LicensePostRequestSchema + + with open('tests/resources/api/license-post.json') as f: + LicensePostRequestSchema().load({'compact': 'socw', 'jurisdiction': 'oh', **json.load(f)}) + + def test_invalid_post(self): + from cc_common.data_model.schema.license.api import LicensePostRequestSchema + + with open('tests/resources/api/license-post.json') as f: + license_data = json.load(f) + license_data.pop('ssn') + + with self.assertRaises(ValidationError): + LicensePostRequestSchema().load({'compact': 'socw', 'jurisdiction': 'oh', **license_data}) + + def test_compact_eligible_with_inactive_license_not_allowed(self): + from cc_common.data_model.schema.license.api import LicensePostRequestSchema + + with open('tests/resources/api/license-post.json') as f: + license_data = json.load(f) + license_data['licenseStatus'] = 'inactive' + license_data['compactEligibility'] = 'eligible' + + with self.assertRaises(ValidationError): + LicensePostRequestSchema().load({'compact': 'socw', 'jurisdiction': 'oh', **license_data}) + + +class TestLicenseRecordSchema(TstLambdas): + def test_serde(self): + """Test round-trip serialization/deserialization of license records""" + from cc_common.data_model.schema import LicenseRecordSchema + + with open('tests/resources/dynamo/license.json') as f: + expected_license = json.load(f) + + schema = LicenseRecordSchema() + + loaded_license = schema.load(expected_license.copy()) + # assert calculated status fields are added + self.assertIn('licenseStatus', loaded_license) + self.assertIn('compactEligibility', loaded_license) + + license_data = schema.dump(loaded_license) + # assert that the calculated status fields were stripped from the data on dump + self.assertNotIn('licenseStatus', license_data) + self.assertNotIn('compactEligibility', license_data) + + # Drop dynamic fields that won't match + del expected_license['dateOfUpdate'] + del license_data['dateOfUpdate'] + + self.assertEqual(expected_license, license_data) + + def test_invalid(self): + from cc_common.data_model.schema import LicenseRecordSchema + + with open('tests/resources/dynamo/license.json') as f: + license_data = json.load(f) + license_data.pop('ssnLastFour') + + with self.assertRaises(ValidationError): + LicenseRecordSchema().load(license_data) + + def test_serialize_from_ingest(self): + """Licenses are the only record that directly originate from external clients. We'll test their serialization + as it comes from clients. + """ + from cc_common.data_model.schema import LicenseRecordSchema + from cc_common.data_model.schema.license.ingest import LicenseIngestSchema + + with open('tests/resources/dynamo/license.json') as f: + expected_license_record = json.load(f) + + with open('tests/resources/api/license-post.json') as f: + license_record = json.load(f) + + # the preprocessor lambda removes the full SSN and replaces it with the last 4 digits as well as the + # associated provider id within the system. + license_record['ssnLastFour'] = license_record['ssn'][-4:] + license_record['providerId'] = expected_license_record['providerId'] + del license_record['ssn'] + license_data = LicenseIngestSchema().load({'compact': 'socw', 'jurisdiction': 'oh', **license_record}) + + # Provider will normally be looked up / generated internally, not come from the client + provider_id = expected_license_record['providerId'] + + license_record = LicenseRecordSchema().dump( + { + 'compact': 'socw', + 'jurisdiction': 'co', + 'providerId': UUID(provider_id), + 'ssnLastFour': '1234', + **license_data, + }, + ) + + # These are dynamic and so won't match + del expected_license_record['dateOfUpdate'] + del license_record['dateOfUpdate'] + + self.assertEqual(expected_license_record, license_record) + + def test_sets_status_to_inactive_if_license_expired(self): + from cc_common.data_model.schema import LicenseRecordSchema + + with open('tests/resources/dynamo/license.json') as f: + raw_license_data = json.load(f) + raw_license_data['dateOfExpiration'] = '2020-01-01' + + schema = LicenseRecordSchema() + license_data = schema.load(raw_license_data) + + self.assertEqual('inactive', license_data['licenseStatus']) + self.assertEqual('ineligible', license_data['compactEligibility']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-09T03:59:59+00:00')) + def test_status_is_set_to_active_right_before_expiration_for_utc_minus_four_timezone(self): + from cc_common.data_model.schema.license.record import LicenseRecordSchema + + with open('tests/resources/dynamo/license.json') as f: + privilege_data = json.load(f) + privilege_data['dateOfExpiration'] = '2024-11-08' + + result = LicenseRecordSchema().load(privilege_data) + + self.assertEqual(result['licenseStatus'], 'active') + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-09T04:00:00+00:00')) + def test_status_is_set_to_inactive_right_at_expiration_for_utc_minus_four_timezone(self): + from cc_common.data_model.schema.license.record import LicenseRecordSchema + + with open('tests/resources/dynamo/license.json') as f: + privilege_data = json.load(f) + privilege_data['dateOfExpiration'] = '2024-11-08' + + result = LicenseRecordSchema().load(privilege_data) + + self.assertEqual(result['licenseStatus'], 'inactive') + + def test_license_record_schema_sets_status_to_inactive_if_jurisdiction_status_inactive(self): + from cc_common.data_model.schema import LicenseRecordSchema + + with open('tests/resources/dynamo/license.json') as f: + raw_license_data = json.load(f) + raw_license_data['dateOfExpiration'] = '2100-01-01' + raw_license_data['jurisdictionUploadedLicenseStatus'] = 'inactive' + + schema = LicenseRecordSchema() + license_data = schema.load(raw_license_data) + + self.assertEqual('inactive', license_data['licenseStatus']) + self.assertEqual('ineligible', license_data['compactEligibility']) + + +class TestLicenseUpdateRecordSchema(TstLambdas): + @patch('cc_common.config.datetime', autospec=True) + def test_load_dump(self, mock_datetime): + from cc_common.data_model.schema.license.record import LicenseUpdateRecordSchema + + # We want to inspect how time-based fields are serialized in this schema, so we'll have to mock datetime.now + # for predictable results + mock_datetime.now.return_value = datetime(2020, 4, 7, 12, 59, 59, tzinfo=UTC) + + schema = LicenseUpdateRecordSchema() + + with open('tests/resources/dynamo/license-update.json') as f: + record = json.load(f) + + loaded_record = schema.load(record) + + dumped_record = schema.dump(loaded_record) + + # Round-trip SERDE with a fixed timestamp demonstrates that our sk generation is deterministic for the same + # input values, which is an important property for this schema. + self.assertEqual(record, dumped_record) + + def test_hash_is_deterministic(self): + """ + Verify that our change hash is consistent for the same previous/updatedValues + """ + from cc_common.data_model.schema.license.record import LicenseUpdateRecordSchema + + schema = LicenseUpdateRecordSchema() + + with open('tests/resources/dynamo/license-update.json') as f: + record = json.load(f) + + loaded_record = schema.load(record) + change_hash = schema.hash_changes(schema.dump(loaded_record)) + + alternate_record = schema.dump( + { + 'type': 'licenseUpdate', + 'providerId': uuid4(), + 'compact': 'socw', + 'jurisdiction': 'ky', + 'licenseType': 'cosmetologist', + 'updateType': loaded_record['updateType'], + 'createDate': loaded_record['createDate'], + # These two fields should determine the change hash: + 'previous': loaded_record['previous'].copy(), + 'updatedValues': loaded_record['updatedValues'].copy(), + } + ) + self.assertEqual(change_hash, schema.hash_changes(alternate_record)) + + def test_hash_is_unique(self): + """ + Verify that our change hash is unique for the different previous/updatedValues + """ + from cc_common.data_model.schema.license.record import LicenseUpdateRecordSchema + + schema = LicenseUpdateRecordSchema() + + with open('tests/resources/dynamo/license-update.json') as f: + record = json.load(f) + + loaded_record = schema.load(record) + change_hash = schema.hash_changes(schema.dump(loaded_record)) + + alternate_record = { + 'type': 'licenseUpdate', + 'providerId': uuid4(), + 'compact': 'socw', + 'jurisdiction': 'ky', + 'licenseType': 'cosmetologist', + 'updateType': loaded_record['updateType'], + 'createDate': loaded_record['createDate'], + # These two fields should determine the change hash: + 'previous': loaded_record['previous'].copy(), + 'updatedValues': loaded_record['updatedValues'].copy(), + } + # Change one value in the previous values + alternate_record['previous']['dateOfUpdate'] = datetime(2020, 6, 7, 12, 59, 59, tzinfo=UTC) + + # The hashes should now be different + self.assertNotEqual(change_hash, schema.hash_changes(schema.dump(alternate_record))) + + def test_invalid_if_missing_investigation_details_when_update_type_is_investigation(self): + from cc_common.data_model.schema.license.record import LicenseUpdateRecordSchema + + with open('tests/resources/dynamo/license-update.json') as f: + privilege_data = json.load(f) + # Privilege investigation updates require an 'investigationDetails' fields + privilege_data['updateType'] = 'investigation' + + with self.assertRaises(ValidationError) as context: + LicenseUpdateRecordSchema().load(privilege_data) + + self.assertEqual( + {'investigationDetails': ['This field is required when update was investigation type']}, + context.exception.messages, + ) + + +class TestLicenseIngestSchema(TstLambdas): + def test_calculated_status_to_jurisdiction_uploaded_status(self): + from cc_common.data_model.schema.license.ingest import LicenseIngestSchema + + with open('tests/resources/api/license-post.json') as f: + # This license record contains a `licenseStatus` and `compactEligibility` field + license_record = json.load(f) + + # the preprocessor lambda removes the full SSN and replaces it with the last 4 digits as well as the + # associated provider id within the system. + license_record['ssnLastFour'] = license_record['ssn'][-4:] + license_record['providerId'] = uuid4() + del license_record['ssn'] + + result = LicenseIngestSchema().load({'compact': 'socw', 'jurisdiction': 'oh', **license_record}) + # Verify that the `licenseStatus` and `compactEligibility` fields are renamed to `jurisdictionUploaded*` + self.assertEqual('active', result['jurisdictionUploadedLicenseStatus']) + self.assertEqual('eligible', result['jurisdictionUploadedCompactEligibility']) + + def test_compact_eligible_with_inactive_license_not_allowed(self): + from cc_common.data_model.schema.license.ingest import LicenseIngestSchema + + with open('tests/resources/api/license-post.json') as f: + license_record = json.load(f) + + # the preprocessor lambda removes the full SSN and replaces it with the last 4 digits as well as the + # associated provider id within the system. + license_record['ssnLastFour'] = license_record['ssn'][-4:] + license_record['providerId'] = uuid4() + del license_record['ssn'] + + license_record['licenseStatus'] = 'inactive' + license_record['compactEligibility'] = 'eligible' + + with self.assertRaises(ValidationError): + LicenseIngestSchema().load({'compact': 'socw', 'jurisdiction': 'oh', **license_record}) + + +class TestLicenseOpenSearchDocumentSchema(TstLambdas): + """Tests for LicenseOpenSearchDocumentSchema which extends LicenseGeneralResponseSchema with dateOfBirth.""" + + def _make_license_data(self, *, license_status='active', date_of_expiration='2100-01-01'): + """Create valid license data including dateOfBirth for testing.""" + return { + 'providerId': 'a4182428-d061-701c-82e5-a3d1d547d797', + 'type': 'license', + 'dateOfUpdate': '2024-01-01T00:00:00+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseStatus': license_status, + 'jurisdictionUploadedLicenseStatus': 'active', + 'compactEligibility': 'eligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfIssuance': '2024-01-01', + 'dateOfExpiration': date_of_expiration, + 'homeAddressStreet1': '123 Main St', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'OH', + 'homeAddressPostalCode': '43215', + 'licenseNumber': 'LIC12345', + 'dateOfBirth': '1985-06-06', + 'mostRecentLicenseForType': True, + } + + def test_includes_date_of_birth(self): + """LicenseOpenSearchDocumentSchema should include dateOfBirth in the loaded output.""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data() + result = LicenseOpenSearchDocumentSchema().load(license_data) + + self.assertEqual('1985-06-06', result['dateOfBirth']) + + def test_retains_all_general_response_fields(self): + """LicenseOpenSearchDocumentSchema should retain all fields from LicenseGeneralResponseSchema.""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data() + result = LicenseOpenSearchDocumentSchema().load(license_data) + + for field in [ + 'providerId', + 'type', + 'dateOfUpdate', + 'compact', + 'jurisdiction', + 'licenseType', + 'licenseStatus', + 'licenseNumber', + 'givenName', + 'familyName', + 'dateOfIssuance', + 'dateOfExpiration', + 'homeAddressStreet1', + 'homeAddressCity', + 'homeAddressState', + 'homeAddressPostalCode', + ]: + self.assertIn(field, result, f'Expected field {field} to be in loaded result') + + def test_expired_license_status_corrected_to_inactive(self): + """LicenseOpenSearchDocumentSchema should inherit expiration status logic from LicenseExpirationStatusMixin.""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data(license_status='active', date_of_expiration='2020-01-01') + result = LicenseOpenSearchDocumentSchema().load(license_data) + + self.assertEqual('inactive', result['licenseStatus']) + + def test_strips_fields_not_in_schema(self): + """LicenseOpenSearchDocumentSchema should strip fields not defined in the schema (ForgivingSchema behavior).""" + from cc_common.data_model.schema.license.api import LicenseOpenSearchDocumentSchema + + license_data = self._make_license_data() + license_data['ssnLastFour'] = '1234' + + result = LicenseOpenSearchDocumentSchema().load(license_data) + + self.assertNotIn('ssnLastFour', result) + + +class TestLicenseGeneralResponseSchemaExpirationCheck(TstLambdas): + """ + Tests for the LicenseExpirationStatusMixin applied to LicenseGeneralResponseSchema. + + This mixin corrects stale 'licenseStatus' values when loading license data from sources + like OpenSearch where the status may not have been updated after expiration. + """ + + def _make_license_data(self, *, license_status='active', date_of_expiration='2100-01-01'): + """Create minimal valid license data for testing.""" + return { + 'providerId': 'a4182428-d061-701c-82e5-a3d1d547d797', + 'type': 'license', + 'dateOfUpdate': '2024-01-01T00:00:00+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseStatus': license_status, + 'jurisdictionUploadedLicenseStatus': 'active', + 'compactEligibility': 'eligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfIssuance': '2024-01-01', + 'dateOfExpiration': date_of_expiration, + 'homeAddressStreet1': '123 Main St', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'OH', + 'homeAddressPostalCode': '43215', + 'licenseNumber': 'LIC12345', + } + + def test_expired_license_status_corrected_to_inactive(self): + """When licenseStatus is 'active' but dateOfExpiration is in the past, licenseStatus becomes 'inactive'.""" + from cc_common.data_model.schema.license.api import LicenseGeneralResponseSchema + + license_data = self._make_license_data( + license_status='active', + date_of_expiration='2020-01-01', # Expired + ) + + result = LicenseGeneralResponseSchema().load(license_data) + + self.assertEqual('inactive', result['licenseStatus']) + + def test_unexpired_license_status_remains_active(self): + """When licenseStatus is 'active' and dateOfExpiration is in the future, licenseStatus stays 'active'.""" + from cc_common.data_model.schema.license.api import LicenseGeneralResponseSchema + + license_data = self._make_license_data( + license_status='active', + date_of_expiration='2100-01-01', # Far in the future + ) + + result = LicenseGeneralResponseSchema().load(license_data) + + self.assertEqual('active', result['licenseStatus']) + + def test_already_inactive_license_status_remains_inactive(self): + """When licenseStatus is already 'inactive', it stays 'inactive' regardless of expiration.""" + from cc_common.data_model.schema.license.api import LicenseGeneralResponseSchema + + license_data = self._make_license_data( + license_status='inactive', + date_of_expiration='2100-01-01', # Not expired, but status is inactive + ) + + result = LicenseGeneralResponseSchema().load(license_data) + + self.assertEqual('inactive', result['licenseStatus']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-09T03:59:59+00:00')) + def test_license_status_active_right_before_expiration_utc_minus_four(self): + """License status remains 'active' right before midnight UTC-4 on expiration day.""" + from cc_common.data_model.schema.license.api import LicenseGeneralResponseSchema + + license_data = self._make_license_data( + license_status='active', + date_of_expiration='2024-11-08', # Expires at midnight UTC-4 on Nov 9 + ) + + result = LicenseGeneralResponseSchema().load(license_data) + + self.assertEqual('active', result['licenseStatus']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-09T04:00:00+00:00')) + def test_license_status_corrected_to_inactive_at_expiration_utc_minus_four(self): + """License status corrected to 'inactive' at midnight UTC-4 on the day after expiration.""" + from cc_common.data_model.schema.license.api import LicenseGeneralResponseSchema + + license_data = self._make_license_data( + license_status='active', + date_of_expiration='2024-11-08', # Expired at midnight UTC-4 on Nov 9 + ) + + result = LicenseGeneralResponseSchema().load(license_data) + + self.assertEqual('inactive', result['licenseStatus']) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py new file mode 100644 index 0000000000..a5dae5f90d --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py @@ -0,0 +1,225 @@ +import json +from datetime import UTC, datetime +from unittest.mock import patch + +from marshmallow import ValidationError + +from tests import TstLambdas + + +class TestProviderOpenSearchDocumentSchema(TstLambdas): + """Tests for ProviderOpenSearchDocumentSchema which extends ProviderGeneralResponseSchema + with dateOfBirth on nested license objects.""" + + def _make_provider_data_with_license(self): + """Create valid provider data with a nested license that includes dateOfBirth.""" + return { + 'providerId': 'a4182428-d061-701c-82e5-a3d1d547d797', + 'type': 'provider', + 'dateOfUpdate': '2024-07-08T23:59:59+00:00', + 'compact': 'socw', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfExpiration': '2100-01-01', + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'birthMonthDay': '06-06', + 'licenses': [ + { + 'providerId': 'a4182428-d061-701c-82e5-a3d1d547d797', + 'type': 'license', + 'dateOfUpdate': '2024-06-06T12:59:59+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseStatus': 'active', + 'jurisdictionUploadedLicenseStatus': 'active', + 'compactEligibility': 'eligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'licenseNumber': 'LIC12345', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfIssuance': '2024-01-01', + 'dateOfExpiration': '2100-01-01', + 'homeAddressStreet1': '123 Main St', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'OH', + 'homeAddressPostalCode': '43215', + 'dateOfBirth': '1985-06-06', + 'mostRecentLicenseForType': True, + } + ], + 'privileges': [], + } + + def test_license_includes_date_of_birth(self): + """ProviderOpenSearchDocumentSchema should include dateOfBirth in nested license objects.""" + from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema + + data = self._make_provider_data_with_license() + result = ProviderOpenSearchDocumentSchema().load(data) + + self.assertEqual(1, len(result['licenses'])) + self.assertEqual('1985-06-06', result['licenses'][0]['dateOfBirth']) + + def test_top_level_fields_match_general_response(self): + """ProviderOpenSearchDocumentSchema should retain all top-level fields from ProviderGeneralResponseSchema.""" + from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema + + data = self._make_provider_data_with_license() + result = ProviderOpenSearchDocumentSchema().load(data) + + for field in [ + 'providerId', + 'type', + 'dateOfUpdate', + 'compact', + 'licenseJurisdiction', + 'licenseStatus', + 'compactEligibility', + 'givenName', + 'familyName', + 'dateOfExpiration', + 'birthMonthDay', + ]: + self.assertIn(field, result, f'Expected field {field} to be in loaded result') + + def test_does_not_include_private_fields_at_top_level(self): + """ProviderOpenSearchDocumentSchema should NOT include top-level private fields.""" + from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema + + data = self._make_provider_data_with_license() + data['dateOfBirth'] = '1985-06-06' + data['ssnLastFour'] = '1234' + result = ProviderOpenSearchDocumentSchema().load(data) + + self.assertNotIn('dateOfBirth', result) + self.assertNotIn('ssnLastFour', result) + + def test_general_response_schema_does_not_include_date_of_birth_in_licenses(self): + """ProviderGeneralResponseSchema should NOT include dateOfBirth in license objects (baseline comparison).""" + from cc_common.data_model.schema.provider.api import ProviderGeneralResponseSchema + + data = self._make_provider_data_with_license() + result = ProviderGeneralResponseSchema().load(data) + + self.assertNotIn('dateOfBirth', result['licenses'][0]) + + +class TestQueryProvidersRequestSchema(TstLambdas): + """QueryProvidersRequestSchema.QuerySchema licenseNumber length matches API Gateway model (max 100).""" + + def test_query_license_number_accepts_100_chars(self): + from cc_common.data_model.schema.provider.api import QueryProvidersRequestSchema + + ln = 'x' * 100 + body = {'query': {'licenseNumber': ln, 'jurisdiction': 'oh'}} + loaded = QueryProvidersRequestSchema().load(body) + self.assertEqual(ln, loaded['query']['licenseNumber']) + + def test_query_license_number_rejects_over_100_chars(self): + from cc_common.data_model.schema.provider.api import QueryProvidersRequestSchema + + body = {'query': {'licenseNumber': 'x' * 101, 'jurisdiction': 'oh'}} + with self.assertRaises(ValidationError) as ctx: + QueryProvidersRequestSchema().load(body) + self.assertIn('licenseNumber', ctx.exception.messages['query']) + + +class TestProviderRecordSchema(TstLambdas): + def test_serde(self): + """Test round-trip deserialization/serialization""" + from cc_common.data_model.schema import ProviderRecordSchema + + with open('tests/resources/dynamo/provider.json') as f: + expected_provider_record = json.load(f) + + schema = ProviderRecordSchema() + loaded_record = schema.load(expected_provider_record.copy()) + # assert licenseStatus field is added + self.assertIn('licenseStatus', loaded_record) + + license_record = schema.dump(schema.load(expected_provider_record.copy())) + # assert that the licenseStatus field was stripped from the data on dump + self.assertNotIn('licenseStatus', license_record) + + # These are dynamic and so won't match + del expected_provider_record['dateOfUpdate'] + del license_record['dateOfUpdate'] + del expected_provider_record['providerDateOfUpdate'] + del license_record['providerDateOfUpdate'] + + self.assertEqual(expected_provider_record, license_record) + + def test_invalid(self): + from cc_common.data_model.schema import ProviderRecordSchema + + with open('tests/resources/dynamo/provider.json') as f: + license_data = json.load(f) + license_data.pop('providerId') + + with self.assertRaises(ValidationError): + ProviderRecordSchema().load(license_data) + + def test_provider_record_schema_sets_status_to_inactive_if_license_expired(self): + """Test round-trip serialization/deserialization of license records""" + from cc_common.data_model.schema import ProviderRecordSchema + + with open('tests/resources/dynamo/provider.json') as f: + raw_provider_data = json.load(f) + raw_provider_data['dateOfExpiration'] = '2020-01-01' + + schema = ProviderRecordSchema() + provider_data = schema.load(raw_provider_data) + + self.assertEqual('inactive', provider_data['licenseStatus']) + + def test_provider_record_schema_sets_status_to_inactive_if_license_status_inactive(self): + """Test round-trip serialization/deserialization of license records""" + from cc_common.data_model.schema import ProviderRecordSchema + + with open('tests/resources/dynamo/provider.json') as f: + raw_provider_data = json.load(f) + raw_provider_data['dateOfExpiration'] = '2100-01-01' + raw_provider_data['jurisdictionUploadedLicenseStatus'] = 'inactive' + + schema = ProviderRecordSchema() + provider_data = schema.load(raw_provider_data) + + self.assertEqual('inactive', provider_data['licenseStatus']) + self.assertEqual('ineligible', provider_data['compactEligibility']) + + def test_prov_date_of_update_matches_new_date_of_update(self): + """ + When a provider record is serialized date of update fields should be processed like: + 1) dateOfUpdate is overwritten with the current time + 2) providerDateOfUpdate is overwritten with the new dateOfUpdate + 3) The resulting serialized record has both fields updated to the current time + + If 2 happens before 1, we could have an incorrect value in providerDateOfUpdate, which would + break time-based querying of providers + """ + from cc_common.data_model.schema import ProviderRecordSchema + + with open('tests/resources/dynamo/provider.json') as f: + expected_provider_record = json.load(f) + + old_date_of_update = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + new_date_of_update = datetime(2025, 2, 1, 0, 0, 0, tzinfo=UTC) + expected_provider_record['dateOfUpdate'] = old_date_of_update.isoformat() + + schema = ProviderRecordSchema() + + with patch('cc_common.config._Config.current_standard_datetime', new_date_of_update): + loaded_record = schema.load(expected_provider_record.copy()) + # Verify we have the expected _old_ dateOfUpdate on load + self.assertEqual(loaded_record['dateOfUpdate'], old_date_of_update) + + dumped_record = schema.dump(schema.load(expected_provider_record.copy())) + + self.assertEqual(new_date_of_update.isoformat(), dumped_record['dateOfUpdate']) + # If 1 and 2 happened out of order, `providerDateOfUpdate` will be incorrect + self.assertEqual(new_date_of_update.isoformat(), dumped_record['providerDateOfUpdate']) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py new file mode 100644 index 0000000000..766d677c22 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py @@ -0,0 +1,68 @@ +import json +from unittest.mock import MagicMock +from uuid import UUID + +from cc_common.config import logger + +from tests import TstLambdas + +TEST_COMPACT = 'socw' +TEST_FORMER_JURISDICTION = 'tn' +TEST_NEW_JURISDICTION = 'oh' +TEST_PROVIDER_ID = UUID('12345678-1234-5678-1234-567812345678') + + +class TestEmailServiceClient(TstLambdas): + def _generate_test_model(self, mock_lambda_client): + from cc_common.email_service_client import EmailServiceClient + + mock_lambda_client.invoke.return_value = { + 'StatusCode': 200, + 'LogResult': 'string', + 'Payload': '{"message": "Email message sent"}', + 'ExecutedVersion': '1', + } + + return EmailServiceClient( + lambda_client=mock_lambda_client, email_notification_service_lambda_name='test-lambda-name', logger=logger + ) + + def test_send_provider_home_state_change_email_should_invoke_lambda_client_with_expected_parameters(self): + from cc_common.email_service_client import HomeJurisdictionChangeNotificationTemplateVariables + + mock_lambda_client = MagicMock() + test_model = self._generate_test_model(mock_lambda_client) + + test_model.send_provider_home_state_change_email( + compact=TEST_COMPACT, + jurisdiction=TEST_FORMER_JURISDICTION, + template_variables=HomeJurisdictionChangeNotificationTemplateVariables( + provider_first_name='Jane', + provider_last_name='Smith', + former_jurisdiction=TEST_FORMER_JURISDICTION, + current_jurisdiction=TEST_NEW_JURISDICTION, + license_type='Cosmetologist', + provider_id=TEST_PROVIDER_ID, + ), + ) + + mock_lambda_client.invoke.assert_called_once_with( + FunctionName='test-lambda-name', + InvocationType='RequestResponse', + Payload=json.dumps( + { + 'compact': TEST_COMPACT, + 'jurisdiction': TEST_FORMER_JURISDICTION, + 'template': 'homeJurisdictionChangeNotification', + 'recipientType': 'JURISDICTION_OPERATIONS_TEAM', + 'templateVariables': { + 'providerFirstName': 'Jane', + 'providerLastName': 'Smith', + 'providerId': str(TEST_PROVIDER_ID), + 'previousJurisdiction': TEST_FORMER_JURISDICTION, + 'newJurisdiction': TEST_NEW_JURISDICTION, + 'licenseType': 'Cosmetologist', + }, + } + ), + ) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_event_batch_writer.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_event_batch_writer.py new file mode 100644 index 0000000000..b1bf0e3e40 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_event_batch_writer.py @@ -0,0 +1,198 @@ +# ruff: noqa: N803 invalid-name +import json +from unittest.mock import MagicMock +from uuid import uuid4 + +from botocore.exceptions import ParamValidationError + +from tests import TstLambdas + + +class TestEventBatchWriter(TstLambdas): + def test_write_big_batch(self): + from cc_common.event_batch_writer import EventBatchWriter + + put_count = [] + + def mock_put_items(Entries: list[dict]): # noqa: N801 invalid-name + put_count.extend(Entries) + return {} + + mock_client = MagicMock() + mock_client.put_events.side_effect = mock_put_items + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + event = json.load(f) + + with EventBatchWriter(client=mock_client) as writer: + # Send a bunch of messages, make sure each is sent + for _ in range(123): + writer.put_event(Entry=event) + + # Make sure each message was eventually sent + self.assertEqual(123, len(put_count)) + # Make sure these were sent in the expected number of batches: + # - 12 batches of 10 + # - 1 batch of 3 + # Total 13 batches + self.assertEqual(13, mock_client.put_events.call_count) + + def test_write_small_batch(self): + from cc_common.event_batch_writer import EventBatchWriter + + put_count = [] + + def mock_put_items(Entries: list[dict]): # noqa: N801 invalid-name + put_count.extend(Entries) + return {} + + mock_client = MagicMock() + mock_client.put_events.side_effect = mock_put_items + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + event = json.load(f) + + with EventBatchWriter(client=mock_client) as writer: + # Send a bunch of messages, make sure each is sent + for _ in range(3): + writer.put_event(Entry=event) + + # Make sure each message was eventually sent + self.assertEqual(3, len(put_count)) + # Make sure these were sent in one batch + self.assertEqual(1, mock_client.put_events.call_count) + + def test_write_batch(self): + """Making sure that, in the event that we exit with exactly 0 messages remaining, we don't try + to put an empty batch + """ + from cc_common.event_batch_writer import EventBatchWriter + + put_count = [] + + def mock_put_items(Entries: list[dict]): # noqa: N801 invalid-name + if len(Entries) < 1: + raise ParamValidationError(report='Invalid length for parameter Entries, value: 0, valid min length: 1') + put_count.extend(Entries) + return {} + + mock_client = MagicMock() + mock_client.put_events.side_effect = mock_put_items + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + event = json.load(f) + + with EventBatchWriter(client=mock_client) as writer: + # Send a bunch of messages, make sure each is sent + for _ in range(10): + writer.put_event(Entry=event) + + # Make sure each message was eventually sent + self.assertEqual(10, len(put_count)) + # Make sure these were sent in one batch + self.assertEqual(1, mock_client.put_events.call_count) + + def test_exception_recovery(self): + from cc_common.event_batch_writer import EventBatchWriter + + put_count = [] + + def mock_put_items(Entries: list[dict]): # noqa: N801 invalid-name + put_count.extend(Entries) + return {} + + mock_client = MagicMock() + mock_client.put_events.side_effect = mock_put_items + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + event = json.load(f) + + def interrupted_with_exception(): + with EventBatchWriter(client=mock_client) as writer: + # Send a bunch of messages, make sure each is sent + for _ in range(3): + writer.put_event(Entry=event) + raise RuntimeError('Oh noes!') + + # Make sure the exception is not suppressed + with self.assertRaises(RuntimeError): + interrupted_with_exception() + + # Make sure each message was eventually sent + self.assertEqual(3, len(put_count)) + # Make sure these were sent in one batch + self.assertEqual(1, mock_client.put_events.call_count) + + def test_bad_use(self): + """EventBatchWriter requires that it be used as a context manager (in a `with EventBatchWriter(...):` block) + Trying to use it otherwise should raise an exception. + """ + from cc_common.event_batch_writer import EventBatchWriter + + # If a developer uses this wrong + writer = EventBatchWriter(MagicMock()) + with self.assertRaises(RuntimeError): + writer.put_event(Entry={}) + + def test_entry_failures(self): + from cc_common.event_batch_writer import EventBatchWriter + + put_count = [] + + def mock_put_items(Entries: list[dict]): # noqa: N801 invalid-name + """Fail every last entry""" + put_count.extend(Entries) + response = {'FailedEntryCount': 1, 'Entries': [{'EventId': uuid4().hex} for entry in Entries[:-1]]} + response['Entries'].append( + { + 'EventId': uuid4().hex, + 'ErrorCode': 'InternalFailure', + 'ErrorMessage': 'Oh no, AWS is having problems!', + }, + ) + return response + + mock_client = MagicMock() + mock_client.put_events.side_effect = mock_put_items + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + event = json.load(f) + + with EventBatchWriter(client=mock_client) as writer: + # Send a bunch of messages, make sure each is sent + for _ in range(123): + writer.put_event(Entry=event) + + self.assertEqual(123, len(put_count)) + # 13 batches, one failure each + self.assertEqual(13, writer.failed_entry_count) + self.assertEqual(13, len(writer.failed_entries)) + + def test_write_custom_batch_size(self): + """Override the default batch size of 10""" + from cc_common.event_batch_writer import EventBatchWriter + + put_count = [] + + def mock_put_items(Entries: list[dict]): # noqa: N803 invalid-name + put_count.extend(Entries) + return {} + + mock_client = MagicMock() + mock_client.put_events.side_effect = mock_put_items + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + event = json.load(f) + + with EventBatchWriter(client=mock_client, batch_size=5) as writer: + # Send a bunch of messages, make sure each is sent + for _ in range(42): + writer.put_event(Entry=event) + + # Make sure each message was eventually sent + self.assertEqual(42, len(put_count)) + # Make sure these were sent in the expected number of batches: + # - 8 batches of 5 + # - 1 batch of 2 + # Total 9 batches + self.assertEqual(9, mock_client.put_events.call_count) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_feature_flag_client.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_feature_flag_client.py new file mode 100644 index 0000000000..875bd256d6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_feature_flag_client.py @@ -0,0 +1,236 @@ +from unittest.mock import MagicMock, patch + +from cc_common.feature_flag_enum import FeatureFlagEnum + +from tests import TstLambdas + + +class TestFeatureFlagClient(TstLambdas): + def test_is_feature_enabled_returns_true_when_flag_enabled(self): + """Test that is_feature_enabled returns True when the API returns enabled=true.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock successful API response with enabled=True + mock_response = MagicMock() + mock_response.json.return_value = {'enabled': True} + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response) as mock_post: + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG) + + # Verify the result + self.assertTrue(result) + + # Verify the API was called correctly + mock_post.assert_called_once_with( + 'https://api.example.com/v1/flags/test-flag/check', + json={}, + timeout=5, + headers={'Content-Type': 'application/json'}, + ) + + def test_is_feature_enabled_returns_false_when_flag_disabled(self): + """Test that is_feature_enabled returns False when the API returns enabled=false.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock successful API response with enabled=False + mock_response = MagicMock() + mock_response.json.return_value = {'enabled': False} + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG) + + # Verify the result + self.assertFalse(result) + + def test_is_feature_enabled_with_context(self): + """Test that is_feature_enabled correctly passes context to the API.""" + from cc_common.feature_flag_client import FeatureFlagContext, is_feature_enabled + + # Mock successful API response + mock_response = MagicMock() + mock_response.json.return_value = {'enabled': True} + + context = FeatureFlagContext(user_id='user123', custom_attributes={'licenseType': 'cos'}) + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response) as mock_post: + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, context=context) + + # Verify the result + self.assertTrue(result) + + # Verify the API was called with the context + mock_post.assert_called_once_with( + 'https://api.example.com/v1/flags/test-flag/check', + json={ + 'context': {'userId': 'user123', 'customAttributes': {'licenseType': 'cos'}}, + }, + timeout=5, + headers={'Content-Type': 'application/json'}, + ) + + def test_is_feature_enabled_fail_closed_on_timeout(self): + """Test that is_feature_enabled returns False (fail closed) on timeout.""" + from cc_common.feature_flag_client import is_feature_enabled + + with patch('cc_common.feature_flag_client.requests.post', side_effect=Exception('Timeout')): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False) + + # Verify it fails closed (returns False) + self.assertFalse(result) + + def test_is_feature_enabled_fail_open_on_timeout(self): + """Test that is_feature_enabled returns True (fail open) on timeout.""" + from cc_common.feature_flag_client import is_feature_enabled + + with patch('cc_common.feature_flag_client.requests.post', side_effect=Exception('Timeout')): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True) + + # Verify it fails open (returns True) + self.assertTrue(result) + + def test_is_feature_enabled_fail_closed_on_http_error(self): + """Test that is_feature_enabled returns False (fail closed) on HTTP error.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock HTTP error response + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = Exception('500 Server Error') + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False) + + # Verify it fails closed (returns False) + self.assertFalse(result) + + def test_is_feature_enabled_fail_open_on_http_error(self): + """Test that is_feature_enabled returns True (fail open) on HTTP error.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock HTTP error response + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = Exception('500 Server Error') + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True) + + # Verify it fails open (returns True) + self.assertTrue(result) + + def test_is_feature_enabled_fail_closed_on_invalid_response(self): + """Test that is_feature_enabled returns False (fail closed) when response missing 'enabled' field.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock response with missing 'enabled' field + mock_response = MagicMock() + mock_response.json.return_value = {'some_other_field': 'value'} + mock_response.raise_for_status = MagicMock() + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False) + + # Verify it fails closed (returns False) + self.assertFalse(result) + + def test_is_feature_enabled_fail_open_on_invalid_response(self): + """Test that is_feature_enabled returns True (fail open) when response missing 'enabled' field.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock response with missing 'enabled' field + mock_response = MagicMock() + mock_response.json.return_value = {'some_other_field': 'value'} + mock_response.raise_for_status = MagicMock() + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True) + + # Verify it fails open (returns True) + self.assertTrue(result) + + def test_is_feature_enabled_fail_closed_on_json_parse_error(self): + """Test that is_feature_enabled returns False (fail closed) when JSON parsing fails.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock response with invalid JSON + mock_response = MagicMock() + mock_response.json.side_effect = ValueError('Invalid JSON') + mock_response.raise_for_status = MagicMock() + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=False) + + # Verify it fails closed (returns False) + self.assertFalse(result) + + def test_is_feature_enabled_fail_open_on_json_parse_error(self): + """Test that is_feature_enabled returns True (fail open) when JSON parsing fails.""" + from cc_common.feature_flag_client import is_feature_enabled + + # Mock response with invalid JSON + mock_response = MagicMock() + mock_response.json.side_effect = ValueError('Invalid JSON') + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response): + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, fail_default=True) + + # Verify it fails open (returns True) + self.assertTrue(result) + + def test_feature_flag_context_with_user_id_only(self): + """Test FeatureFlagContext to_dict with only user_id.""" + from cc_common.feature_flag_client import FeatureFlagContext + + context = FeatureFlagContext(user_id='user123') + result = context.to_dict() + + self.assertEqual(result, {'userId': 'user123'}) + + def test_feature_flag_context_with_custom_attributes_only(self): + """Test FeatureFlagContext to_dict with only custom_attributes.""" + from cc_common.feature_flag_client import FeatureFlagContext + + context = FeatureFlagContext(custom_attributes={'licenseType': 'cos', 'jurisdiction': 'oh'}) + result = context.to_dict() + + self.assertEqual(result, {'customAttributes': {'licenseType': 'cos', 'jurisdiction': 'oh'}}) + + def test_feature_flag_context_with_both_fields(self): + """Test FeatureFlagContext to_dict with both user_id and custom_attributes.""" + from cc_common.feature_flag_client import FeatureFlagContext + + context = FeatureFlagContext(user_id='user456', custom_attributes={'licenseType': 'physician'}) + result = context.to_dict() + + self.assertEqual(result, {'userId': 'user456', 'customAttributes': {'licenseType': 'physician'}}) + + def test_feature_flag_context_empty(self): + """Test FeatureFlagContext to_dict with no fields set.""" + from cc_common.feature_flag_client import FeatureFlagContext + + context = FeatureFlagContext() + result = context.to_dict() + + self.assertEqual(result, {}) + + def test_is_feature_enabled_with_context_user_id_only(self): + """Test that is_feature_enabled works with context containing only user_id.""" + from cc_common.feature_flag_client import FeatureFlagContext, is_feature_enabled + + # Mock successful API response + mock_response = MagicMock() + mock_response.json.return_value = {'enabled': True} + + context = FeatureFlagContext(user_id='user789') + + with patch('cc_common.feature_flag_client.requests.post', return_value=mock_response) as mock_post: + result = is_feature_enabled(FeatureFlagEnum.TEST_FLAG, context=context) + + # Verify the result + self.assertTrue(result) + + # Verify the API was called with only userId in context + mock_post.assert_called_once_with( + 'https://api.example.com/v1/flags/test-flag/check', + json={'context': {'userId': 'user789'}}, + timeout=5, + headers={'Content-Type': 'application/json'}, + ) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_investigation_event_bus_client.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_investigation_event_bus_client.py new file mode 100644 index 0000000000..ded159be3f --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_investigation_event_bus_client.py @@ -0,0 +1,365 @@ +import json +from datetime import datetime +from unittest.mock import MagicMock +from uuid import uuid4 + +from tests import TstLambdas + + +class TestInvestigationEventBusClient(TstLambdas): + def setUp(self): + from cc_common.config import config + from cc_common.event_bus_client import EventBusClient + + self.mock_events_client = MagicMock(name='events-client') + config.events_client = self.mock_events_client + + self.client = EventBusClient() + + def test_publish_privilege_investigation_event(self): + """Test publishing privilege investigation event""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + create_date = datetime.fromisoformat('2024-02-15T12:00:00+00:00') + + # Call the method + self.client.publish_investigation_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + create_date=create_date, + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + investigation_id=investigation_id, + ) + + # Verify put_events was called + self.mock_events_client.put_events.assert_called_once() + + # Verify the event structure + call_args = self.mock_events_client.put_events.call_args[1] + entries = call_args['Entries'] + self.assertEqual(1, len(entries)) + + event = entries[0] + + # Create expected event structure (without Detail field) + expected_event = { + 'Source': 'test.source', + 'DetailType': 'privilege.investigation', + 'EventBusName': 'license-data-events', + } + + # Create expected detail structure + expected_detail = { + 'compact': 'socw', + 'providerId': str(provider_id), + 'investigationId': str(investigation_id), + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'investigationAgainst': 'privilege', + } + + # Pop dynamic field from actual event + actual_event = event.copy() + actual_detail = json.loads(actual_event['Detail']) + actual_detail.pop('eventTime') + actual_event.pop('Detail') + + # Compare event structure and detail separately + self.assertEqual(expected_event, actual_event) + self.assertEqual(expected_detail, actual_detail) + + def test_publish_license_investigation_event(self): + """Test publishing license investigation event""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + create_date = datetime.fromisoformat('2024-02-15T12:00:00+00:00') + + # Call the method + self.client.publish_investigation_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + create_date=create_date, + investigation_against=InvestigationAgainstEnum.LICENSE, + investigation_id=investigation_id, + ) + + # Verify put_events was called + self.mock_events_client.put_events.assert_called_once() + + # Verify the event structure + call_args = self.mock_events_client.put_events.call_args[1] + entries = call_args['Entries'] + self.assertEqual(1, len(entries)) + + event = entries[0] + + # Create expected event structure (without Detail field) + expected_event = { + 'Source': 'test.source', + 'DetailType': 'license.investigation', + 'EventBusName': 'license-data-events', + } + + # Create expected detail structure + expected_detail = { + 'compact': 'socw', + 'providerId': str(provider_id), + 'investigationId': str(investigation_id), + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'investigationAgainst': 'license', + } + + # Pop dynamic field from actual event + actual_event = event.copy() + actual_detail = json.loads(actual_event['Detail']) + actual_detail.pop('eventTime') + actual_event.pop('Detail') + + # Compare event structure and detail separately + self.assertEqual(expected_event, actual_event) + self.assertEqual(expected_detail, actual_detail) + + def test_publish_privilege_investigation_closed_event(self): + """Test publishing privilege investigation closed event""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + close_date = datetime.fromisoformat('2024-03-15T12:00:00+00:00') + + # Call the method + self.client.publish_investigation_closed_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + close_date=close_date, + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + investigation_id=investigation_id, + ) + + # Verify put_events was called + self.mock_events_client.put_events.assert_called_once() + + # Verify the event structure + call_args = self.mock_events_client.put_events.call_args[1] + entries = call_args['Entries'] + self.assertEqual(1, len(entries)) + + event = entries[0] + + # Create expected event structure (without Detail field) + expected_event = { + 'Source': 'test.source', + 'DetailType': 'privilege.investigationClosed', + 'EventBusName': 'license-data-events', + } + + # Create expected detail structure + expected_detail = { + 'compact': 'socw', + 'providerId': str(provider_id), + 'investigationId': str(investigation_id), + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'investigationAgainst': 'privilege', + } + + # Pop dynamic field from actual event + actual_event = event.copy() + actual_detail = json.loads(actual_event['Detail']) + actual_detail.pop('eventTime') + actual_event.pop('Detail') + + # Compare event structure and detail separately + self.assertEqual(expected_event, actual_event) + self.assertEqual(expected_detail, actual_detail) + + def test_publish_license_investigation_closed_event(self): + """Test publishing license investigation closed event""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + close_date = datetime.fromisoformat('2024-03-15T12:00:00+00:00') + + # Call the method + self.client.publish_investigation_closed_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + close_date=close_date, + investigation_against=InvestigationAgainstEnum.LICENSE, + investigation_id=investigation_id, + ) + + # Verify put_events was called + self.mock_events_client.put_events.assert_called_once() + + # Verify the event structure + call_args = self.mock_events_client.put_events.call_args[1] + entries = call_args['Entries'] + self.assertEqual(1, len(entries)) + + event = entries[0] + + # Create expected event structure (without Detail field) + expected_event = { + 'Source': 'test.source', + 'DetailType': 'license.investigationClosed', + 'EventBusName': 'license-data-events', + } + + # Create expected detail structure + expected_detail = { + 'compact': 'socw', + 'providerId': str(provider_id), + 'investigationId': str(investigation_id), + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': 'cos', + 'investigationAgainst': 'license', + } + + # Pop dynamic field from actual event + actual_event = event.copy() + actual_detail = json.loads(actual_event['Detail']) + actual_detail.pop('eventTime') + actual_event.pop('Detail') + + # Compare event structure and detail separately + self.assertEqual(expected_event, actual_event) + self.assertEqual(expected_detail, actual_detail) + + def test_publish_privilege_investigation_event_with_batch_writer(self): + """Test publishing privilege investigation event with batch writer""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + create_date = datetime.fromisoformat('2024-02-15T12:00:00+00:00') + + # Mock batch writer + mock_batch_writer = MagicMock() + + # Call the method + self.client.publish_investigation_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + create_date=create_date, + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + investigation_id=investigation_id, + event_batch_writer=mock_batch_writer, + ) + + # Verify put_events was NOT called directly + self.mock_events_client.put_events.assert_not_called() + + # Verify batch writer was used + mock_batch_writer.put_event.assert_called_once() + + def test_publish_license_investigation_event_with_batch_writer(self): + """Test publishing license investigation event with batch writer""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + create_date = datetime.fromisoformat('2024-02-15T12:00:00+00:00') + + # Mock batch writer + mock_batch_writer = MagicMock() + + # Call the method + self.client.publish_investigation_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + create_date=create_date, + investigation_against=InvestigationAgainstEnum.LICENSE, + investigation_id=investigation_id, + event_batch_writer=mock_batch_writer, + ) + + # Verify put_events was NOT called directly + self.mock_events_client.put_events.assert_not_called() + + # Verify batch writer was used + mock_batch_writer.put_event.assert_called_once() + + def test_publish_privilege_investigation_closed_event_with_batch_writer(self): + """Test publishing privilege investigation closed event with batch writer""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + close_date = datetime.fromisoformat('2024-03-15T12:00:00+00:00') + + # Mock batch writer + mock_batch_writer = MagicMock() + + # Call the method + self.client.publish_investigation_closed_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + close_date=close_date, + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + investigation_id=investigation_id, + event_batch_writer=mock_batch_writer, + ) + + # Verify put_events was NOT called directly + self.mock_events_client.put_events.assert_not_called() + + # Verify batch writer was used + mock_batch_writer.put_event.assert_called_once() + + def test_publish_license_investigation_closed_event_with_batch_writer(self): + """Test publishing license investigation closed event with batch writer""" + from cc_common.data_model.schema.common import InvestigationAgainstEnum + + provider_id = uuid4() + investigation_id = uuid4() + close_date = datetime.fromisoformat('2024-03-15T12:00:00+00:00') + + # Mock batch writer + mock_batch_writer = MagicMock() + + # Call the method + self.client.publish_investigation_closed_event( + source='test.source', + compact='socw', + provider_id=provider_id, + jurisdiction='ne', + license_type_abbreviation='cos', + close_date=close_date, + investigation_against=InvestigationAgainstEnum.LICENSE, + investigation_id=investigation_id, + event_batch_writer=mock_batch_writer, + ) + + # Verify put_events was NOT called directly + self.mock_events_client.put_events.assert_not_called() + + # Verify batch writer was used + mock_batch_writer.put_event.assert_called_once() diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_optional_signature_auth.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_optional_signature_auth.py new file mode 100644 index 0000000000..a71801d65d --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_optional_signature_auth.py @@ -0,0 +1,273 @@ +# ruff: noqa: ARG001 unused-argument +""" +Tests for the optional signature authentication decorator. + +This module tests the optional_signature_auth decorator which allows endpoints to +support both authenticated and unauthenticated access based on whether a +public key is configured for the compact/state combination. +""" + +import json +from datetime import UTC, datetime +from unittest.mock import patch + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.exceptions import CCInvalidRequestException, CCUnauthorizedException + +from tests import TstLambdas + + +class TestOptionalSignatureAuth(TstLambdas): + """Test the optional_signature_auth decorator.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + # Load test keys + with open('tests/resources/client_private_key.pem') as f: + self.private_key_pem = f.read() + + with open('tests/resources/client_public_key.pem') as f: + self.public_key_pem = f.read() + + # Load base event + with open('tests/resources/api-client-event.json') as f: + self.base_event = json.load(f) + + def test_no_public_key_configured_allows_request(self): + """Test that requests proceed when no public key is configured.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK', 'authenticated': False} + + # Mock DynamoDB to return empty dict (no public key configured) + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {} + + resp = lambda_handler(self.base_event, self.mock_context) + + # Should proceed without signature validation + self.assertEqual({'message': 'OK', 'authenticated': False}, resp) + + def test_public_key_configured_enforces_signature_validation(self): + """Test that signature validation is enforced when public key is configured.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK', 'authenticated': True} + + # Create a properly signed event + event = self._create_signed_event() + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {'test-key-001': self.public_key_pem} + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + resp = lambda_handler(event, self.mock_context) + + # Should validate signature and proceed + self.assertEqual({'message': 'OK', 'authenticated': True}, resp) + + def test_public_key_configured_missing_headers_rejected(self): + """Test that missing signature headers are rejected when public key is configured.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Mock DynamoDB to return True (keys configured) + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {'test-key-001': self.public_key_pem} + + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(self.base_event, self.mock_context) + + self.assertIn('X-Key-Id header required when signature keys are configured', str(cm.exception)) + + def test_public_key_configured_invalid_signature_rejected(self): + """Test that invalid signatures are rejected when public key is configured.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with invalid signature + event = self._create_signed_event() + event['headers']['X-Signature'] = 'invalid-signature' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {'test-key-001': self.public_key_pem} + + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Invalid request signature', str(cm.exception)) + + def test_missing_path_parameters_rejected(self): + """Test that missing path parameters are rejected regardless of public key configuration.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event without path parameters + event = self.base_event.copy() + event['pathParameters'] = {} + + # Should be rejected even if no public key is configured + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {} + + with self.assertRaises(CCInvalidRequestException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Missing compact or jurisdiction parameters', str(cm.exception)) + + def test_public_key_configured_invalid_timestamp_rejected(self): + """Test that invalid timestamps are rejected when public key is configured.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with old timestamp + event = self._create_signed_event() + event['headers']['X-Timestamp'] = '2020-01-01T00:00:00Z' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {'test-key-001': self.public_key_pem} + + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Request timestamp is too old or too far in the future', str(cm.exception)) + + def test_public_key_configured_malformed_timestamp_rejected(self): + """Test that malformed timestamps are rejected when public key is configured.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with malformed timestamp + event = self._create_signed_event() + event['headers']['X-Timestamp'] = 'not-a-timestamp' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {'test-key-001': self.public_key_pem} + + with self.assertRaises(CCInvalidRequestException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Invalid timestamp format', str(cm.exception)) + + def test_public_key_configured_unsupported_algorithm_rejected(self): + """Test that unsupported algorithms are rejected when public key is configured.""" + from cc_common.signature_auth import optional_signature_auth + + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with unsupported algorithm + event = self._create_signed_event() + event['headers']['X-Algorithm'] = 'RSA-SHA256' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {'test-key-001': self.public_key_pem} + + with self.assertRaises(CCUnauthorizedException) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Unsupported signature algorithm', str(cm.exception)) + + def test_integration_with_api_handler(self): + """Test that optional_signature_auth works correctly with api_handler decorator.""" + from cc_common.signature_auth import optional_signature_auth + from cc_common.utils import api_handler + + @api_handler + @optional_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + # Check if we have signature headers to determine if authenticated + headers = event.get('headers') or {} + has_signature_headers = all( + [ + headers.get('X-Algorithm'), + headers.get('X-Timestamp'), + headers.get('X-Nonce'), + headers.get('X-Signature'), + ] + ) + return {'message': 'OK', 'authenticated': has_signature_headers} + + # Test with no public key configured + + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {} + + resp = lambda_handler(self.base_event, self.mock_context) + + # Should return API Gateway response format + self.assertEqual(200, resp['statusCode']) + self.assertEqual('{"message": "OK", "authenticated": false}', resp['body']) + + # Test with public key configured and valid signature + event = self._create_signed_event() + + with patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') as mock_get_keys: + mock_get_keys.return_value = {'test-key-001': self.public_key_pem} + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + resp = lambda_handler(event, self.mock_context) + + # Should return API Gateway response format + self.assertEqual(200, resp['statusCode']) + self.assertEqual('{"message": "OK", "authenticated": true}', resp['body']) + + def _create_signed_event(self) -> dict: + """Create a properly signed event for testing.""" + # Create base event + event = self.base_event.copy() + + # Generate current timestamp and nonce + timestamp = datetime.now(UTC).isoformat() + nonce = '550e8400-e29b-41d4-a716-446655440000' + + # Import and use the sign_request function + from common_test.sign_request import sign_request + + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp, + nonce=nonce, + key_id='test-key-001', + private_key_pem=self.private_key_pem, + ) + + # Add signature headers to event + event['headers'].update(headers) + + return event diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_provider_record_util.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_provider_record_util.py new file mode 100644 index 0000000000..8dd600a8ec --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_provider_record_util.py @@ -0,0 +1,1295 @@ +from datetime import date +from unittest.mock import ANY, MagicMock, patch +from uuid import UUID + +from tests import TstLambdas + + +@patch('cc_common.config._Config.expiration_resolution_date', date(2025, 6, 1)) +class TestGeneratePrivilegesForProvider(TstLambdas): + """Tests for ProviderUserRecords.generate_privileges_for_provider().""" + + def _make_provider_records(self, provider_overrides=None, license_overrides_list=None): + """Build list of provider + license (and optional other) records as dicts for ProviderUserRecords.""" + from common_test.test_data_generator import TestDataGenerator + + if license_overrides_list is None: + license_overrides_list = [] + + provider = TestDataGenerator.generate_default_provider(provider_overrides or {}) + provider_record = provider.serialize_to_database_record() + records = [provider_record] + for overrides in license_overrides_list: + test_license = TestDataGenerator.generate_default_license(overrides) + records.append(test_license.serialize_to_database_record()) + return records + + def _patch_config_for_privilege_generation(self, live_compact_jurisdictions=None): + """Patch config used by provider_record_util for privilege generation. + + By default, we set the list of live compact jurisdictions to ['al', 'ky', 'oh']. + + We also set the mock current date to 2025-06-01. The license expiration date is set to 2025-04-04, so + if the test does not override this the license will be expired and therefore inactive. + + live_compact_jurisdictions: dict[compact, list[jurisdiction_str]], e.g. {'socw': ['al', 'ky', 'oh']}. + """ + if live_compact_jurisdictions is None: + live_compact_jurisdictions = {'socw': ['al', 'ky', 'oh']} + mock_config = MagicMock() + mock_config.live_compact_jurisdictions = live_compact_jurisdictions + mock_config.license_type_abbreviations = {'socw': {'cosmetologist': 'cos', 'esthetician': 'esth'}} + return patch('cc_common.data_model.provider_record_util.config', mock_config) + + def test_returns_empty_list_when_no_licenses(self): + """If provider has no license records, generate_privileges_for_provider returns empty list.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + + records = self._make_provider_records() + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(result, []) + + def test_skips_ineligible_license_type(self): + """If the selected home license for a type is not compact-eligible, no privileges for that type.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.INELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(result, []) + + def test_one_eligible_license_generates_privileges_excluding_home(self): + """One eligible license in oh: privileges for al and ky only""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 2, 28), + } + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(len(result), 2) # al and ky, not oh + self.assertEqual( + [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 2, 28), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 2, 28), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + ], + result, + ) + + def test_same_license_type_in_two_states_uses_most_recently_issued(self): + """Same license type in al and oh: most recently issued is home, privileges use that jurisdiction.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2023, 1, 1), + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2024, 6, 1), + }, + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + # oh is more recent -> home is oh; we get privileges for al and ky only + self.assertEqual(len(result), 2) + for p in result: + self.assertEqual(p['licenseJurisdiction'], 'oh') + + def test_privileges_are_associated_with_license_most_recently_renewed_when_multiple_licenses_present(self): + """When multiple licenses of same type have different renewal dates, most recently renewed is home.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + oh_expiration = date(2026, 4, 4) + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2020, 1, 1), + 'dateOfRenewal': date(2023, 6, 1), + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': oh_expiration, + 'dateOfIssuance': date(2020, 1, 1), + 'dateOfRenewal': date(2024, 6, 1), + }, + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(len(result), 2) + for p in result: + self.assertEqual(p['licenseJurisdiction'], 'oh', 'Home should be OH (most recently renewed)') + self.assertEqual(p['dateOfExpiration'], oh_expiration) + + def test_privileges_are_associated_with_license_most_recently_issued_when_multiple_licenses_present_no_renewal( + self, + ): + """When multiple licenses of same type have no renewal date, most recently issued is home.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2025, 1, 1), + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2024, 6, 1), + }, + ] + ) + # Remove dateOfRenewal so both licenses use only issuance for selection (schema allows omitted field) + for rec in records[1:]: + rec.pop('dateOfRenewal', None) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(len(result), 2) + for p in result: + self.assertEqual(p['licenseJurisdiction'], 'al', 'Home should be AL (most recently issued when no renewal)') + + def test_multiple_license_types_generate_privileges_for_both(self): + """Cosmetologist in al and esthetician in oh: privileges for both types across active jurisdictions.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'esthetician', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + }, + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + # cosmetologist: al is home -> privileges for ky, oh (2). + # esthetician: oh is home -> privileges for al, ky (2). Total 4. + self.assertEqual(len(result), 4) + by_type = {} + for p in result: + by_type.setdefault(p['licenseType'], []).append(p) + self.assertEqual(len(by_type['cosmetologist']), 2) + self.assertEqual(len(by_type['esthetician']), 2) + cos_jurisdictions = {p['jurisdiction'] for p in by_type['cosmetologist']} + est_jurisdictions = {p['jurisdiction'] for p in by_type['esthetician']} + self.assertEqual(cos_jurisdictions, {'ky', 'oh'}) + self.assertEqual(est_jurisdictions, {'al', 'ky'}) + + def test_privileges_not_generated_when_license_expired(self): + """When home license is expired (before resolution date), no privileges are generated.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2024, 1, 1), + } + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(result, []) + + def test_status_active_when_privilege_not_encumbered(self): + """When privilege is not encumbered, its status should be active.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(2, len(result)) + for p in result: + self.assertEqual(p['status'], 'active') + + def test_status_inactive_when_privilege_encumbered(self): + """When there is an unlifted adverse action in the privilege jurisdiction, + privilege status should be inactive.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + records.append( + self.test_data_generator.generate_default_adverse_action( + value_overrides={'jurisdiction': 'al'} + ).serialize_to_database_record() + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(2, len(result)) + for p in result: + if p.get('jurisdiction') == 'al': + self.assertEqual(p['status'], 'inactive') + else: + self.assertEqual(p['status'], 'active') + + def test_open_investigation_included_and_investigation_status_set(self): + """If there is an open investigation against a privilege jurisdiction, it is included + in the privilege's investigations list and investigationStatus is underInvestigation.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus, InvestigationStatusEnum + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + open_investigation = self.test_data_generator.generate_default_investigation( + value_overrides={ + 'jurisdiction': 'al', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + } + ) + records.append(open_investigation.serialize_to_database_record()) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + privilege_al = next((p for p in result if p['jurisdiction'] == 'al'), None) + self.assertIsNotNone(privilege_al, 'Expected a privilege for jurisdiction al') + self.assertEqual(len(privilege_al['investigations']), 1, 'Open investigation should be in list') + self.assertEqual( + privilege_al['investigationStatus'], + InvestigationStatusEnum.UNDER_INVESTIGATION.value, + 'investigationStatus should be underInvestigation when there is an open investigation', + ) + # even with the investigation status, it should still be set to active + self.assertEqual('active', privilege_al['status']) + + def test_returns_privilege_when_home_ineligible_and_privilege_adverse_action_matches(self): + """Ineligible home license still yields a privilege row when a privilege AA matches that jurisdiction.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.INELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + records.append( + self.test_data_generator.generate_default_adverse_action( + value_overrides={'jurisdiction': 'al'} + ).serialize_to_database_record() + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(1, len(result)) + self.assertEqual('al', result[0]['jurisdiction']) + self.assertGreaterEqual(len(result[0]['adverseActions']), 1) + self.assertEqual({'al'}, {p['jurisdiction'] for p in result}) + + def test_returns_privilege_when_home_ineligible_and_open_privilege_investigation_matches(self): + """Ineligible home license still yields a privilege row when an open privilege investigation matches.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus, InvestigationStatusEnum + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.INELIGIBLE, + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + open_investigation = self.test_data_generator.generate_default_investigation( + value_overrides={ + 'jurisdiction': 'al', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'investigationAgainst': 'privilege', + } + ) + records.append(open_investigation.serialize_to_database_record()) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + result = provider_user_records.generate_privileges_for_provider() + self.assertEqual(1, len(result)) + self.assertEqual('al', result[0]['jurisdiction']) + self.assertEqual(1, len(result[0]['investigations'])) + self.assertEqual( + InvestigationStatusEnum.UNDER_INVESTIGATION.value, + result[0]['investigationStatus'], + ) + + +class TestProviderRecordUtility(TstLambdas): + def setUp(self): + from cc_common.data_model.schema.common import ActiveInactiveStatus, CompactEligibilityStatus + + # Create a base license record that we'll modify for different test cases + self.base_license = { + 'type': 'license', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseType': 'physician', + 'licenseNumber': '12345', + 'dateOfIssuance': '2024-01-01', + 'licenseStatus': ActiveInactiveStatus.ACTIVE, + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + } + + # Create a base privilege record that we'll modify for different test cases + self.base_privilege = { + 'dateOfUpdate': '2025-05-12T15:05:08+00:00', + 'type': 'privilege', + 'providerId': 'aa2e057d-6972-4a68-a55d-aad1c3d05278', + 'compact': 'socw', + 'jurisdiction': 'al', + 'licenseJurisdiction': 'ky', + 'licenseType': 'cosmetologist', + 'dateOfIssuance': '2025-04-23T15:47:14+00:00', + 'dateOfRenewal': '2025-04-23T15:47:14+00:00', + 'dateOfExpiration': '2027-02-12', + 'attestations': [], + 'privilegeId': 'COS-AL-12', + 'administratorSetStatus': 'active', + 'status': 'active', + 'history': [], + 'adverseActions': [], + } + + def test_find_best_license_date_of_issuance_preferred_when_no_renewal(self): + """Test that find_best_license selects by most recent issuance.""" + from cc_common.data_model.provider_record_util import ProviderRecordUtility + from cc_common.data_model.schema.common import CompactEligibilityStatus + + licenses = [ + { + **self.base_license, + 'dateOfIssuance': '2024-01-01', + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + **self.base_license, + 'dateOfIssuance': '2024-02-01', + 'compactEligibility': CompactEligibilityStatus.INELIGIBLE, + }, + ] + + best_license = ProviderRecordUtility.find_most_recently_issued_or_renewed_license(licenses) + self.assertEqual(best_license['dateOfIssuance'], '2024-02-01') + self.assertEqual(best_license['compactEligibility'], CompactEligibilityStatus.INELIGIBLE) + + def test_latest_renewed_license_selected_even_when_inactive(self): + """Best license is the one renewed/issued most recently; status and eligibility are not considered.""" + from cc_common.data_model.provider_record_util import ProviderRecordUtility + from cc_common.data_model.schema.common import ActiveInactiveStatus, CompactEligibilityStatus + + # Active, compact-eligible but older renewal; inactive, ineligible but renewed most recently + licenses = [ + { + **self.base_license, + 'dateOfIssuance': '2023-01-01', + 'dateOfRenewal': '2024-01-01', + 'licenseStatus': ActiveInactiveStatus.ACTIVE, + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + **self.base_license, + 'dateOfIssuance': '2022-01-01', + 'dateOfRenewal': '2024-06-01', + 'licenseStatus': ActiveInactiveStatus.INACTIVE, + 'compactEligibility': CompactEligibilityStatus.INELIGIBLE, + }, + ] + + best_license = ProviderRecordUtility.find_most_recently_issued_or_renewed_license(licenses) + self.assertEqual(best_license['dateOfRenewal'], '2024-06-01') + self.assertEqual(best_license['licenseStatus'], ActiveInactiveStatus.INACTIVE) + self.assertEqual(best_license['compactEligibility'], CompactEligibilityStatus.INELIGIBLE) + + def test_find_best_license_raises_exception_when_no_licenses(self): + """Test that find_best_license raises an exception when no licenses are provided.""" + from cc_common.data_model.provider_record_util import ProviderRecordUtility + from cc_common.exceptions import CCInternalException + + with self.assertRaises(CCInternalException): + ProviderRecordUtility.find_most_recently_issued_or_renewed_license([]) + + def test_find_best_license_complex_scenario(self): + """With multiple licenses, the one with the most recent issuance is selected regardless of status.""" + from cc_common.data_model.provider_record_util import ProviderRecordUtility + from cc_common.data_model.schema.common import ActiveInactiveStatus, CompactEligibilityStatus + + licenses = [ + { + **self.base_license, + 'dateOfIssuance': '2024-01-01', + 'dateOfRenewal': '2024-02-25', + 'licenseStatus': ActiveInactiveStatus.ACTIVE, + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + **self.base_license, + 'dateOfIssuance': '2024-02-01', + 'licenseStatus': ActiveInactiveStatus.ACTIVE, + 'compactEligibility': CompactEligibilityStatus.INELIGIBLE, + }, + { + **self.base_license, + 'dateOfIssuance': '2024-03-01', + 'licenseStatus': ActiveInactiveStatus.INACTIVE, + 'compactEligibility': CompactEligibilityStatus.INELIGIBLE, + }, + ] + + best_license = ProviderRecordUtility.find_most_recently_issued_or_renewed_license(licenses) + self.assertEqual(best_license['dateOfIssuance'], '2024-03-01') + self.assertEqual(best_license['compactEligibility'], CompactEligibilityStatus.INELIGIBLE) + + +@patch('cc_common.config._Config.expiration_resolution_date', date(2025, 6, 1)) +class TestGenerateApiResponseObject(TstLambdas): + def _make_provider_records(self, provider_overrides=None, license_overrides_list=None, extra_records=None): + """Build list of provider + license (and optional other) records as dicts for ProviderUserRecords.""" + from common_test.test_data_generator import TestDataGenerator + + if license_overrides_list is None: + license_overrides_list = [] + + provider = TestDataGenerator.generate_default_provider(provider_overrides or {}) + provider_record = provider.serialize_to_database_record() + records = [provider_record] + for overrides in license_overrides_list: + test_license = TestDataGenerator.generate_default_license(overrides) + records.append(test_license.serialize_to_database_record()) + if extra_records: + records.extend(extra_records) + return records + + def _patch_config_for_privilege_generation(self, live_compact_jurisdictions=None): + if live_compact_jurisdictions is None: + live_compact_jurisdictions = {'socw': ['al', 'ky', 'oh']} + mock_config = MagicMock() + mock_config.live_compact_jurisdictions = live_compact_jurisdictions + mock_config.license_type_abbreviations = {'socw': {'cosmetologist': 'cos', 'esthetician': 'esth'}} + return patch('cc_common.data_model.provider_record_util.config', mock_config) + + def test_generate_api_response_object_returns_adverse_actions_as_a_top_level_field_for_all_adverse_actions(self): + # create two adverse_actions, one for a license and one for a privilege, and verify that both are returned in + # generated api response object + from cc_common.data_model.provider_record_util import ProviderUserRecords + from common_test.test_data_generator import TestDataGenerator + + license_adverse_action = TestDataGenerator.generate_default_adverse_action( + value_overrides={ + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'actionAgainst': 'license', + } + ) + privilege_adverse_action = TestDataGenerator.generate_default_adverse_action( + value_overrides={ + 'jurisdiction': 'al', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'actionAgainst': 'privilege', + 'effectiveStartDate': date.fromisoformat('2025-05-15'), + } + ) + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + } + ], + extra_records=[ + license_adverse_action.serialize_to_database_record(), + privilege_adverse_action.serialize_to_database_record(), + ], + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + api_response = provider_user_records.generate_api_response_object() + + self.assertEqual( + [license_adverse_action.to_dict(), privilege_adverse_action.to_dict()], + api_response['adverseActions'], + ) + + +@patch('cc_common.config._Config.expiration_resolution_date', date(2025, 6, 1)) +class TestGenerateOpenSearchDocuments(TstLambdas): + """Tests for ProviderUserRecords.generate_opensearch_documents().""" + + def _make_provider_records(self, provider_overrides=None, license_overrides_list=None, extra_records=None): + """Build list of provider + license (and optional other) records as dicts for ProviderUserRecords.""" + from common_test.test_data_generator import TestDataGenerator + + if license_overrides_list is None: + license_overrides_list = [] + + provider = TestDataGenerator.generate_default_provider(provider_overrides or {}) + provider_record = provider.serialize_to_database_record() + records = [provider_record] + for overrides in license_overrides_list: + test_license = TestDataGenerator.generate_default_license(overrides) + records.append(test_license.serialize_to_database_record()) + if extra_records: + records.extend(extra_records) + return records + + def _patch_config_for_privilege_generation(self, live_compact_jurisdictions=None): + if live_compact_jurisdictions is None: + live_compact_jurisdictions = {'socw': ['al', 'ky', 'oh']} + mock_config = MagicMock() + mock_config.live_compact_jurisdictions = live_compact_jurisdictions + mock_config.license_type_abbreviations = {'socw': {'cosmetologist': 'cos', 'esthetician': 'esth'}} + return patch('cc_common.data_model.provider_record_util.config', mock_config) + + def test_single_license_returns_one_document(self): + """Provider with one license produces exactly one OpenSearch document.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + } + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual( + [ + { + 'birthMonthDay': '06-06', + 'compact': 'socw', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'socw', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'mostRecentLicenseForType': True, + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + ], + 'adverseActions': [], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + } + ], + docs, + ) + + def test_two_licenses_different_types_returns_two_documents(self): + """Provider with two licenses of different types produces two documents. + The second license is also ineligible, so its associated privileges should be inactive. + """ + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'esthetician', + 'dateOfExpiration': date(2026, 4, 4), + # jurisdictionUploadedCompactEligibility is ineligible, so the privileges should be inactive + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.INELIGIBLE, + }, + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual( + [ + { + 'birthMonthDay': '06-06', + 'compact': 'socw', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'socw', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'al', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'mostRecentLicenseForType': True, + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'oh', + 'licenseJurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + ], + 'adverseActions': [], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + { + 'birthMonthDay': '06-06', + 'compact': 'socw', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'socw', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2010, 6, 6), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'ineligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'esthetician', + 'middleName': 'Gunnar', + 'mostRecentLicenseForType': True, + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + # these privileges are inactive due to the home state license being ineligible + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'esthetician', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'inactive', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'esthetician', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'inactive', + 'type': 'privilege', + }, + ], + 'adverseActions': [], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + ], + docs, + ) + + def test_three_licenses_two_same_type_one_other_sets_most_recent_per_type(self): + """Two cosmetologist licenses + one esthetician: each type's most recent license shows + mostRecentLicenseForType true.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'ky', + 'licenseType': 'cosmetologist', + 'licenseNumber': 'KY-COS-OLDER', + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2005, 1, 1), + 'dateOfRenewal': date(2010, 6, 1), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + 'jurisdiction': 'al', + 'licenseType': 'esthetician', + 'licenseNumber': 'AL-EST-ONLY', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'jurisdictionUploadedCompactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual(3, len(docs)) + by_jurisdiction_and_type = { + (d['licenses'][0]['jurisdiction'], d['licenses'][0]['licenseType']): d['licenses'][0][ + 'mostRecentLicenseForType' + ] + for d in docs + } + self.assertFalse(by_jurisdiction_and_type[('ky', 'cosmetologist')]) + self.assertTrue(by_jurisdiction_and_type[('oh', 'cosmetologist')]) + self.assertTrue(by_jurisdiction_and_type[('al', 'esthetician')]) + + def test_privileges_assigned_only_to_home_license_document(self): + """Privileges are only on the document whose license is the home license for its type.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + 'dateOfIssuance': date(2023, 1, 1), + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + # this license was issued more recently, so it should have the privileges associated with it. + 'dateOfIssuance': date(2024, 6, 1), + }, + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual( + [ + { + 'birthMonthDay': '06-06', + 'compact': 'socw', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'socw', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2023, 1, 1), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'al', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'mostRecentLicenseForType': False, + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [], + 'adverseActions': [], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + { + 'birthMonthDay': '06-06', + 'compact': 'socw', + 'compactEligibility': 'ineligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2025, 4, 4), + 'dateOfUpdate': ANY, + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'licenses': [ + { + 'adverseActions': [], + 'compact': 'socw', + 'compactEligibility': 'eligible', + 'dateOfBirth': date(1985, 6, 6), + 'dateOfExpiration': date(2026, 4, 4), + 'dateOfIssuance': date(2024, 6, 1), + 'dateOfRenewal': date(2020, 4, 4), + 'dateOfUpdate': ANY, + 'emailAddress': 'björk@example.com', + 'familyName': 'Guðmundsdóttir', + 'givenName': 'Björk', + 'homeAddressCity': 'Columbus', + 'homeAddressPostalCode': '43004', + 'homeAddressState': 'oh', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'investigations': [], + 'jurisdiction': 'oh', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'licenseNumber': 'A0608337260', + 'licenseStatus': 'active', + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseType': 'cosmetologist', + 'middleName': 'Gunnar', + 'mostRecentLicenseForType': True, + 'phoneNumber': '+13213214321', + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'license', + } + ], + 'middleName': 'Gunnar', + 'privileges': [ + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'al', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + { + 'administratorSetStatus': 'active', + 'adverseActions': [], + 'compact': 'socw', + 'dateOfExpiration': date(2026, 4, 4), + 'investigations': [], + 'jurisdiction': 'ky', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'status': 'active', + 'type': 'privilege', + }, + ], + 'adverseActions': [], + 'providerId': UUID('89a6377e-c3a5-40e5-bca5-317ec854c570'), + 'ssnLastFour': '1234', + 'type': 'provider', + }, + ], + docs, + ) + + def test_multiple_types_privileges_on_correct_home_licenses(self): + """With two license types, each type's home license gets its own privileges.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'al', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + { + 'jurisdiction': 'oh', + 'licenseType': 'esthetician', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + }, + ] + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual(2, len(docs)) + al_doc = next(d for d in docs if d['licenses'][0]['jurisdiction'] == 'al') + oh_doc = next(d for d in docs if d['licenses'][0]['jurisdiction'] == 'oh') + # cosmetologist home is al -> al_doc gets cosmetologist privileges + cos_privs = [p for p in al_doc['privileges'] if p['licenseType'] == 'cosmetologist'] + self.assertGreater(len(cos_privs), 0) + # esthetician home is oh -> oh_doc gets esthetician privileges + esth_privs = [p for p in oh_doc['privileges'] if p['licenseType'] == 'esthetician'] + self.assertGreater(len(esth_privs), 0) + + def test_license_adverse_actions_included(self): + """Each document nests license-targeted adverse actions under that license and duplicates them at top level.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + } + ], + extra_records=[ + self.test_data_generator.generate_default_adverse_action( + value_overrides={ + 'jurisdiction': 'oh', + 'actionAgainst': 'license', + 'licenseTypeAbbreviation': 'cos', + } + ).serialize_to_database_record() + ], + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual(1, len(docs)) + self.assertEqual(1, len(docs[0]['licenses'][0]['adverseActions'])) + self.assertEqual(1, len(docs[0]['adverseActions'])) + + def test_privilege_adverse_actions_included_in_top_level_adverse_actions(self): + """Privilege-targeted adverse actions are in top-level adverseActions (aggregated list)""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import CompactEligibilityStatus + from common_test.test_data_generator import TestDataGenerator + + privilege_aa = TestDataGenerator.generate_default_adverse_action( + value_overrides={ + 'jurisdiction': 'al', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'actionAgainst': 'privilege', + 'effectiveStartDate': date(2025, 5, 15), + } + ) + records = self._make_provider_records( + license_overrides_list=[ + { + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': date(2026, 4, 4), + 'compactEligibility': CompactEligibilityStatus.ELIGIBLE, + } + ], + extra_records=[privilege_aa.serialize_to_database_record()], + ) + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + all_aa = provider_user_records.get_adverse_action_records() + self.assertEqual(1, len(all_aa)) + self.assertEqual('privilege', all_aa[0].actionAgainst) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual(1, len(docs)) + self.assertEqual([], docs[0]['licenses'][0]['adverseActions']) + self.assertEqual([privilege_aa.to_dict()], docs[0]['adverseActions']) + + def test_no_licenses_returns_empty_list(self): + """Provider with no license records produces an empty list.""" + from cc_common.data_model.provider_record_util import ProviderUserRecords + + records = self._make_provider_records() + with self._patch_config_for_privilege_generation(): + provider_user_records = ProviderUserRecords(records) + docs = provider_user_records.generate_opensearch_documents() + + self.assertEqual([], docs) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_response_encoder.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_response_encoder.py new file mode 100644 index 0000000000..962e9976e9 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_response_encoder.py @@ -0,0 +1,33 @@ +import json +from datetime import UTC, date, datetime +from decimal import Decimal + +from tests import TstLambdas + + +class TestResponseEncoder(TstLambdas): + def test_standard_fields(self): + from cc_common.utils import ResponseEncoder + + start_data = {'foo': 42, 'bar': 'baz'} + + round_trip = json.loads(json.dumps(start_data, cls=ResponseEncoder)) + self.assertEqual(start_data, round_trip) + + def test_decimal(self): + from cc_common.utils import ResponseEncoder + + start_data = {'decimal': Decimal('4.1'), 'integer': Decimal(4)} + + dumped = json.dumps(start_data, cls=ResponseEncoder) + self.assertIn('"integer": 4', dumped) + self.assertIn('"decimal": 4.1', dumped) + + def test_date(self): + from cc_common.utils import ResponseEncoder + + start_data = {'date': date(2024, 7, 21), 'datetime': datetime(2024, 7, 21, 17, 20, 12, 54321, tzinfo=UTC)} + dumped = json.dumps(start_data, cls=ResponseEncoder) + + self.assertIn('"date": "2024-07-21"', dumped) + self.assertIn('"datetime": "2024-07-21T17:20:12.054321+00:00"', dumped) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_sanitize_provider_data.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_sanitize_provider_data.py new file mode 100644 index 0000000000..93e8918a5d --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_sanitize_provider_data.py @@ -0,0 +1,78 @@ +import json +from datetime import datetime +from unittest.mock import patch + +from tests import TstLambdas + + +class TestSanitizeProviderData(TstLambdas): + def when_expecting_full_provider_record_returned(self, scopes: set[str]): + from cc_common.utils import sanitize_provider_data_based_on_caller_scopes + + with open('tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + test_provider = expected_provider.copy() + + resp = sanitize_provider_data_based_on_caller_scopes(compact='socw', provider=test_provider, scopes=scopes) + + self.assertEqual(expected_provider, resp) + + def test_full_provider_record_returned_if_caller_has_compact_read_private_permissions(self): + self.when_expecting_full_provider_record_returned( + scopes={'openid', 'email', 'socw/readGeneral', 'socw/readPrivate'} + ) + + def test_full_provider_record_returned_if_caller_has_read_private_permissions_for_license_jurisdiction(self): + self.when_expecting_full_provider_record_returned( + scopes={'openid', 'email', 'socw/readGeneral', 'oh/socw.readPrivate'} + ) + + def test_full_provider_record_returned_if_caller_has_read_private_permissions_for_privileges_jurisdiction(self): + self.when_expecting_full_provider_record_returned( + scopes={'openid', 'email', 'socw/readGeneral', 'ne/socw.readPrivate'} + ) + + def when_testing_general_provider_info_returned(self, scopes: set[str]): + from cc_common.data_model.schema.provider.api import ProviderGeneralResponseSchema + from cc_common.utils import sanitize_provider_data_based_on_caller_scopes + + with open('tests/resources/api/provider-detail-response.json') as f: + full_provider = json.load(f) + # Re-read data from file to have an independent second copy + f.seek(0) + expected_provider = json.load(f) + mock_ssn = full_provider['ssnLastFour'] + mock_dob = full_provider['dateOfBirth'] + # simplest way to set up mock test user as returned from the db + loaded_provider = ProviderGeneralResponseSchema().load(full_provider) + loaded_provider['ssnLastFour'] = mock_ssn + loaded_provider['dateOfBirth'] = mock_dob + loaded_provider['licenses'][0]['ssnLastFour'] = mock_ssn + loaded_provider['licenses'][0]['dateOfBirth'] = mock_dob + + # test provider has a license in oh and privilege in ne + resp = sanitize_provider_data_based_on_caller_scopes(compact='socw', provider=loaded_provider, scopes=scopes) + + # now create expected provider record with the ssn and dob removed + del expected_provider['ssnLastFour'] + del expected_provider['dateOfBirth'] + # also remove the ssn from the license record + del expected_provider['licenses'][0]['ssnLastFour'] + del expected_provider['licenses'][0]['dateOfBirth'] + + self.assertEqual(expected_provider, resp) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2025-04-01T12:00:00+00:00')) + def test_sanitized_provider_record_returned_if_caller_does_not_have_read_private_permissions_for_jurisdiction( + self, + ): + # Mock datetime so schema expiration checks keep license/privilege status active (test data expires 2025-04-04) + self.when_testing_general_provider_info_returned( + scopes={'openid', 'email', 'socw/readGeneral', 'az/socw.readPrivate'} + ) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2025-04-01T12:00:00+00:00')) + def test_sanitized_provider_record_returned_if_caller_does_not_have_any_read_private_permissions(self): + # Mock datetime so schema expiration checks keep license/privilege status active (test data expires 2025-04-04) + self.when_testing_general_provider_info_returned(scopes={'openid', 'email', 'socw/readGeneral'}) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_signature_auth.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_signature_auth.py new file mode 100644 index 0000000000..dedde2bf0b --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_signature_auth.py @@ -0,0 +1,738 @@ +# ruff: noqa: ARG001 unused-argument +import base64 +import json +from copy import deepcopy +from datetime import UTC, datetime +from unittest.mock import patch + +from aws_lambda_powertools.utilities.typing import LambdaContext + +from tests import TstLambdas + + +class TestSignatureAuth(TstLambdas): + """Testing that the signature_auth_required decorator is working as expected.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + # Load test keys + with open('tests/resources/client_private_key.pem') as f: + self.private_key_pem = f.read() + + with open('tests/resources/client_public_key.pem') as f: + self.public_key_pem = f.read() + + # Load test event + with open('tests/resources/api-client-event.json') as f: + self.base_event = json.load(f) + + def test_happy_path(self): + """Test successful signature authentication.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create a properly signed request + event = self._create_signed_event() + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + resp = lambda_handler(event, self.mock_context) + + # The decorator returns the raw function result, not an API Gateway response + self.assertEqual({'message': 'OK'}, resp) + + def test_missing_headers(self): + """Test authentication failure when required headers are missing.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event without signature headers + event = deepcopy(self.base_event) + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Missing required X-Key-Id header', str(cm.exception)) + + def test_unsupported_algorithm(self): + """Test authentication failure with unsupported algorithm.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with wrong algorithm + event = self._create_signed_event() + event['headers']['X-Algorithm'] = 'RSA-SHA256' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Unsupported signature algorithm', str(cm.exception)) + + def test_invalid_timestamp(self): + """Test authentication failure with invalid timestamp.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with old timestamp + event = self._create_signed_event() + event['headers']['X-Timestamp'] = '2020-01-01T00:00:00Z' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Request timestamp is too old or too far in the future', str(cm.exception)) + + def test_malformed_timestamp(self): + """Test authentication failure with malformed timestamp.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with malformed timestamp + event = self._create_signed_event() + event['headers']['X-Timestamp'] = 'not-a-timestamp' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Invalid timestamp format', str(cm.exception)) + + def test_timestamp_format_compatibility(self): + """Test that both timestamp formats work with the same signature validation.""" + from cc_common.signature_auth import _build_signature_string, _verify_signature, required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Test both timestamp formats + timestamp_formats = [ + datetime.now(UTC).isoformat(), # '+00:00' format + datetime.now(UTC).isoformat().replace('+00:00', 'Z'), # 'Z' format + ] + + for timestamp in timestamp_formats: + with self.subTest(timestamp_format=timestamp): + # Create event with current timestamp format + event = deepcopy(self.base_event) + nonce = '550e8400-e29b-41d4-a716-446655440000' + + # Import and use the sign_request function + from common_test.sign_request import sign_request + + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp, + nonce=nonce, + key_id='test-key-001', + private_key_pem=self.private_key_pem, + ) + + event['headers'].update(headers) + + # Verify that the signature string can be built and verified + signature_string = _build_signature_string(event) + is_valid = _verify_signature(signature_string, headers['X-Signature'], self.public_key_pem) + self.assertTrue(is_valid) + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + resp = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK'}, resp) + + def test_missing_path_parameters(self): + """Test authentication failure when compact/jurisdiction are missing.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event without path parameters + event = self._create_signed_event() + event['pathParameters'] = {} + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Missing compact or jurisdiction parameters', str(cm.exception)) + + def test_public_key_not_found(self): + """Test authentication failure when public key is not found in DynamoDB.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create a properly signed request + event = self._create_signed_event() + + # Mock DynamoDB to return None (key not found) + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = None + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Public key not found for this compact/jurisdiction', str(cm.exception)) + + def test_invalid_signature(self): + """Test authentication failure with invalid signature.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with invalid signature + event = self._create_signed_event() + event['headers']['X-Signature'] = 'invalid-signature' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Invalid request signature', str(cm.exception)) + + def test_sign_request_utility_function(self): + """Test the sign_request utility function works correctly.""" + # Test data + method = 'POST' + path = '/v1/compacts/socw/jurisdictions/al/providers/query' + query_params = {'pageSize': '50', 'startDateTime': '2024-01-01T00:00:00Z'} + timestamp = '2024-01-15T10:30:00Z' + nonce = '550e8400-e29b-41d4-a716-446655440000' + + # Import and use the sign_request function + from common_test.sign_request import sign_request + + headers = sign_request(method, path, query_params, timestamp, nonce, 'test-key-001', self.private_key_pem) + + # Verify headers are present + self.assertEqual('ECDSA-SHA256', headers['X-Algorithm']) + self.assertEqual(timestamp, headers['X-Timestamp']) + self.assertEqual(nonce, headers['X-Nonce']) + self.assertIn('X-Key-Id', headers) + self.assertIn('X-Signature', headers) + + # Verify signature can be decoded + signature_bytes = base64.b64decode(headers['X-Signature']) + self.assertIsInstance(signature_bytes, bytes) + self.assertGreater(len(signature_bytes), 0) + + def test_sign_request_with_url_encoded_parameters(self): + """Test that sign_request properly URL-encodes query parameters.""" + # Test data with special characters + method = 'GET' + path = '/v1/compacts/socw/jurisdictions/al/providers/query' + query_params = { + 'search': 'test value with spaces', + 'filter': 'status=active&type=provider', + 'special': '!@#$%^&*()', + } + timestamp = '2024-01-15T10:30:00Z' + nonce = '550e8400-e29b-41d4-a716-446655440000' + + # Import and use the sign_request function + from common_test.sign_request import sign_request + + headers = sign_request(method, path, query_params, timestamp, nonce, 'test-key-001', self.private_key_pem) + + # Verify headers are present + self.assertEqual('ECDSA-SHA256', headers['X-Algorithm']) + self.assertEqual(timestamp, headers['X-Timestamp']) + self.assertEqual(nonce, headers['X-Nonce']) + self.assertIn('X-Key-Id', headers) + self.assertIn('X-Signature', headers) + + # Verify signature can be decoded + signature_bytes = base64.b64decode(headers['X-Signature']) + self.assertIsInstance(signature_bytes, bytes) + self.assertGreater(len(signature_bytes), 0) + + # Verify that the signature string includes URL-encoded parameters + # The signature string should be: GET\n/path\nfilter=status%3Dactive%26type%3Dprovider& + # search=test%20value%20with%20spaces&special=%21%40%23%24%25%5E%26%2A%28%29\n... + # We can't directly verify the signature string, but we can verify the signature is valid + # by creating a test event and validating it + from cc_common.signature_auth import _build_signature_string, _verify_signature + + test_event = {'httpMethod': method, 'path': path, 'queryStringParameters': query_params, 'headers': headers} + + signature_string = _build_signature_string(test_event) + is_valid = _verify_signature(signature_string, headers['X-Signature'], self.public_key_pem) + self.assertTrue(is_valid) + + def test_signature_string_construction(self): + """Test that signature string is constructed correctly.""" + from cc_common.signature_auth import _build_signature_string + + # Create event with specific components + event = { + 'httpMethod': 'POST', + 'path': '/v1/compacts/socw/jurisdictions/al/providers/query', + 'queryStringParameters': {'pageSize': '50', 'startDateTime': '2024-01-01T00:00:00Z'}, + 'headers': { + 'X-Timestamp': '2024-01-15T10:30:00Z', + 'X-Nonce': '550e8400-e29b-41d4-a716-446655440000', + 'X-Key-Id': 'test-key-001', + }, + } + + signature_string = _build_signature_string(event) + + expected = ( + 'POST\n' + '/v1/compacts/socw/jurisdictions/al/providers/query\n' + 'pageSize=50&startDateTime=2024-01-01T00%3A00%3A00Z\n' + '2024-01-15T10:30:00Z\n' + '550e8400-e29b-41d4-a716-446655440000\n' + 'test-key-001' + ) + + self.assertEqual(expected, signature_string) + + def test_query_parameters_sorting(self): + """Test that query parameters are sorted correctly in signature string.""" + from cc_common.signature_auth import _build_signature_string + + # Create event with unsorted query parameters + event = { + 'httpMethod': 'GET', + 'path': '/v1/compacts/socw/jurisdictions/al/providers/query', + 'queryStringParameters': {'zebra': 'last', 'alpha': 'first', 'beta': 'second'}, + 'headers': { + 'X-Timestamp': '2024-01-15T10:30:00Z', + 'X-Nonce': '550e8400-e29b-41d4-a716-446655440000', + 'X-Key-Id': 'test-key-001', + }, + } + + signature_string = _build_signature_string(event) + + # Verify parameters are sorted alphabetically and URL-encoded + expected = ( + 'GET\n' + '/v1/compacts/socw/jurisdictions/al/providers/query\n' + 'alpha=first&beta=second&zebra=last\n' + '2024-01-15T10:30:00Z\n' + '550e8400-e29b-41d4-a716-446655440000\n' + 'test-key-001' + ) + + self.assertEqual(expected, signature_string) + + def test_empty_query_parameters(self): + """Test signature string construction with no query parameters.""" + from cc_common.signature_auth import _build_signature_string + + event = { + 'httpMethod': 'GET', + 'path': '/v1/compacts/socw/jurisdictions/al/providers', + 'queryStringParameters': None, + 'headers': { + 'X-Timestamp': '2024-01-15T10:30:00Z', + 'X-Nonce': '550e8400-e29b-41d4-a716-446655440000', + 'X-Key-Id': 'test-key-001', + }, + } + + signature_string = _build_signature_string(event) + + expected = ( + 'GET\n' + '/v1/compacts/socw/jurisdictions/al/providers\n' + '\n' + '2024-01-15T10:30:00Z\n' + '550e8400-e29b-41d4-a716-446655440000\n' + 'test-key-001' + ) + + self.assertEqual(expected, signature_string) + + def test_url_encoded_query_parameters(self): + """Test that query parameters are properly URL-encoded in signature string.""" + from cc_common.signature_auth import _build_signature_string + + event = { + 'httpMethod': 'GET', + 'path': '/v1/compacts/socw/jurisdictions/al/providers/query', + 'queryStringParameters': { + 'search': 'test value with spaces', + 'filter': 'status=active&type=provider', + 'special': '!@#$%^&*()', + 'unicode': 'café résumé', + }, + 'headers': { + 'X-Timestamp': '2024-01-15T10:30:00Z', + 'X-Nonce': '550e8400-e29b-41d4-a716-446655440000', + 'X-Key-Id': 'test-key-001', + }, + } + + signature_string = _build_signature_string(event) + + # Verify that parameters are sorted alphabetically and URL-encoded + expected = ( + 'GET\n' + '/v1/compacts/socw/jurisdictions/al/providers/query\n' + 'filter=status%3Dactive%26type%3Dprovider&search=test%20value%20with%20spaces&special=%21%40%23%24%25%5E%26%2A%28%29&unicode=caf%C3%A9%20r%C3%A9sum%C3%A9\n' + '2024-01-15T10:30:00Z\n' + '550e8400-e29b-41d4-a716-446655440000\n' + 'test-key-001' + ) + + self.assertEqual(expected, signature_string) + + def test_case_insensitive_headers(self): + """Test that header extraction is case insensitive using CaseInsensitiveDict.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create a properly signed event first + event = self._create_signed_event() + + # Now create a new event with mixed case headers but keep the original signature + # This tests that CaseInsensitiveDict can handle different header cases + mixed_case_event = deepcopy(self.base_event) + mixed_case_event['headers'] = { + 'x-algorithm': event['headers']['X-Algorithm'], + 'X-Timestamp': event['headers']['X-Timestamp'], + 'x-nonce': event['headers']['X-Nonce'], + 'x-key-id': event['headers']['X-Key-Id'], + 'X-Signature': event['headers']['X-Signature'], + } + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + resp = lambda_handler(mixed_case_event, self.mock_context) + + # The decorator returns the raw function result, not an API Gateway response + self.assertEqual({'message': 'OK'}, resp) + + def _create_signed_event(self) -> dict: + """Create a properly signed event for testing.""" + # Create base event + event = deepcopy(self.base_event) + + # Generate current timestamp and nonce + timestamp = datetime.now(UTC).isoformat() + nonce = '550e8400-e29b-41d4-a716-446655440000' + key_id = 'test-key-001' + + # Import and use the sign_request function + from common_test.sign_request import sign_request + + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp, + nonce=nonce, + key_id=key_id, + private_key_pem=self.private_key_pem, + ) + + # Add signature headers to event + event['headers'].update(headers) + + return event + + def _create_signed_event_with_nonce(self, nonce: str) -> dict: + """Create a properly signed event with a specific nonce for testing.""" + # Create base event + event = deepcopy(self.base_event) + + # Generate current timestamp + timestamp = datetime.now(UTC).isoformat() + key_id = 'test-key-001' + + # Import and use the sign_request function + from common_test.sign_request import sign_request + + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp, + nonce=nonce, + key_id=key_id, + private_key_pem=self.private_key_pem, + ) + + # Add signature headers to event + event['headers'].update(headers) + + return event + + def test_nonce_reuse_prevention(self): + """Test that nonce reuse is prevented.""" + from botocore.exceptions import ClientError + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create a properly signed request + event = self._create_signed_event() + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table for the first call (successful nonce storage) + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + # First call should succeed (nonce doesn't exist) + mock_table.put_item.return_value = None + + # First request should succeed + resp = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK'}, resp) + + # Verify the nonce was stored + mock_table.put_item.assert_called_once() + call_args = mock_table.put_item.call_args + self.assertEqual('NONCE#socw#JURISDICTION#al', call_args[1]['Item']['pk']) + self.assertEqual(f'NONCE#{event["headers"]["X-Nonce"]}', call_args[1]['Item']['sk']) + + # Reset the mock for the second call + mock_table.put_item.reset_mock() + + # Mock the second call to simulate nonce already exists + error_response = { + 'Error': {'Code': 'ConditionalCheckFailedException', 'Message': 'The conditional request failed'} + } + mock_table.put_item.side_effect = ClientError(error_response, 'PutItem') + + # Second request with same nonce should fail + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Nonce has already been used', str(cm.exception)) + + def test_nonce_storage_failure(self): + """Test handling of nonce storage failures.""" + from botocore.exceptions import ClientError + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create a properly signed request + event = self._create_signed_event() + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table to simulate a different error + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + error_response = { + 'Error': {'Code': 'ProvisionedThroughputExceededException', 'Message': 'Rate exceeded'} + } + mock_table.put_item.side_effect = ClientError(error_response, 'PutItem') + + # Request should fail with nonce validation error + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Failed to validate nonce', str(cm.exception)) + + def test_nonce_format_validation_empty_nonce(self): + """Test that empty nonces are rejected.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with empty nonce + event = self._create_signed_event() + event['headers']['X-Nonce'] = '' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + # Empty nonce is caught by the missing headers check + self.assertIn('Missing required signature authentication headers', str(cm.exception)) + + def test_nonce_format_validation_too_long_nonce(self): + """Test that nonces longer than 256 characters are rejected.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with nonce that's too long + event = self._create_signed_event() + event['headers']['X-Nonce'] = 'a' * 257 # 257 characters + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Nonce cannot be longer than 256 characters', str(cm.exception)) + + def test_nonce_format_validation_invalid_characters(self): + """Test that nonces with invalid characters are rejected.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Test various invalid characters + invalid_nonces = [ + 'test@nonce', # @ symbol + 'test nonce', # space + 'test_nonce', # underscore + 'test.nonce', # period + 'test+nonce', # plus + 'test=nonce', # equals + 'test/nonce', # slash + 'test\\nonce', # backslash + 'test(nonce)', # parentheses + 'test[nonce]', # brackets + 'test{nonce}', # braces + 'test#nonce', # hash + 'test$nonce', # dollar + 'test%nonce', # percent + 'test^nonce', # caret + 'test&nonce', # ampersand + 'test*nonce', # asterisk + 'test|nonce', # pipe + 'test~nonce', # tilde + 'test`nonce', # backtick + 'test;nonce', # semicolon + 'test:nonce', # colon + 'test"nonce', # quote + "test'nonce", # single quote + 'testnonce', # greater than + 'test,nonce', # comma + 'test?nonce', # question mark + 'test!nonce', # exclamation + ] + + for invalid_nonce in invalid_nonces: + with self.subTest(nonce=invalid_nonce): + # Create event with invalid nonce + event = self._create_signed_event() + event['headers']['X-Nonce'] = invalid_nonce + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + with self.assertRaises(Exception) as cm: + lambda_handler(event, self.mock_context) + + self.assertIn('Nonce can only contain alphanumeric characters and hyphens', str(cm.exception)) + + def test_nonce_format_validation_valid_characters(self): + """Test that nonces with valid characters are accepted.""" + from cc_common.signature_auth import required_signature_auth + + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Test various valid nonces + valid_nonces = [ + 'test-nonce', # hyphen + 'test123', # numbers + 'TEST123', # uppercase + 'test123-nonce', # mixed alphanumeric with hyphen + 'a', # single character + 'a' * 256, # exactly 256 characters + '1234567890', # all numbers + 'abcdefghijklmnopqrstuvwxyz', # all lowercase + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', # all uppercase + 'a1b2c3-d4e5f6', # mixed with hyphens + ] + + for valid_nonce in valid_nonces: + with self.subTest(nonce=valid_nonce): + # Create event with valid nonce and recalculate signature + event = self._create_signed_event_with_nonce(valid_nonce) + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + # Should succeed + resp = lambda_handler(event, self.mock_context) + self.assertEqual({'message': 'OK'}, resp) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_signature_auth_integration.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_signature_auth_integration.py new file mode 100644 index 0000000000..2eb03a27b3 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_signature_auth_integration.py @@ -0,0 +1,236 @@ +# ruff: noqa: ARG001 unused-argument +import json +from copy import deepcopy +from datetime import UTC, datetime +from unittest.mock import patch + +from aws_lambda_powertools.utilities.typing import LambdaContext + +from tests import TstLambdas + + +class TestSignatureAuthIntegration(TstLambdas): + """Testing signature authentication integration with the existing api_handler decorator.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + + # Load test keys + with open('tests/resources/client_private_key.pem') as f: + self.private_key_pem = f.read() + + with open('tests/resources/client_public_key.pem') as f: + self.public_key_pem = f.read() + + # Load test event + with open('tests/resources/api-client-event.json') as f: + self.base_event = json.load(f) + + def test_signature_with_api_handler_success(self): + """Test successful signature authentication with api_handler decorator.""" + from cc_common.signature_auth import required_signature_auth + from cc_common.utils import api_handler + + @api_handler + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK', 'authenticated': True} + + # Create a properly signed request + event = self._create_signed_event() + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + + # Parse response body to verify content + body = json.loads(resp['body']) + self.assertEqual('OK', body['message']) + self.assertTrue(body['authenticated']) + + def test_signature_with_api_handler_unauthorized(self): + """Test signature authentication failure with api_handler decorator returns 401.""" + from cc_common.signature_auth import required_signature_auth + from cc_common.utils import api_handler + + @api_handler + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event without signature headers + event = deepcopy(self.base_event) + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(401, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + self.assertEqual('{"message": "Missing required X-Key-Id header"}', resp['body']) + + def test_signature_with_api_handler_invalid_request(self): + """Test signature validation failure with api_handler decorator returns 400.""" + from cc_common.signature_auth import required_signature_auth + from cc_common.utils import api_handler + + @api_handler + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with malformed timestamp + event = self._create_signed_event() + event['headers']['X-Timestamp'] = 'not-a-timestamp' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(401, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + self.assertEqual({'message': 'Invalid timestamp format'}, json.loads(resp['body'])) + + def test_signature_with_api_handler_invalid_signature(self): + """Test invalid signature with api_handler decorator returns 401.""" + from cc_common.signature_auth import required_signature_auth + from cc_common.utils import api_handler + + @api_handler + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event with invalid signature + event = self._create_signed_event() + event['headers']['X-Signature'] = 'invalid-signature' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(401, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + self.assertEqual({'message': 'Invalid request signature'}, json.loads(resp['body'])) + + def test_signature_with_api_handler_public_key_not_found(self): + """Test public key not found with api_handler decorator returns 401.""" + from cc_common.signature_auth import required_signature_auth + from cc_common.utils import api_handler + + @api_handler + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create a properly signed request + event = self._create_signed_event() + + # Mock DynamoDB to return None (key not found) + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = None + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(401, resp['statusCode']) + self.assertEqual('https://example.org', resp['headers']['Access-Control-Allow-Origin']) + self.assertEqual( + {'message': 'Public key not found for this compact/jurisdiction/key-id'}, json.loads(resp['body']) + ) + + def test_decorator_order_matters(self): + """Test that decorator order affects behavior (api_handler should be outermost).""" + from cc_common.signature_auth import required_signature_auth + from cc_common.utils import api_handler + + # This test demonstrates that api_handler should be the outermost decorator + # so it can properly handle exceptions from signature_auth_required + + @required_signature_auth + @api_handler + def lambda_handler_wrong_order(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create event without signature headers + event = deepcopy(self.base_event) + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # This should raise an exception directly instead of returning a proper API response + with self.assertRaises(Exception) as cm: + lambda_handler_wrong_order(event, self.mock_context) + + self.assertIn('Missing required X-Key-Id header', str(cm.exception)) + + def test_signature_with_api_handler_cors_handling(self): + """Test that CORS headers are properly handled with signature authentication.""" + from cc_common.signature_auth import required_signature_auth + from cc_common.utils import api_handler + + @api_handler + @required_signature_auth + def lambda_handler(event: dict, context: LambdaContext): + return {'message': 'OK'} + + # Create a properly signed request with localhost origin + event = self._create_signed_event() + event['headers']['origin'] = 'http://localhost:1234' + + # Mock DynamoDB to return the public key + with patch('cc_common.signature_auth._get_public_key_from_dynamodb') as mock_get_key: + mock_get_key.return_value = self.public_key_pem + + # Mock the rate limiting table for nonce storage + with patch('cc_common.config._Config.rate_limiting_table') as mock_table: + mock_table.put_item.return_value = None + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual('http://localhost:1234', resp['headers']['Access-Control-Allow-Origin']) + + def _create_signed_event(self) -> dict: + """Create a properly signed event for testing.""" + # Create base event + event = deepcopy(self.base_event) + + # Generate current timestamp and nonce + timestamp = datetime.now(UTC).isoformat().replace('+00:00', 'Z') + nonce = '550e8400-e29b-41d4-a716-446655440000' + + # Import the sign_request function + from common_test.sign_request import sign_request + + # Sign the request + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp, + nonce=nonce, + key_id='test-key-001', + private_key_pem=self.private_key_pem, + ) + + # Add signature headers to event + event['headers'].update(headers) + + return event diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_sqs_handler.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_sqs_handler.py new file mode 100644 index 0000000000..1758fc31db --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_sqs_handler.py @@ -0,0 +1,46 @@ +import json +from unittest.mock import Mock +from uuid import uuid4 + +from tests import TstLambdas + + +class TestSQSHandler(TstLambdas): + def test_happy_path(self): + from cc_common.utils import sqs_handler + + @sqs_handler + def message_handler(message: dict): # noqa: ARG001 unused-argument + return + + event = {'Records': [{'messageId': str(uuid4()), 'body': json.dumps({'foo': 'bar'})}]} + + resp = message_handler(event, self.mock_context) + + self.assertEqual({'batchItemFailures': []}, resp) + + def test_partial_failure(self): + from cc_common.utils import sqs_handler + + mock_partial_failures = Mock( + # Responses when called - three successes, two failures + side_effect=[None, RuntimeError('Oh no!'), None, None, RuntimeError('Not again!')], + ) + + @sqs_handler + def message_handler(message: dict): # noqa: ARG001 unused-argument + return mock_partial_failures() + + event = { + 'Records': [ + {'messageId': '1', 'body': json.dumps({'foo': 'bar'})}, + {'messageId': '2', 'body': json.dumps({'foo': 'bar'})}, + {'messageId': '3', 'body': json.dumps({'foo': 'bar'})}, + {'messageId': '4', 'body': json.dumps({'foo': 'bar'})}, + {'messageId': '5', 'body': json.dumps({'foo': 'bar'})}, + ], + } + + resp = message_handler(event, self.mock_context) + + self.assertEqual({'batchItemFailures': [{'itemIdentifier': '2'}, {'itemIdentifier': '5'}]}, resp) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_utils.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_utils.py new file mode 100644 index 0000000000..f2d8b17f77 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_utils.py @@ -0,0 +1,41 @@ +import json +from unittest.mock import patch + +from tests import TstLambdas + + +class TestUtils(TstLambdas): + @patch('cc_common.config._Config.license_preprocessing_queue') + def test_send_licenses_to_preprocessing_queue_handles_failures(self, mock_preprocessing_queue): + from cc_common.utils import send_licenses_to_preprocessing_queue + + def mock_send_messages(Entries): # noqa N803 AWS defines the kwargs + failed_entries = [ + {'Id': entry['Id'], 'SenderFault': False, 'Code': '1234', 'Message': 'Something went wrong'} + for entry in Entries + ] + return {'Successful': [], 'Failed': failed_entries} + + # we have to mock the SQS queue to force a failure scenario + mock_preprocessing_queue.send_messages.side_effect = mock_send_messages + + with open('tests/resources/api/license-post.json') as f: + license_record = json.load(f) + license_record['compact'] = 'socw' + license_record['jurisdiction'] = 'oh' + + # generate 5 records and ensure the system processes all the failures + licenses_data = [] + for i in range(5): + with open('tests/resources/api/license-post.json') as f: + license_record = json.load(f) + license_record['compact'] = 'socw' + license_record['jurisdiction'] = 'oh' + license_record['licenseNumber'] = f'licenseNumber-{i}' + licenses_data.append(license_record) + + failed_license_numbers = send_licenses_to_preprocessing_queue( + licenses_data=licenses_data, event_time='2024-12-04T08:08:08+00:00' + ) + + self.assertEqual([f'licenseNumber-{i}' for i in range(5)], failed_license_numbers) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_utils_password_hashing.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_utils_password_hashing.py new file mode 100644 index 0000000000..e5597afab9 --- /dev/null +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_utils_password_hashing.py @@ -0,0 +1,62 @@ +import unittest + + +class TestPasswordHashing(unittest.TestCase): + """Test cases for password hashing utilities.""" + + def test_hash_password_returns_different_hash_each_time(self): + """Test that hashing the same password twice produces different hashes (due to salt).""" + from cc_common.utils import hash_password + + password = 'test_recovery_token_123' # noqa: S105 mock password + hash1 = hash_password(password) + hash2 = hash_password(password) + + self.assertNotEqual(hash1, hash2) + self.assertTrue(hash1.startswith('$argon2id$')) + self.assertTrue(hash2.startswith('$argon2id$')) + + def test_verify_password_with_correct_password(self): + """Test that verify_password returns True for correct password.""" + from cc_common.utils import hash_password, verify_password + + password = 'test_recovery_token_456' # noqa: S105 mock password + hashed = hash_password(password) + + result = verify_password(hashed, password) + self.assertTrue(result) + + def test_verify_password_with_incorrect_password(self): + """Test that verify_password returns False for incorrect password.""" + from cc_common.utils import hash_password, verify_password + + password = 'test_recovery_token_789' # noqa: S105 mock password + wrong_password = 'wrong_password' # noqa: S105 mock password + hashed = hash_password(password) + + result = verify_password(hashed, wrong_password) + self.assertFalse(result) + + def test_verify_password_with_empty_password(self): + """Test that verify_password returns False for empty password.""" + from cc_common.utils import hash_password, verify_password + + password = 'test_recovery_token_000' # noqa: S105 mock password + hashed = hash_password(password) + + result = verify_password(hashed, '') + self.assertFalse(result) + + def test_hash_password_handles_special_characters(self): + """Test that hashing works with special characters.""" + from cc_common.utils import hash_password, verify_password + + password = 'test_token_!@#$%^&*()_+-=[]{}|;:,.<>?' # noqa: S105 mock password + hashed = hash_password(password) + + result = verify_password(hashed, password) + self.assertTrue(result) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/social-work-app/lambdas/python/compact-configuration/handlers/__init__.py b/backend/social-work-app/lambdas/python/compact-configuration/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/compact-configuration/handlers/compact_configuration.py b/backend/social-work-app/lambdas/python/compact-configuration/handlers/compact_configuration.py new file mode 100644 index 0000000000..71b380e992 --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/handlers/compact_configuration.py @@ -0,0 +1,429 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.compact_configuration_utils import CompactConfigUtility +from cc_common.data_model.schema.common import CCPermissionsAction +from cc_common.data_model.schema.compact import CompactConfigurationData +from cc_common.data_model.schema.compact.api import ( + CompactConfigurationResponseSchema, + PutCompactConfigurationRequestSchema, +) +from cc_common.data_model.schema.jurisdiction import JurisdictionConfigurationData +from cc_common.data_model.schema.jurisdiction.api import ( + CompactJurisdictionConfigurationResponseSchema, + CompactJurisdictionsPublicResponseSchema, + CompactJurisdictionsStaffUsersResponseSchema, + PutCompactJurisdictionConfigurationRequestSchema, +) +from cc_common.exceptions import CCInvalidRequestException, CCNotFoundException +from cc_common.utils import api_handler, authorize_compact_level_only_action, authorize_state_level_only_action +from marshmallow import ValidationError + + +@api_handler +def compact_configuration_api_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Handle attestation requests.""" + # handle GET compact jurisdictions method at path /v1/compacts/{compact}/jurisdictions + if event['httpMethod'] == 'GET' and event['resource'] == '/v1/compacts/{compact}/jurisdictions': + return _get_staff_users_compact_jurisdictions(event, context) + if event['httpMethod'] == 'GET' and event['resource'] == '/v1/public/compacts/{compact}/jurisdictions': + return _get_public_compact_jurisdictions(event, context) + if event['httpMethod'] == 'GET' and event['resource'] == '/v1/public/jurisdictions/live': + return _get_live_public_compact_jurisdictions(event, context) + if event['httpMethod'] == 'GET' and event['resource'] == '/v1/compacts/{compact}': + return _get_staff_users_compact_configuration(event, context) + if event['httpMethod'] == 'PUT' and event['resource'] == '/v1/compacts/{compact}': + return _put_compact_configuration(event, context) + if event['httpMethod'] == 'GET' and event['resource'] == '/v1/compacts/{compact}/jurisdictions/{jurisdiction}': + return _get_staff_users_jurisdiction_configuration(event, context) + if event['httpMethod'] == 'PUT' and event['resource'] == '/v1/compacts/{compact}/jurisdictions/{jurisdiction}': + return _put_jurisdiction_configuration(event, context) + + raise CCInvalidRequestException('Invalid HTTP method') + + +def _validate_compact(compact: str) -> None: + """ + Validate that the provided compact exists in the configured list of compacts. + + :param compact: The compact abbreviation to validate + :raises CCInvalidRequestException: If the compact does not exist + """ + if compact.lower() not in config.compacts: + logger.info('Invalid compact abbreviation', compact=compact) + raise CCInvalidRequestException(f'Invalid compact abbreviation: {compact}') + + +def _validate_jurisdiction(jurisdiction: str) -> None: + """ + Validate that the provided jurisdiction exists in the configured list of jurisdictions. + + :param jurisdiction: The jurisdiction postal abbreviation to validate + :raises CCInvalidRequestException: If the jurisdiction does not exist + """ + if jurisdiction.lower() not in config.jurisdictions: + logger.info('Invalid jurisdiction postal abbreviation', jurisdiction=jurisdiction) + raise CCInvalidRequestException(f'Invalid jurisdiction postal abbreviation: {jurisdiction}') + + +def _get_staff_users_compact_jurisdictions(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint for staff users to get the current active jurisdictions for a compact. + + Currently, this returns the same data as the public endpoint, but this will likely change in the future as admins + need more information about configured jurisdictions. + + :param event: API Gateway event + :param context: Lambda context + :return: The latest version of the attestation record + """ + compact = event['pathParameters']['compact'] + + # Validate the compact + _validate_compact(compact) + + logger.info('Getting active jurisdictions for compact', compact=compact) + + try: + compact_jurisdictions = config.compact_configuration_client.get_active_compact_jurisdictions(compact=compact) + except CCNotFoundException: + logger.info('no member jurisdictions found for provided compact. Returning empty list', compact=compact) + return [] + + return CompactJurisdictionsStaffUsersResponseSchema().load(compact_jurisdictions, many=True) + + +def _get_public_compact_jurisdictions(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Public endpoint to get the current active jurisdictions for a compact. + + Given the public nature of this endpoint, only public information about compact jurisdictions should be returned + from here. + + :param event: API Gateway event + :param context: Lambda context + :return: The latest version of the attestation record + """ + compact = event['pathParameters']['compact'] + + # Validate the compact + _validate_compact(compact) + + logger.info('Getting active jurisdictions for compact', compact=compact) + + try: + compact_jurisdictions = config.compact_configuration_client.get_active_compact_jurisdictions(compact=compact) + except CCNotFoundException: + logger.info('no member jurisdictions found for provided compact. Returning empty list', compact=compact) + return [] + + return CompactJurisdictionsPublicResponseSchema().load(compact_jurisdictions, many=True) + + +def _get_live_public_compact_jurisdictions(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint to get all live jurisdictions, optionally filtered by compact. + + :param event: API Gateway event with optional query parameter 'compact' + :param context: Lambda context + :return: Dictionary with compact abbreviations as keys and lists of live jurisdiction abbreviations as values + """ + query_params = event.get('queryStringParameters') or {} + compact_filter = query_params.get('compact') + + # Determine which compacts to query + compacts_to_query = [] + if compact_filter: + # Validate the compact + if compact_filter.lower() in config.compacts: + compacts_to_query = [compact_filter.lower()] + logger.info('Getting live jurisdictions for specific compact', compact=compact_filter) + else: + logger.info('Invalid compact provided', compact=compact_filter) + raise CCInvalidRequestException(f'Invalid request query param: {compact_filter}') + else: + logger.info('Getting live jurisdictions for all compacts') + compacts_to_query = config.compacts + + # Build result dictionary + result = {} + for compact in compacts_to_query: + live_jurisdictions = config.compact_configuration_client.get_live_compact_jurisdictions(compact=compact) + result[compact] = live_jurisdictions + + logger.info('Returning live jurisdictions', compacts_count=len(result)) + return result + + +@authorize_compact_level_only_action(action=CCPermissionsAction.ADMIN) +def _get_staff_users_compact_configuration(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint for staff users to get the compact configuration. + + :param event: API Gateway event + :param context: Lambda context + :return: The compact configuration + """ + compact = event['pathParameters']['compact'] + + # Validate the compact + _validate_compact(compact) + + logger.info('Getting compact configuration', compact=compact) + + try: + compact_config = config.compact_configuration_client.get_compact_configuration(compact=compact) + return CompactConfigurationResponseSchema().load(compact_config.to_dict()) + except CCNotFoundException: + # in the case of a not found exception, we want to return an empty compact configuration with + # null values + compact_name = CompactConfigUtility.get_compact_name(compact) + + # Create a new empty configuration with the correct field names + empty_config = CompactConfigurationData.create_new( + { + 'compactAbbr': compact, + 'compactName': compact_name, + 'licenseeRegistrationEnabled': False, + 'compactOperationsTeamEmails': [], + 'compactAdverseActionsNotificationEmails': [], + 'configuredStates': [], + } + ).to_dict() + + return CompactConfigurationResponseSchema().load(empty_config) + + +@authorize_compact_level_only_action(action=CCPermissionsAction.ADMIN) +def _put_compact_configuration(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint for staff users to upsert the compact configuration. + + :param event: API Gateway event + :param context: Lambda context + :return: The updated compact configuration + """ + compact = event['pathParameters']['compact'] + submitting_user_id = event['requestContext']['authorizer']['claims']['sub'] + + logger.info('Updating compact configuration', compact=compact, submitting_user_id=submitting_user_id) + + try: + # Validate the request body + validated_data = PutCompactConfigurationRequestSchema().loads(event['body']) + + # Add compact abbreviation and name from path parameter + validated_data['compactAbbr'] = compact + compact_name = CompactConfigUtility.get_compact_name(compact) + if not compact_name: + raise CCInvalidRequestException(f'Invalid compact abbreviation: {compact}') + validated_data['compactName'] = compact_name + + # Check if licenseeRegistrationEnabled is being changed from true to false + try: + existing_config = config.compact_configuration_client.get_compact_configuration(compact=compact) + if existing_config.licenseeRegistrationEnabled and not validated_data.get('licenseeRegistrationEnabled'): + logger.info( + 'attempt to disable licensee registration after it was enabled.', + compact=compact, + submitting_user_id=submitting_user_id, + ) + raise CCInvalidRequestException('Once licensee registration has been enabled, it cannot be disabled.') + + _validate_configured_states_transitions( + existing_config.configuredStates, validated_data['configuredStates'], compact, submitting_user_id + ) + except CCNotFoundException: + # No existing configuration, so this is the first time setting this field + logger.info('No existing configuration, so this is the first time setting this field', compact=compact) + + compact_configuration = CompactConfigurationData.create_new(validated_data) + # Save the compact configuration + config.compact_configuration_client.save_compact_configuration(compact_configuration) + + return {'message': 'ok'} + except ValidationError as e: + logger.info('Invalid compact configuration', compact=compact, error=e) + raise CCInvalidRequestException('Invalid compact configuration: ' + str(e)) from e + + +def _validate_configured_states_transitions( + existing_states: list[dict], new_states: list[dict], compact: str, submitting_user_id: str +) -> None: + """ + Validate that configuredStates transitions are allowed. + + Rules: + 1. States cannot be manually added or removed - only managed internally by the API + 2. Only isLive status can be modified (false -> true only) + + :param existing_states: Current configuredStates from the database + :param new_states: New configuredStates from the request + :param compact: The compact abbreviation for logging + :param submitting_user_id: The user making the request for logging + :raises CCInvalidRequestException: If validation fails + """ + + # Create lookup dictionaries for easier comparison + existing_states_by_postal = {state['postalAbbreviation']: state for state in existing_states} + new_states_by_postal = {state['postalAbbreviation']: state for state in new_states} + + # Check for removed states + removed_states = set(existing_states_by_postal.keys()) - set(new_states_by_postal.keys()) + if removed_states: + logger.warning( + 'Attempt to remove states from configuredStates', + compact=compact, + submitting_user_id=submitting_user_id, + removed_states=list(removed_states), + ) + raise CCInvalidRequestException( + f'States cannot be removed from configuredStates. Attempted to remove: {", ".join(sorted(removed_states))}' + ) + + # Check for added states + added_states = set(new_states_by_postal.keys()) - set(existing_states_by_postal.keys()) + if added_states: + logger.warning( + 'Attempt to add states to configuredStates', + compact=compact, + submitting_user_id=submitting_user_id, + added_states=list(added_states), + ) + raise CCInvalidRequestException( + f'States cannot be manually added to configuredStates. Attempted to add: {", ".join(sorted(added_states))}' + ) + + # Check for isLive downgrades (true -> false) + for postal_abbr, existing_state in existing_states_by_postal.items(): + if postal_abbr in new_states_by_postal: + new_state = new_states_by_postal[postal_abbr] + if existing_state['isLive'] and not new_state['isLive']: + logger.warning( + 'Attempt to change isLive from true to false', + compact=compact, + submitting_user_id=submitting_user_id, + state=postal_abbr, + existing_is_live=existing_state['isLive'], + new_is_live=new_state['isLive'], + ) + raise CCInvalidRequestException( + f'State "{postal_abbr}" cannot be changed from live to non-live status. ' + f'Once a state is live (isLive: true), it cannot be reverted to non-live (isLive: false).' + ) + + +@authorize_state_level_only_action(action=CCPermissionsAction.ADMIN) +def _get_staff_users_jurisdiction_configuration(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint for staff users to get the jurisdiction configuration. + + :param event: API Gateway event + :param context: Lambda context + :return: The jurisdiction configuration + """ + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + + # Validate the compact and jurisdiction + _validate_compact(compact) + _validate_jurisdiction(jurisdiction) + + logger.info('Getting jurisdiction configuration', compact=compact, jurisdiction=jurisdiction) + + try: + jurisdiction_config = config.compact_configuration_client.get_jurisdiction_configuration( + compact=compact, jurisdiction=jurisdiction + ) + return CompactJurisdictionConfigurationResponseSchema().load(jurisdiction_config.to_dict()) + except CCNotFoundException: + logger.info( + 'Jurisdiction configuration not found. Returning empty jurisdiction configuration.', + compact=compact, + jurisdiction=jurisdiction, + ) + jurisdiction_name = CompactConfigUtility.get_jurisdiction_name(jurisdiction) + + # Create a new empty configuration with the correct field names + empty_config = JurisdictionConfigurationData.create_new( + { + 'compact': compact, + 'jurisdictionName': jurisdiction_name, + 'postalAbbreviation': jurisdiction, + 'jurisprudenceRequirements': { + 'required': False, + 'linkToDocumentation': None, + }, + 'jurisdictionOperationsTeamEmails': [], + 'jurisdictionAdverseActionsNotificationEmails': [], + 'licenseeRegistrationEnabled': False, + } + ).to_dict() + + return CompactJurisdictionConfigurationResponseSchema().load(empty_config) + + +@authorize_state_level_only_action(action=CCPermissionsAction.ADMIN) +def _put_jurisdiction_configuration(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint for staff users to upsert the jurisdiction configuration. + + :param event: API Gateway event + :param context: Lambda context + :return: A success message + """ + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + submitting_user_id = event['requestContext']['authorizer']['claims']['sub'] + + logger.info( + 'Updating jurisdiction configuration', + compact=compact, + jurisdiction=jurisdiction, + submitting_user_id=submitting_user_id, + ) + + # Validate the request body + try: + validated_data = PutCompactJurisdictionConfigurationRequestSchema().loads(event['body']) + + # Add compact and jurisdiction details from path parameters + validated_data['compact'] = compact + validated_data['postalAbbreviation'] = jurisdiction + + # Set the jurisdiction name based on the postal abbreviation + jurisdiction_name = CompactConfigUtility.get_jurisdiction_name(jurisdiction) + if not jurisdiction_name: + raise CCInvalidRequestException(f'Invalid jurisdiction postal abbreviation: {jurisdiction}') + validated_data['jurisdictionName'] = jurisdiction_name + + # Check if licenseeRegistrationEnabled is being changed from true to false + if validated_data.get('licenseeRegistrationEnabled') is False: + try: + existing_config = config.compact_configuration_client.get_jurisdiction_configuration( + compact=compact, jurisdiction=jurisdiction + ) + if existing_config.licenseeRegistrationEnabled is True: + logger.info( + 'attempt to disable licensee registration after it was enabled.', + compact=compact, + submitting_user_id=submitting_user_id, + ) + raise CCInvalidRequestException( + 'Once licensee registration has been enabled, it cannot be disabled.' + ) + except CCNotFoundException: + # No existing configuration, so this is the first time setting this field + logger.info( + 'No existing configuration, so this is the first time setting this field', + compact=compact, + jurisdiction=jurisdiction, + ) + + jurisdiction_data = JurisdictionConfigurationData.create_new(validated_data) + # Save the jurisdiction configuration + config.compact_configuration_client.save_jurisdiction_configuration(jurisdiction_data) + except ValidationError as e: + logger.info('Invalid jurisdiction configuration', compact=compact, jurisdiction=jurisdiction, error=e) + raise CCInvalidRequestException('Invalid jurisdiction configuration: ' + str(e)) from e + + return {'message': 'ok'} diff --git a/backend/social-work-app/lambdas/python/compact-configuration/requirements-dev.in b/backend/social-work-app/lambdas/python/compact-configuration/requirements-dev.in new file mode 100644 index 0000000000..5a61b7b0d2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb, s3]>=5.0.12, <6 diff --git a/backend/social-work-app/lambdas/python/compact-configuration/requirements-dev.txt b/backend/social-work-app/lambdas/python/compact-configuration/requirements-dev.txt new file mode 100644 index 0000000000..734805476f --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/requirements-dev.txt @@ -0,0 +1,64 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/compact-configuration/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/compact-configuration/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/compact-configuration/requirements.in b/backend/social-work-app/lambdas/python/compact-configuration/requirements.in new file mode 100644 index 0000000000..68b7c56e7c --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common requirements.in file diff --git a/backend/social-work-app/lambdas/python/compact-configuration/requirements.txt b/backend/social-work-app/lambdas/python/compact-configuration/requirements.txt new file mode 100644 index 0000000000..79d227dbe3 --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/compact-configuration/requirements.in +# diff --git a/backend/social-work-app/lambdas/python/compact-configuration/tests/__init__.py b/backend/social-work-app/lambdas/python/compact-configuration/tests/__init__.py new file mode 100644 index 0000000000..cf35946057 --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/tests/__init__.py @@ -0,0 +1,38 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'true', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': '["ne", "oh", "ky"]', + 'ENVIRONMENT_NAME': 'test', + 'LICENSE_TYPES': json.dumps( + { + 'socw': [ + {'name': 'cosmetologist', 'abbreviation': 'cos'}, + {'name': 'esthetician', 'abbreviation': 'esth'}, + ], + }, + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + from cc_common import config + + cls.config = config._Config() # noqa: SLF001 protected-access + config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/compact-configuration/tests/function/__init__.py b/backend/social-work-app/lambdas/python/compact-configuration/tests/function/__init__.py new file mode 100644 index 0000000000..dd1a5bff74 --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/tests/function/__init__.py @@ -0,0 +1,45 @@ +import logging +import os + +import boto3 +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + + self.build_resources() + + self.addCleanup(self.delete_resources) + # this must be imported here as the import relies on env variables being set by the parent class + from common_test.test_data_generator import TestDataGenerator + + # Helper class used to generate test objects + self.test_data_generator = TestDataGenerator() + + def build_resources(self): + self.create_compact_configuration_table() + + def create_compact_configuration_table(self): + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def delete_resources(self): + self._compact_configuration_table.delete() diff --git a/backend/social-work-app/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py b/backend/social-work-app/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py new file mode 100644 index 0000000000..9559280b6d --- /dev/null +++ b/backend/social-work-app/lambdas/python/compact-configuration/tests/function/test_compact_configuration.py @@ -0,0 +1,937 @@ +# ruff: noqa: E501 line-too-long +import json +from datetime import datetime +from unittest.mock import patch + +from cc_common.exceptions import CCInternalException +from common_test.test_constants import DEFAULT_DATE_OF_UPDATE_TIMESTAMP +from moto import mock_aws + +from . import TstFunction + +STAFF_USERS_COMPACT_JURISDICTION_ENDPOINT_RESOURCE = '/v1/compacts/{compact}/jurisdictions' +PUBLIC_COMPACT_JURISDICTION_ENDPOINT_RESOURCE = '/v1/public/compacts/{compact}/jurisdictions' +LIVE_JURISDICTIONS_ENDPOINT_RESOURCE = '/v1/public/jurisdictions/live' + +COMPACT_CONFIGURATION_ENDPOINT_RESOURCE = '/v1/compacts/{compact}' +JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE = '/v1/compacts/{compact}/jurisdictions/{jurisdiction}' + + +def generate_test_event(method: str, resource: str, scopes: str = None) -> dict: + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + event['httpMethod'] = method + event['resource'] = resource + event['pathParameters'] = { + 'compact': 'socw', + } + + if scopes: + event['requestContext']['authorizer']['claims']['scope'] = scopes + + return event + + +def load_compact_active_member_jurisdictions(postal_abbreviations: list[str], compact: str = 'socw'): + """Load active member jurisdictions using the TestDataGenerator.""" + from common_test.test_data_generator import TestDataGenerator + + TestDataGenerator.put_compact_active_member_jurisdictions( + compact=compact, postal_abbreviations=postal_abbreviations + ) + + +@mock_aws +class TestGetStaffUsersCompactJurisdictions(TstFunction): + """Test suite for get compact jurisdiction endpoints.""" + + def test_get_compact_jurisdictions_returns_invalid_exception_if_invalid_http_method(self): + """Test getting an empty list if no jurisdictions configured.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PATCH', STAFF_USERS_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Invalid HTTP method'}, + response_body, + ) + + def test_get_compact_jurisdictions_returns_invalid_exception_if_invalid_compact(self): + """Test getting an error if invalid compact is provided.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', STAFF_USERS_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + event['pathParameters']['compact'] = 'invalid_compact' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Invalid compact abbreviation: invalid_compact'}, + response_body, + ) + + def test_get_compact_jurisdictions_returns_empty_list_if_no_active_jurisdictions(self): + """Test getting an empty list if no jurisdictions configured.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', STAFF_USERS_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + [], + response_body, + ) + + def test_get_compact_jurisdictions_returns_list_of_configured_jurisdictions(self): + """Test getting list of jurisdictions configured for a compact.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Load jurisdictions and active member jurisdictions + load_compact_active_member_jurisdictions(postal_abbreviations=['ky', 'oh']) + + event = generate_test_event('GET', STAFF_USERS_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Sort to ensure consistent order for comparison + sorted_response = sorted(response_body, key=lambda x: x['postalAbbreviation']) + + self.assertEqual( + [ + {'compact': 'socw', 'jurisdictionName': 'Kentucky', 'postalAbbreviation': 'ky'}, + {'compact': 'socw', 'jurisdictionName': 'Ohio', 'postalAbbreviation': 'oh'}, + ], + sorted_response, + ) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestGetPublicCompactJurisdictions(TstFunction): + """Test suite for get compact jurisdiction endpoints.""" + + def test_get_compact_jurisdictions_returns_invalid_exception_if_invalid_http_method(self): + """Test getting an empty list if no jurisdictions configured.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PATCH', PUBLIC_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Invalid HTTP method'}, + response_body, + ) + + def test_get_compact_jurisdictions_returns_invalid_exception_if_invalid_compact(self): + """Test getting an error if invalid compact is provided.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', PUBLIC_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + event['pathParameters']['compact'] = 'invalid_compact' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Invalid compact abbreviation: invalid_compact'}, + response_body, + ) + + def test_get_compact_jurisdictions_returns_empty_list_if_no_active_jurisdictions(self): + """Test getting an empty list if no jurisdictions configured.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', PUBLIC_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + [], + response_body, + ) + + def test_get_compact_jurisdictions_returns_list_of_configured_jurisdictions(self): + """Test getting list of jurisdictions configured for a compact.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Load jurisdictions and active member jurisdictions + load_compact_active_member_jurisdictions(postal_abbreviations=['ky', 'oh']) + + event = generate_test_event('GET', PUBLIC_COMPACT_JURISDICTION_ENDPOINT_RESOURCE) + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Sort to ensure consistent order for comparison + sorted_response = sorted(response_body, key=lambda x: x['postalAbbreviation']) + + self.assertEqual( + [ + {'compact': 'socw', 'jurisdictionName': 'Kentucky', 'postalAbbreviation': 'ky'}, + {'compact': 'socw', 'jurisdictionName': 'Ohio', 'postalAbbreviation': 'oh'}, + ], + sorted_response, + ) + + def test_get_public_live_compact_jurisdictions_returns_list_of_all_live_jurisdictions(self): + """Test getting list of live jurisdictions across all compacts when no query param provided""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create compact configurations with some jurisdictions marked as live + # socw compact with some live jurisdictions + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'socw', + 'configuredStates': [ + {'postalAbbreviation': 'ky', 'isLive': True}, + {'postalAbbreviation': 'oh', 'isLive': True}, + {'postalAbbreviation': 'ne', 'isLive': False}, + ], + }, + ) + + # Create event without query params + event = generate_test_event('GET', LIVE_JURISDICTIONS_ENDPOINT_RESOURCE) + event['queryStringParameters'] = None + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Should return all compacts with their live jurisdictions + self.assertIn('socw', response_body) + + # Verify the live jurisdictions for each compact + self.assertCountEqual(['oh', 'ky'], response_body['socw']) + + def test_get_public_live_compact_jurisdictions_returns_list_of_live_jurisdictions_in_compact(self): + """Test getting list of live jurisdictions for compact designated through query param""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create compact configurations + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'socw', + 'configuredStates': [ + {'postalAbbreviation': 'ky', 'isLive': True}, + {'postalAbbreviation': 'oh', 'isLive': True}, + {'postalAbbreviation': 'ne', 'isLive': False}, + ], + }, + ) + + # Create event with compact query param + event = generate_test_event('GET', LIVE_JURISDICTIONS_ENDPOINT_RESOURCE) + event['queryStringParameters'] = {'compact': 'socw'} + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Should only return the specified compact + self.assertIn('socw', response_body) + + # Verify the live jurisdictions + self.assertCountEqual(['ky', 'oh'], response_body['socw']) + + def test_get_public_live_compact_jurisdictions_returns_400_if_bad_compact_param(self): + """Test getting list of live jurisdictions returns 400 when invalid query param provided""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create compact configurations + self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={ + 'compactAbbr': 'socw', + 'configuredStates': [ + {'postalAbbreviation': 'ky', 'isLive': True}, + ], + }, + ) + + # Create event with invalid compact query param + event = generate_test_event('GET', LIVE_JURISDICTIONS_ENDPOINT_RESOURCE) + event['queryStringParameters'] = {'compact': 'invalid_compact'} + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Verify the error message + self.assertEqual({'message': 'Invalid request query param: invalid_compact'}, response_body) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestStaffUsersCompactConfiguration(TstFunction): + """Test suite for managing compact configurations.""" + + def _when_testing_get_compact_configuration_with_existing_compact_configuration(self): + compact_config = self.test_data_generator.put_default_compact_configuration_in_configuration_table() + event = generate_test_event( + 'GET', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE, scopes=f'{compact_config.compactAbbr}/admin' + ) + event['pathParameters']['compact'] = compact_config['compactAbbr'] + return event, compact_config + + def _when_testing_put_compact_configuration_with_existing_configuration(self): + from cc_common.utils import ResponseEncoder + + compact_config = self.test_data_generator.put_default_compact_configuration_in_configuration_table() + + event = generate_test_event('PUT', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE) + event['pathParameters']['compact'] = compact_config.compactAbbr + # add compact admin scope to the event + event['requestContext']['authorizer']['claims']['scope'] = f'{compact_config.compactAbbr}/admin' + event['requestContext']['authorizer']['claims']['sub'] = 'some-admin-id' + + # we only allow the following values in the body + event['body'] = json.dumps( + { + 'licenseeRegistrationEnabled': compact_config.licenseeRegistrationEnabled, + 'compactOperationsTeamEmails': compact_config.compactOperationsTeamEmails, + 'compactAdverseActionsNotificationEmails': compact_config.compactAdverseActionsNotificationEmails, + 'configuredStates': compact_config.configuredStates, + }, + cls=ResponseEncoder, + ) + return event, compact_config + + def _when_testing_put_compact_configuration(self): + from cc_common.utils import ResponseEncoder + + compact_config = self.test_data_generator.generate_default_compact_configuration( + value_overrides={'licenseeRegistrationEnabled': False} + ) + event = generate_test_event('PUT', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE) + event['pathParameters']['compact'] = compact_config.compactAbbr + # add compact admin scope to the event + event['requestContext']['authorizer']['claims']['scope'] = f'{compact_config.compactAbbr}/admin' + event['requestContext']['authorizer']['claims']['sub'] = 'some-admin-id' + + # we only allow the following values in the body + event['body'] = json.dumps( + { + 'licenseeRegistrationEnabled': compact_config.licenseeRegistrationEnabled, + 'compactOperationsTeamEmails': compact_config.compactOperationsTeamEmails, + 'compactAdverseActionsNotificationEmails': compact_config.compactAdverseActionsNotificationEmails, + 'configuredStates': compact_config.configuredStates, + }, + cls=ResponseEncoder, + ) + return event, compact_config + + def test_get_compact_configuration_returns_invalid_exception_if_invalid_http_method(self): + """Test getting a compact configuration returns an invalid exception if the HTTP method is invalid.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PATCH', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE, scopes='socw/admin') + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Invalid HTTP method'}, + response_body, + ) + + def test_get_compact_configuration_returns_unauthorized_exception_if_invalid_compact(self): + """Test getting an error if invalid compact is provided.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE, scopes='socw/admin') + event['pathParameters']['compact'] = 'invalid_compact' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + def test_get_compact_configuration_returns_empty_compact_configuration_if_no_configuration_exists(self): + """Test getting a compact configuration returns a compact configuration.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE, scopes='socw/admin') + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + { + 'compactAbbr': 'socw', + 'compactName': 'Social Work', + 'compactOperationsTeamEmails': [], + 'compactAdverseActionsNotificationEmails': [], + 'licenseeRegistrationEnabled': False, + 'configuredStates': [], + }, + response_body, + ) + + def test_put_compact_configuration_rejects_invalid_compact_with_auth_error(self): + """Test putting a compact configuration rejects an invalid compact abbreviation.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PUT', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE) + event['pathParameters']['compact'] = 'foo' + # add compact admin scope to the event + event['requestContext']['authorizer']['claims']['scopes'] = 'socw/admin' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode']) + self.assertIn('Access denied', json.loads(response['body'])['message']) + + def test_put_compact_configuration_rejects_state_admin_with_auth_error(self): + """Test putting a compact configuration rejects an invalid compact abbreviation.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PUT', COMPACT_CONFIGURATION_ENDPOINT_RESOURCE) + event['pathParameters']['compact'] = 'socw' + # add state admin scope to the event, but not compact admin + event['requestContext']['authorizer']['scopes'] = 'oh/socw.admin' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode']) + self.assertIn('Access denied', json.loads(response['body'])['message']) + + def test_put_compact_configuration_stores_new_compact_configuration(self): + """Test putting a compact configuration stores the compact configuration.""" + from cc_common.data_model.schema.compact import CompactConfigurationData + from handlers.compact_configuration import compact_configuration_api_handler + + event, compact_config = self._when_testing_put_compact_configuration() + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # load the record from the configuration table + serialized_compact_config = compact_config.serialize_to_database_record() + response = self.config.compact_configuration_table.get_item( + Key={'pk': serialized_compact_config['pk'], 'sk': serialized_compact_config['sk']} + ) + + stored_compact_data = CompactConfigurationData.from_database_record(response['Item']) + + self.assertEqual(compact_config.to_dict(), stored_compact_data.to_dict()) + + def test_put_compact_configuration_rejects_disabling_licensee_registration(self): + """Test that a compact configuration update is rejected if trying to disable licensee registration after enabling it.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a compact configuration with licenseeRegistrationEnabled=True + event, _ = self._when_testing_put_compact_configuration_with_existing_configuration() + + # Now attempt to update with licenseeRegistrationEnabled=False + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = False + event['body'] = json.dumps(body) + + # Should be rejected with a 400 error + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('Once licensee registration has been enabled, it cannot be disabled', response_body['message']) + + def test_put_compact_configuration_rejects_removing_configured_states(self): + """Test that removing states from configuredStates is rejected.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a compact configuration with some configured states + event, original_config = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': False}, + {'postalAbbreviation': 'oh', 'isLive': True}, + ] + event['body'] = json.dumps(body) + + # Submit the configuration + compact_configuration_api_handler(event, self.mock_context) + + # Now attempt to remove one of the states + event, _ = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': False}, + # Removed Ohio + ] + event['body'] = json.dumps(body) + + # Should be rejected with a 400 error + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('States cannot be removed from configuredStates', response_body['message']) + self.assertIn('oh', response_body['message']) + + def test_put_compact_configuration_rejects_downgrading_is_live_status(self): + """Test that changing isLive from true to false is rejected.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a compact configuration with a live state + event, original_config = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': True}, + {'postalAbbreviation': 'oh', 'isLive': False}, + ] + event['body'] = json.dumps(body) + + # Submit the configuration + compact_configuration_api_handler(event, self.mock_context) + + # Now attempt to change Kentucky from live to non-live + event, _ = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': False}, # Changed to false + {'postalAbbreviation': 'oh', 'isLive': False}, + ] + event['body'] = json.dumps(body) + + # Should be rejected with a 400 error + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('cannot be changed from live to non-live status', response_body['message']) + self.assertIn('ky', response_body['message']) + + def test_put_compact_configuration_allows_upgrading_is_live_status(self): + """Test that changing isLive from false to true is allowed.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a compact configuration with a non-live state + event, original_config = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': False}, + ] + event['body'] = json.dumps(body) + + # Submit the configuration + compact_configuration_api_handler(event, self.mock_context) + + # Now change Kentucky from non-live to live + event, _ = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': True}, # Changed to true + ] + event['body'] = json.dumps(body) + + # Should be accepted + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify the state was updated in the database + stored_compact_data = self.config.compact_configuration_client.get_compact_configuration( + original_config.compactAbbr + ) + configured_states = stored_compact_data.configuredStates + + self.assertEqual(len(configured_states), 1) + self.assertEqual(configured_states[0]['postalAbbreviation'], 'ky') + self.assertTrue(configured_states[0]['isLive']) + + def test_put_compact_configuration_rejects_adding_new_states(self): + """Test that adding new states to configuredStates is rejected.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a compact configuration with one state + event, original_config = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': False}, + ] + event['body'] = json.dumps(body) + + # Submit the configuration + compact_configuration_api_handler(event, self.mock_context) + + # Now attempt to add a new state + event, _ = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': False}, + {'postalAbbreviation': 'oh', 'isLive': True}, # New state + ] + event['body'] = json.dumps(body) + + # Should be rejected with a 400 error + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('States cannot be manually added to configuredStates', response_body['message']) + self.assertIn('oh', response_body['message']) + + def test_put_compact_configuration_rejects_duplicate_configured_states(self): + """Test that duplicate states in configuredStates are rejected.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event, original_config = self._when_testing_put_compact_configuration() + body = json.loads(event['body']) + body['configuredStates'] = [ + {'postalAbbreviation': 'ky', 'isLive': False}, + {'postalAbbreviation': 'oh', 'isLive': True}, + {'postalAbbreviation': 'ky', 'isLive': True}, # Duplicate + ] + event['body'] = json.dumps(body) + + # Should be rejected with a 400 error + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('Duplicate states found in configuredStates', response_body['message']) + self.assertIn('ky', response_body['message']) + self.assertIn('Each state can only appear once', response_body['message']) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestStaffUsersJurisdictionConfiguration(TstFunction): + """Test suite for managing jurisdiction configurations.""" + + def _when_testing_put_jurisdiction_configuration(self, create_compact=True): + from cc_common.utils import ResponseEncoder + + if create_compact: + self.test_data_generator.put_default_compact_configuration_in_configuration_table() + + jurisdiction_config = self.test_data_generator.generate_default_jurisdiction_configuration() + event = generate_test_event('PUT', JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE) + event['pathParameters']['jurisdiction'] = jurisdiction_config.postalAbbreviation + # add compact admin scope to the event + event['requestContext']['authorizer']['claims']['scope'] = ( + f'{jurisdiction_config.postalAbbreviation}/{jurisdiction_config.compact}.admin' + ) + event['requestContext']['authorizer']['claims']['sub'] = 'some-admin-id' + + event['body'] = json.dumps( + { + 'jurisdictionOperationsTeamEmails': jurisdiction_config.jurisdictionOperationsTeamEmails, + 'jurisdictionAdverseActionsNotificationEmails': jurisdiction_config.jurisdictionAdverseActionsNotificationEmails, + 'licenseeRegistrationEnabled': jurisdiction_config.licenseeRegistrationEnabled, + }, + cls=ResponseEncoder, + ) + + return event, jurisdiction_config + + def test_get_jurisdiction_configuration_returns_invalid_exception_if_invalid_http_method(self): + """Test getting a jurisdiction configuration returns an invalid exception if the HTTP method is invalid.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PATCH', JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE, scopes='ky/socw.admin') + event['pathParameters']['jurisdiction'] = 'ky' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Invalid HTTP method'}, + response_body, + ) + + def test_get_jurisdiction_configuration_returns_unauthorized_exception_if_invalid_compact(self): + """Test getting an error if invalid compact is provided.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE, scopes='ky/socw.admin') + event['pathParameters']['compact'] = 'invalid_compact' + event['pathParameters']['jurisdiction'] = 'ky' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + def test_get_jurisdiction_configuration_returns_unauthorized_exception_if_invalid_jurisdiction(self): + """Test getting an error if invalid jurisdiction is provided.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE, scopes='ky/socw.admin') + event['pathParameters']['jurisdiction'] = 'invalid_jurisdiction' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + def test_get_jurisdiction_configuration_returns_empty_jurisdiction_configuration_if_no_configuration_exists(self): + """Test getting a jurisdiction configuration returns a default configuration if none exists.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('GET', JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE, scopes='ky/socw.admin') + event['pathParameters']['jurisdiction'] = 'ky' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Verify the jurisdiction name is set correctly from the mapping + self.assertEqual( + { + 'compact': 'socw', + 'jurisdictionAdverseActionsNotificationEmails': [], + 'jurisdictionName': 'Kentucky', + 'jurisdictionOperationsTeamEmails': [], + 'licenseeRegistrationEnabled': False, + 'postalAbbreviation': 'ky', + }, + response_body, + ) + + def test_get_jurisdiction_configuration_returns_configuration_if_exists(self): + """Test getting a jurisdiction configuration returns the existing configuration.""" + from handlers.compact_configuration import compact_configuration_api_handler + + test_jurisdiction_config = ( + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table() + ) + + # Now retrieve it + event = generate_test_event( + 'GET', + JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE, + scopes=f'{test_jurisdiction_config.postalAbbreviation}/{test_jurisdiction_config.compact}.admin', + ) + event['pathParameters']['jurisdiction'] = test_jurisdiction_config.postalAbbreviation + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # Verify the returned configuration matches what we created + self.assertEqual( + { + 'compact': test_jurisdiction_config.compact, + 'jurisdictionName': test_jurisdiction_config.jurisdictionName, + 'postalAbbreviation': test_jurisdiction_config.postalAbbreviation, + 'jurisdictionOperationsTeamEmails': test_jurisdiction_config.jurisdictionOperationsTeamEmails, + 'jurisdictionAdverseActionsNotificationEmails': test_jurisdiction_config.jurisdictionAdverseActionsNotificationEmails, + 'licenseeRegistrationEnabled': test_jurisdiction_config.licenseeRegistrationEnabled, + }, + response_body, + ) + + def test_put_jurisdiction_configuration_rejects_invalid_jurisdiction_with_auth_error(self): + """Test putting a jurisdiction configuration rejects an invalid jurisdiction abbreviation.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PUT', JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE) + # Set the jurisdiction to an invalid one + event['pathParameters']['jurisdiction'] = 'invalid_jurisdiction' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode']) + self.assertIn('Access denied', json.loads(response['body'])['message']) + + def test_put_jurisdiction_configuration_rejects_compact_admin_with_auth_error(self): + """Test putting a jurisdiction configuration rejects an update request from a compact admin.""" + from handlers.compact_configuration import compact_configuration_api_handler + + event = generate_test_event('PUT', JURISDICTION_CONFIGURATION_ENDPOINT_RESOURCE) + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + # add compact admin scope to the event, but not state admin + event['requestContext']['authorizer']['claims']['scope'] = 'socw/admin' + event['requestContext']['authorizer']['claims']['sub'] = 'some-admin-id' + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode']) + self.assertIn('Access denied', json.loads(response['body'])['message']) + + def test_put_jurisdiction_configuration_stores_jurisdiction_configuration(self): + """Test putting a jurisdiction configuration stores the jurisdiction configuration.""" + from cc_common.data_model.schema.jurisdiction import JurisdictionConfigurationData + from handlers.compact_configuration import compact_configuration_api_handler + + event, jurisdiction_config = self._when_testing_put_jurisdiction_configuration() + + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # load the record from the configuration table + serialized_jurisdiction_config = jurisdiction_config.serialize_to_database_record() + response = self.config.compact_configuration_table.get_item( + Key={'pk': serialized_jurisdiction_config['pk'], 'sk': serialized_jurisdiction_config['sk']} + ) + + stored_jurisdiction_data = JurisdictionConfigurationData.from_database_record(response['Item']) + + self.assertEqual(jurisdiction_config.to_dict(), stored_jurisdiction_data.to_dict()) + + def test_put_jurisdiction_configuration_rejects_disabling_licensee_registration(self): + """Test that a jurisdiction configuration update is rejected if trying to disable licensee registration after enabling it.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a jurisdiction configuration with licenseeRegistrationEnabled=True + event, jurisdiction_config = self._when_testing_put_jurisdiction_configuration() + # Set licenseeRegistrationEnabled to True in the request body + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = True + event['body'] = json.dumps(body) + + # Submit the configuration + compact_configuration_api_handler(event, self.mock_context) + + # Now attempt to update with licenseeRegistrationEnabled=False + event, _ = self._when_testing_put_jurisdiction_configuration() + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = False + event['body'] = json.dumps(body) + + # Should be rejected with a 400 error + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('Once licensee registration has been enabled, it cannot be disabled', response_body['message']) + + def test_put_jurisdiction_configuration_adds_state_to_configured_states_when_enabling_registration(self): + """Test that enabling licensee registration automatically adds the state to compact's configuredStates.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a compact configuration with empty configuredStates + compact_config = self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={'configuredStates': []} + ) + + # Create a jurisdiction configuration with licenseeRegistrationEnabled=True + event, jurisdiction_config = self._when_testing_put_jurisdiction_configuration() + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = True + event['body'] = json.dumps(body) + + # Submit the configuration + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify the state was added to the compact's configuredStates + updated_compact_config = self.config.compact_configuration_client.get_compact_configuration( + compact_config.compactAbbr + ) + configured_states = updated_compact_config.configuredStates + + self.assertEqual(len(configured_states), 1) + self.assertEqual(configured_states[0]['postalAbbreviation'], jurisdiction_config.postalAbbreviation) + self.assertFalse(configured_states[0]['isLive']) + + def test_put_jurisdiction_configuration_raises_exception_if_compact_configuration_not_found(self): + """Test the unlikely but possible scenario that a state sets its status to live + and the compact config can't be found.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create a jurisdiction configuration with licenseeRegistrationEnabled=True, without a compact config + event, jurisdiction_config = self._when_testing_put_jurisdiction_configuration(create_compact=False) + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = True + event['body'] = json.dumps(body) + + # In this case, we raise an exception to fire an alert since the compact config should be present + with self.assertRaises(CCInternalException): + compact_configuration_api_handler(event, self.mock_context) + + def test_put_jurisdiction_configuration_does_not_add_duplicate_state_to_configured_states(self): + """Test that a state is not added to configuredStates if it already exists.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # Create a jurisdiction configuration for the same state with licenseeRegistrationEnabled=True + event, jurisdiction_config = self._when_testing_put_jurisdiction_configuration() + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = True + event['body'] = json.dumps(body) + + # Create a compact configuration with the state already in configuredStates + compact_config = self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={'configuredStates': [{'postalAbbreviation': 'ky', 'isLive': True}]} + ) + + # Submit the configuration + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify the configuredStates list is unchanged (no duplicate added) + updated_compact_config = self.config.compact_configuration_client.get_compact_configuration( + compact_config.compactAbbr + ) + configured_states = updated_compact_config.configuredStates + + self.assertEqual(len(configured_states), 1) + self.assertEqual(configured_states[0]['postalAbbreviation'], 'ky') + self.assertTrue(configured_states[0]['isLive']) # Should preserve existing isLive status + + def test_put_jurisdiction_configuration_only_adds_state_to_compact_when_changing_from_false_to_true(self): + """Test that changing licenseeRegistrationEnabled from false to true adds the state to configuredStates.""" + from handlers.compact_configuration import compact_configuration_api_handler + + # First, create a compact configuration with empty configuredStates + compact_config = self.test_data_generator.put_default_compact_configuration_in_configuration_table( + value_overrides={'configuredStates': []} + ) + + # Create a jurisdiction configuration with licenseeRegistrationEnabled=False + event, jurisdiction_config = self._when_testing_put_jurisdiction_configuration() + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = False + event['body'] = json.dumps(body) + + # Submit the initial configuration + compact_configuration_api_handler(event, self.mock_context) + + # Verify the state was not added to configuredStates + updated_compact_config = self.config.compact_configuration_client.get_compact_configuration( + compact_config.compactAbbr + ) + configured_states = updated_compact_config.configuredStates + self.assertEqual(len(configured_states), 0) + + # Now update to enable licensee registration + event, _ = self._when_testing_put_jurisdiction_configuration() + body = json.loads(event['body']) + body['licenseeRegistrationEnabled'] = True + event['body'] = json.dumps(body) + + # Submit the updated configuration + response = compact_configuration_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify the state was added to configuredStates + updated_compact_config = self.config.compact_configuration_client.get_compact_configuration( + compact_config.compactAbbr + ) + configured_states = updated_compact_config.configuredStates + + self.assertEqual(len(configured_states), 1) + self.assertEqual(configured_states[0]['postalAbbreviation'], jurisdiction_config.postalAbbreviation) + self.assertFalse(configured_states[0]['isLive']) diff --git a/backend/social-work-app/lambdas/python/custom-resources/handlers/__init__.py b/backend/social-work-app/lambdas/python/custom-resources/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/custom-resources/handlers/compact_config_uploader.py b/backend/social-work-app/lambdas/python/custom-resources/handlers/compact_config_uploader.py new file mode 100755 index 0000000000..63c7165b4d --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/handlers/compact_config_uploader.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.compact_configuration_utils import CompactConfigUtility + + +def on_event(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """CloudFormation event handler using the CDK provider framework. + See: https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.custom_resources/README.html + + This custom resource stores active member jurisdictions for each compact in the compact configuration + DynamoDB table. + + This custom resource is defined in the CDK app within the 'CompactConfigurationUpload' construct of the + persistent stack. + + :param event: The lambda event with the active_compact_member_jurisdictions data + :param context: + :return: None - no infrastructure resources are created + """ + logger.info('Entering compact configuration uploader') + properties = event['ResourceProperties'] + request_type = event['RequestType'] + match request_type: + case 'Create' | 'Update': + return upload_configuration(properties) + case 'Delete': + # In the case of delete we do not remove any data from the table + # data deletion will be managed by the DB's removal policy. + return None + case _: + raise ValueError(f'Unexpected request type: {request_type}') + + +def upload_configuration(properties: dict): + """Store active member jurisdictions for all active compacts""" + active_compact_member_jurisdictions = json.loads(properties['active_compact_member_jurisdictions']) + + logger.info('Processing active member jurisdictions') + + # Use the keys of active_compact_member_jurisdictions as the compact list + compact_list = list(active_compact_member_jurisdictions.keys()) + + # Store active member jurisdictions for each compact + for compact in compact_list: + _store_active_member_jurisdictions(compact, active_compact_member_jurisdictions[compact]) + + logger.info('Configuration upload successful') + + +def _store_active_member_jurisdictions(compact_abbr: str, member_jurisdictions: list[str]) -> None: + """ + Store the active member jurisdictions for a compact in the database. + + :param compact_abbr: The compact abbreviation + :param member_jurisdictions: List of jurisdiction postal abbreviations + """ + logger.info( + 'Storing active member jurisdictions', compact=compact_abbr, jurisdiction_count=len(member_jurisdictions) + ) + + # Format member jurisdictions into the expected shape + formatted_jurisdictions = [] + for jurisdiction in member_jurisdictions: + formatted_jurisdictions.append( + { + 'jurisdictionName': CompactConfigUtility.get_jurisdiction_name(postal_abbr=jurisdiction), + 'postalAbbreviation': jurisdiction, + 'compact': compact_abbr, + } + ) + + # Create the item to store + item = { + 'pk': f'COMPACT#{compact_abbr}#ACTIVE_MEMBER_JURISDICTIONS', + 'sk': f'COMPACT#{compact_abbr}#ACTIVE_MEMBER_JURISDICTIONS', + 'active_member_jurisdictions': formatted_jurisdictions, + } + + # Store in the table + config.compact_configuration_table.put_item(Item=item) diff --git a/backend/social-work-app/lambdas/python/custom-resources/handlers/ses_email_identity_verification_handler.py b/backend/social-work-app/lambdas/python/custom-resources/handlers/ses_email_identity_verification_handler.py new file mode 100644 index 0000000000..a9ca94bf70 --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/handlers/ses_email_identity_verification_handler.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import json +import time + +import boto3 +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import logger +from cc_common.exceptions import CCInternalException + + +def on_event(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + CloudFormation event handler using the CDK provider framework. + + This custom resource verifies that a SES email identity is verified. It is used to + prevent a race cobefore allowing + dependent resources (like Cognito user pools) to be created. It polls the SES API + until the identity is verified or until the Lambda times out. + + The function will: + - Return successfully if the domain is verified + - Raise an exception if verification fails or times out + - Do nothing on Delete events + + :param event: The CloudFormation custom resource event + :param context: The Lambda context + :return: Physical resource ID on success + """ + logger.info('Entering SES email identity verification handler', event=json.dumps(event)) + properties = event['ResourceProperties'] + request_type = event['RequestType'] + match request_type: + case 'Create': + return verify_ses_email_identity(properties) + case 'Update' | 'Delete': + # In the case of update or delete we do not need to verify the SES identity + return None + case _: + raise ValueError(f'Unexpected request type: {request_type}') + + +# Extract properties from the event +def verify_ses_email_identity(properties: dict): + domain_name = properties.get('DomainName') + + # Create SES client + ses_client = boto3.client('ses') + + # Maximum number of attempts (15 seconds * 60 = 15 minutes) + max_attempts = 60 + attempts = 0 + + # Poll until the identity is verified or we time out + for _attempt in range(max_attempts): + # Check verification status + response = ses_client.get_identity_verification_attributes(Identities=[domain_name]) + + # Get verification status + attributes = response.get('VerificationAttributes', {}) + domain_attributes = attributes.get(domain_name, {}) + verification_status = domain_attributes.get('VerificationStatus', 'NotStarted') + + logger.info(f'Verification status for {domain_name}: {verification_status}') + + # If verified, we're done + if verification_status == 'Success': + logger.info(f'Domain {domain_name} is verified!') + return + + # If failed, raise exception + if verification_status == 'Failed': + error_msg = f'Domain {domain_name} verification failed' + logger.error(error_msg) + raise CCInternalException(error_msg) + + # Wait and try again + attempts += 1 + logger.info(f'Waiting for verification... Attempt {attempts}/{max_attempts}') + time.sleep(15) # Wait 15 seconds between checks + + # If we get here, we've timed out + error_msg = f'Timed out waiting for domain {domain_name} to be verified' + logger.error(error_msg) + raise CCInternalException(error_msg) diff --git a/backend/social-work-app/lambdas/python/custom-resources/requirements-dev.in b/backend/social-work-app/lambdas/python/custom-resources/requirements-dev.in new file mode 100644 index 0000000000..5a61b7b0d2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb, s3]>=5.0.12, <6 diff --git a/backend/social-work-app/lambdas/python/custom-resources/requirements-dev.txt b/backend/social-work-app/lambdas/python/custom-resources/requirements-dev.txt new file mode 100644 index 0000000000..e5e4a38e90 --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/requirements-dev.txt @@ -0,0 +1,64 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/custom-resources/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/custom-resources/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/custom-resources/requirements.in b/backend/social-work-app/lambdas/python/custom-resources/requirements.in new file mode 100644 index 0000000000..3d293fbf73 --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common-python requirements.in file diff --git a/backend/social-work-app/lambdas/python/custom-resources/requirements.txt b/backend/social-work-app/lambdas/python/custom-resources/requirements.txt new file mode 100644 index 0000000000..2a8810a143 --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/custom-resources/requirements.in +# diff --git a/backend/social-work-app/lambdas/python/custom-resources/tests/__init__.py b/backend/social-work-app/lambdas/python/custom-resources/tests/__init__.py new file mode 100644 index 0000000000..6895c6b74d --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/tests/__init__.py @@ -0,0 +1,28 @@ +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'false', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': '["oh", "ky", "ne"]', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'ENVIRONMENT_NAME': 'test', + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/custom-resources/tests/function/__init__.py b/backend/social-work-app/lambdas/python/custom-resources/tests/function/__init__.py new file mode 100644 index 0000000000..7287ae8987 --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/tests/function/__init__.py @@ -0,0 +1,37 @@ +# noqa: N801 invalid-name +import logging +import os + +import boto3 +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): + super().setUp() + + self.build_resources() + self.addCleanup(self.delete_resources) + + def build_resources(self): + self._table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def delete_resources(self): + self._table.delete() diff --git a/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/__init__.py b/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/test_compact_configuration_uploader.py b/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/test_compact_configuration_uploader.py new file mode 100644 index 0000000000..1c7d35a33f --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/test_compact_configuration_uploader.py @@ -0,0 +1,55 @@ +import json +from datetime import datetime +from unittest.mock import patch + +from moto import mock_aws + +from .. import TstFunction + +TEST_ENVIRONMENT_NAME = 'test' +MOCK_CURRENT_TIMESTAMP = '2024-11-08T23:59:59+00:00' + +COSM_COMPACT_ABBREVIATION = 'socw' + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_TIMESTAMP)) +class TestCompactConfigurationUploader(TstFunction): + def test_active_member_jurisdictions_are_stored_correctly(self): + """Test that active member jurisdictions are correctly stored in DynamoDB.""" + from handlers.compact_config_uploader import on_event + + member_jurisdictions = { + COSM_COMPACT_ABBREVIATION: ['al', 'az', 'co', 'ks', 'ky', 'md', 'oh', 'tn', 'va', 'wa'], + } + + event = { + 'RequestType': 'Create', + 'ResourceProperties': { + 'active_compact_member_jurisdictions': json.dumps(member_jurisdictions), + }, + } + + on_event(event, self.mock_context) + + # Check active member jurisdictions for COSM + active_member_response = self.config.compact_configuration_table.get_item( + Key={ + 'pk': f'COMPACT#{COSM_COMPACT_ABBREVIATION}#ACTIVE_MEMBER_JURISDICTIONS', + 'sk': f'COMPACT#{COSM_COMPACT_ABBREVIATION}#ACTIVE_MEMBER_JURISDICTIONS', + } + ) + + self.assertIn('Item', active_member_response) + self.assertIn('active_member_jurisdictions', active_member_response['Item']) + + active_members = active_member_response['Item']['active_member_jurisdictions'] + self.assertEqual(10, len(active_members)) + + # Check that the expected jurisdictions are in the list with the correct structure + for jurisdiction in active_members: + self.assertIn('postalAbbreviation', jurisdiction) + self.assertIn('jurisdictionName', jurisdiction) + self.assertIn('compact', jurisdiction) + self.assertEqual(COSM_COMPACT_ABBREVIATION, jurisdiction['compact']) + self.assertIn(jurisdiction['postalAbbreviation'], member_jurisdictions[COSM_COMPACT_ABBREVIATION]) diff --git a/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/test_ses_email_identity_verification_handler.py b/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/test_ses_email_identity_verification_handler.py new file mode 100644 index 0000000000..c54c647e87 --- /dev/null +++ b/backend/social-work-app/lambdas/python/custom-resources/tests/function/test_handlers/test_ses_email_identity_verification_handler.py @@ -0,0 +1,112 @@ +import unittest +from unittest.mock import MagicMock, patch + +from cc_common.exceptions import CCInternalException + +EXAMPLE_DOMAIN_NAME = 'example.com' + + +def _generate_test_event(request_type: str): + return { + 'RequestType': request_type, + 'ResourceProperties': { + 'DomainName': EXAMPLE_DOMAIN_NAME, + 'Region': 'us-east-1', + 'ServiceToken': 'arn:aws:lambda:us-west-1:123456789012:function:test-function', + }, + } + + +class TestSESEmailIdentityVerificationHandler(unittest.TestCase): + """Test suite for the SES Email Identity Verification Handler.""" + + def setUp(self): + """Set up test fixtures.""" + self.context = MagicMock() + self.context.log_stream_name = 'test-log-stream' + + # Sample event for testing + self.create_event = _generate_test_event('Create') + + self.update_event = _generate_test_event('Update') + + self.delete_event = _generate_test_event('Delete') + + def _when_testing_ses_response_with_status(self, mock_boto3_client, status: str): + mock_ses_client = MagicMock() + mock_boto3_client.return_value = mock_ses_client + + # Mock successful verification + mock_ses_client.get_identity_verification_attributes.return_value = { + 'VerificationAttributes': {'example.com': {'VerificationStatus': status}} + } + + return mock_ses_client + + @patch('handlers.ses_email_identity_verification_handler.boto3.client') + def test_handler_with_verification_success(self, mock_boto3_client): + """Test successful creation of email identity.""" + from handlers.ses_email_identity_verification_handler import on_event + + mock_ses_client = self._when_testing_ses_response_with_status(mock_boto3_client, status='Success') + + # Call handler + on_event(self.create_event, self.context) + + # Verify SES client was created with correct region + mock_boto3_client.assert_called_once_with('ses') + # Verify verify_domain_identity was called + mock_ses_client.get_identity_verification_attributes.assert_called_once_with(Identities=[EXAMPLE_DOMAIN_NAME]) + + @patch('handlers.ses_email_identity_verification_handler.boto3.client') + @patch('handlers.ses_email_identity_verification_handler.time.sleep') + def test_handler_retries_max_times_before_timeout(self, mock_sleep, mock_boto3_client): + """Test timeout during verification.""" + from handlers.ses_email_identity_verification_handler import on_event + + # mocking time so test runs through loop with max retries + mock_sleep.return_value = None + + # Mock pending verification that never completes + mock_ses_client = self._when_testing_ses_response_with_status(mock_boto3_client, status='Pending') + + with self.assertRaises(CCInternalException): + on_event(self.create_event, self.context) + + self.assertEqual(60, mock_ses_client.get_identity_verification_attributes.call_count) + + @patch('handlers.ses_email_identity_verification_handler.boto3.client') + def test_handler_with_failed_verification(self, mock_boto3_client): + """Test failed verification status.""" + from handlers.ses_email_identity_verification_handler import on_event + + # Mock pending verification that never completes + mock_ses_client = self._when_testing_ses_response_with_status(mock_boto3_client, status='Failed') + + with self.assertRaises(CCInternalException): + on_event(self.create_event, self.context) + + # Verify send_response was called only once + mock_ses_client.get_identity_verification_attributes.assert_called_once() + + @patch('handlers.ses_email_identity_verification_handler.boto3.client') + def test_handler_update_event_does_not_verify_domain(self, mock_boto3_client): + """Test update with different domain.""" + from handlers.ses_email_identity_verification_handler import on_event + + # Call handler + on_event(self.update_event, self.context) + + # Verify send_response was not called + mock_boto3_client.assert_not_called() + + @patch('handlers.ses_email_identity_verification_handler.boto3.client') + def test_handler_delete_event_does_(self, mock_boto3_client): + """Test delete operation.""" + from handlers.ses_email_identity_verification_handler import on_event + + # Call handler + on_event(self.delete_event, self.context) + + # Verify send_response was not called + mock_boto3_client.assert_not_called() diff --git a/backend/social-work-app/lambdas/python/data-events/handlers/__init__.py b/backend/social-work-app/lambdas/python/data-events/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/data-events/handlers/data_events.py b/backend/social-work-app/lambdas/python/data-events/handlers/data_events.py new file mode 100644 index 0000000000..68808e0539 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/handlers/data_events.py @@ -0,0 +1,58 @@ +from datetime import UTC, datetime + +from cc_common.config import config, logger +from cc_common.data_model.schema.license.ingest import SanitizedLicenseIngestDataEventSchema +from cc_common.utils import sqs_handler + + +@sqs_handler +def handle_data_events(message: dict): + """Regurgitate any data events straight into the DB""" + _fill_empty_field_names(message['detail']) + + event_type = message['detail-type'] + # in the case of a licence.ingest event, we sanitize the PII from the license record + if event_type == 'license.ingest': + sanitized_schema = SanitizedLicenseIngestDataEventSchema() + # by loading and dumping the data, we ensure that the data is sanitized as the schema + # will remove all fields that are not explicitly defined in the schema + message['detail'] = sanitized_schema.dump(sanitized_schema.load(message['detail'])) + + compact = message['detail']['compact'] + jurisdiction = message['detail']['jurisdiction'] + event_time = datetime.fromisoformat(message['detail']['eventTime']) + key = { + 'pk': f'COMPACT#{compact}#JURISDICTION#{jurisdiction}', + 'sk': f'TYPE#{event_type}#TIME#{int(event_time.timestamp())}#EVENT#{message["id"]}', + } + ttl = config.event_ttls.get(event_type, config.default_event_ttl) + + event_expiry = int((datetime.now(tz=UTC) + ttl).timestamp()) + + config.data_events_table.put_item( + Item={**key, 'eventExpiry': event_expiry, 'eventType': event_type, **message['detail']} + ) + logger.debug('Recorded event', key=key) + + +def _fill_empty_field_names(data: dict | list): + """ + Fill in empty field names with '' + + JSON allows object attributes (fields) to have an empty string name, ('') + but DynamoDB does not. To prevent errors on trying to store these variable + event payloads in DynamoDB, we will proactively 'fill' empty field names in the + event data with '' + """ + if isinstance(data, dict): + try: + data[''] = data.pop('') + except KeyError: + pass + for value in data.values(): + # Move the empty key/value over to + _fill_empty_field_names(value) + if isinstance(data, list): + for item in data: + if isinstance(item, dict | list): + _fill_empty_field_names(item) diff --git a/backend/social-work-app/lambdas/python/data-events/handlers/encumbrance_events.py b/backend/social-work-app/lambdas/python/data-events/handlers/encumbrance_events.py new file mode 100644 index 0000000000..30d836cdbc --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/handlers/encumbrance_events.py @@ -0,0 +1,566 @@ +from uuid import UUID + +from cc_common.config import config, logger +from cc_common.data_model.provider_record_util import ProviderData, ProviderUserRecords +from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum +from cc_common.data_model.schema.data_event.api import ( + EncumbranceEventDetailSchema, +) +from cc_common.email_service_client import ( + EncumbranceNotificationTemplateVariables, + JurisdictionNotificationMethod, +) +from cc_common.event_state_client import EventType, NotificationTracker, RecipientType +from cc_common.exceptions import CCInternalException +from cc_common.license_util import LicenseUtility +from cc_common.utils import sqs_handler_with_notification_tracking + + +def _get_license_type_name(compact: str, license_type_abbreviation: str) -> str: + """ + Get the license type name from abbreviation. + + :param compact: The compact identifier + :param license_type_abbreviation: The license type abbreviation + :return: The license type name + """ + return LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name + + +def _get_provider_records(compact: str, provider_id: str) -> tuple[ProviderUserRecords, ProviderData]: + """ + Retrieve and validate provider records for notification processing. + + :param compact: The compact identifier + :param provider_id: The provider ID + :return: Tuple of (provider_records, provider_record) + :raises Exception: If provider records cannot be retrieved + """ + try: + provider_records = config.data_client.get_provider_user_records( + compact=compact, + provider_id=provider_id, + ) + provider_record = provider_records.get_provider_record() + return provider_records, provider_record + except Exception as e: + logger.error('Failed to retrieve provider records for notification', exception=str(e)) + raise + + +def _send_primary_state_notification( + notification_method: JurisdictionNotificationMethod, + notification_type: str, + *, + provider_record: ProviderData, + jurisdiction: str, + compact: str, + event_type: EventType, + event_time: str, + tracker: NotificationTracker, + provider_id: UUID, + **notification_kwargs, +) -> None: + """ + Send notification to the primary affected state if not already sent. + + :param notification_method: The email service method to call + :param notification_type: Type of notification for logging + :param provider_record: The provider record + :param provider_id: The provider ID + :param jurisdiction: The jurisdiction to notify + :param compact: The compact identifier + :param event_type: Event type (e.g., 'license.encumbrance') + :param event_time: Event timestamp + :param tracker: NotificationTracker instance for idempotency + :param notification_kwargs: Additional arguments for the notification method + """ + if tracker.should_send_state_notification(jurisdiction): + logger.info(f'Sending {notification_type} notification to affected state', affected_jurisdiction=jurisdiction) + try: + notification_method( + compact=compact, + jurisdiction=jurisdiction, + template_variables=EncumbranceNotificationTemplateVariables( + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + provider_id=provider_id, + **notification_kwargs, + ), + ) + logger.info( + 'Successfully called email service client for state notification. Calling Notification Tracker.', + provider_id=provider_id, + event_type=event_type, + jurisdiction=jurisdiction, + ) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction=jurisdiction, + ) + except Exception as e: + logger.error('Failed to send state notification', jurisdiction=jurisdiction, exception=str(e)) + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + error_message=str(e), + jurisdiction=jurisdiction, + ) + raise + else: + logger.info( + 'Skipping primary state notification (already sent successfully)', affected_jurisdiction=jurisdiction + ) + + +def _send_additional_state_notifications( + notification_method: JurisdictionNotificationMethod, + notification_type: str, + *, + provider_record: ProviderData, + excluded_jurisdiction: str, + compact: str, + event_type: EventType, + event_time: str, + tracker: NotificationTracker, + provider_id: UUID, + **notification_kwargs, +) -> None: + """ + Send notifications to all other states that are live in the compact, if not already sent. + Uses config live compact jurisdictions. + + :param notification_method: The email service method to call + :param notification_type: Type of notification for logging + :param provider_record: The provider record + :param provider_id: The provider ID + :param excluded_jurisdiction: Jurisdiction to exclude from notifications + :param compact: The compact identifier + :param event_type: Event type (e.g., 'license.encumbrance') + :param event_time: Event timestamp + :param tracker: NotificationTracker instance for idempotency + :param notification_kwargs: Additional arguments for the notification method + """ + notification_jurisdictions = set() + live_jurisdictions = config.live_compact_jurisdictions.get(compact) + if not live_jurisdictions: + message = 'No live jurisdictions found for compact' + logger.error(message, compact=compact) + raise CCInternalException(message) + + for live_jurisdiction in config.live_compact_jurisdictions.get(compact, []): + if live_jurisdiction.lower() != excluded_jurisdiction.lower(): + notification_jurisdictions.add(live_jurisdiction) + + # Send notifications to all other live states + template_variables = EncumbranceNotificationTemplateVariables( + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + provider_id=provider_id, + **notification_kwargs, + ) + for notification_jurisdiction in notification_jurisdictions: + if tracker.should_send_state_notification(notification_jurisdiction): + logger.info( + f'Sending {notification_type} notification to other state', + notification_jurisdiction=notification_jurisdiction, + ) + try: + notification_method( + compact=compact, + jurisdiction=notification_jurisdiction, + template_variables=template_variables, + ) + logger.info( + 'Successfully called email service client for state notification. Calling Notification Tracker.', + provider_id=provider_id, + event_type=event_type, + jurisdiction=notification_jurisdiction, + ) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + jurisdiction=notification_jurisdiction, + ) + except Exception as e: + logger.error( + 'Failed to send notification to other state', + notification_jurisdiction=notification_jurisdiction, + exception=str(e), + ) + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=event_type, + event_time=event_time, + error_message=str(e), + jurisdiction=notification_jurisdiction, + ) + raise + else: + logger.info( + 'Skipping additional state notification (already sent successfully)', + notification_jurisdiction=notification_jurisdiction, + ) + + +@sqs_handler_with_notification_tracking +def privilege_encumbrance_notification_listener(message: dict, tracker: NotificationTracker): + """ + Handle privilege encumbrance events by sending notifications. + + This handler processes 'privilege.encumbrance' events and sends notifications + to the affected provider and relevant states. + Uses NotificationTracker to ensure idempotent delivery on retries. + """ + detail_schema = EncumbranceEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + effective_date = detail['effectiveDate'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing privilege encumbrance event') + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = _get_license_type_name(compact, license_type_abbreviation) + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # State Notifications + # Send notification to the state where the privilege is encumbered + _send_primary_state_notification( + config.email_service_client.send_privilege_encumbrance_state_notification_email, + 'privilege encumbrance', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.PRIVILEGE_ENCUMBRANCE, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=effective_date, + ) + + # Send notifications to all other states live in the system for the compact + _send_additional_state_notifications( + config.email_service_client.send_privilege_encumbrance_state_notification_email, + 'privilege encumbrance', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.PRIVILEGE_ENCUMBRANCE, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=effective_date, + ) + + logger.info('Successfully processed privilege encumbrance event') + + +@sqs_handler_with_notification_tracking +def privilege_encumbrance_lifting_notification_listener(message: dict, tracker: NotificationTracker): + """ + Handle privilege encumbrance lifting events by sending notifications. + + This handler processes 'privilege.encumbranceLifted' events and sends notifications + to the affected provider and relevant states. + Uses NotificationTracker to ensure idempotent delivery on retries. + """ + detail_schema = EncumbranceEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing privilege encumbrance lifting event') + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = _get_license_type_name(compact, license_type_abbreviation) + + # Get provider records to gather notification targets and provider information + provider_records, provider_record = _get_provider_records(compact, provider_id) + + # Ensure that all encumbrances have been lifted from this privilege before sending out notifications. + # Derive "still encumbered" from adverse actions: any privilege adverse action without effectiveLiftDate. + privilege_adverse_actions = provider_records.get_adverse_action_records_for_privilege( + privilege_jurisdiction=jurisdiction, + privilege_license_type_abbreviation=license_type_abbreviation, + ) + if any(aa.effectiveLiftDate is None for aa in privilege_adverse_actions): + logger.info( + 'Privilege is still encumbered (one or more adverse actions not lifted). ' + 'Not sending lift notifications', + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + ) + return + + # Get latest effective lift date for all adverse actions related to privilege/license + # and determine the actual effective date when privilege was effectively unencumbered. + license_associated_with_privilege = provider_records.find_best_license_in_current_known_licenses( + license_type_abbreviation=license_type_abbreviation + ) + if license_associated_with_privilege.encumberedStatus == LicenseEncumberedStatusEnum.ENCUMBERED: + logger.info( + 'License is still encumbered. Not sending privilege encumbrance lift notifications.', + jurisdiction=license_associated_with_privilege.jurisdiction, + ) + return + + latest_license_lift_date = provider_records.get_latest_effective_lift_date_for_license_adverse_actions( + license_jurisdiction=license_associated_with_privilege.jurisdiction, + license_type_abbreviation=license_type_abbreviation, + ) + + latest_privilege_lift_date = provider_records.get_latest_effective_lift_date_for_privilege_adverse_actions( + privilege_jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + ) + + if latest_license_lift_date is None and latest_privilege_lift_date is None: + error_message = ( + 'No latest effective lift date found for this privilege record. Records with an unencumbered ' + 'status should have a latest effective lift date' + ) + logger.error(error_message) + raise CCInternalException(error_message) + if latest_license_lift_date is None: + latest_effective_lift_date = latest_privilege_lift_date + elif latest_privilege_lift_date is None: + latest_effective_lift_date = latest_license_lift_date + else: + latest_effective_lift_date = max(latest_license_lift_date, latest_privilege_lift_date) + + # State Notifications + # Send notification to the state where the privilege encumbrance was lifted + _send_primary_state_notification( + config.email_service_client.send_privilege_encumbrance_lifting_state_notification_email, + 'privilege encumbrance lifting', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.PRIVILEGE_ENCUMBRANCE_LIFTED, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=latest_effective_lift_date, + ) + + # Send notifications to all other states live in the system for the compact + _send_additional_state_notifications( + config.email_service_client.send_privilege_encumbrance_lifting_state_notification_email, + 'privilege encumbrance lifting', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.PRIVILEGE_ENCUMBRANCE_LIFTED, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=latest_effective_lift_date, + ) + + logger.info('Successfully processed privilege encumbrance lifting event') + + +@sqs_handler_with_notification_tracking +def license_encumbrance_notification_listener(message: dict, tracker: NotificationTracker): + """ + Handle license encumbrance events by sending notifications only. + + This handler processes 'license.encumbrance' events and sends notifications + to the affected provider and relevant states. It does NOT perform any data operations. + Uses NotificationTracker to ensure idempotent delivery on retries. + """ + detail_schema = EncumbranceEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + effective_date = detail['effectiveDate'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing license encumbrance notification event') + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = _get_license_type_name(compact, license_type_abbreviation) + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # State Notifications + # Send notification to the state where the license is encumbered + _send_primary_state_notification( + config.email_service_client.send_license_encumbrance_state_notification_email, + 'license encumbrance', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.LICENSE_ENCUMBRANCE, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=effective_date, + ) + + # Send notifications to all other states live in the system for the compact + _send_additional_state_notifications( + config.email_service_client.send_license_encumbrance_state_notification_email, + 'license encumbrance', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.LICENSE_ENCUMBRANCE, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=effective_date, + ) + + logger.info('Successfully processed license encumbrance notification event') + + +@sqs_handler_with_notification_tracking +def license_encumbrance_lifting_notification_listener(message: dict, tracker: NotificationTracker): + """ + Handle license encumbrance lifting events by sending notifications only. + + This handler processes 'license.encumbranceLifted' events and sends notifications + to the affected provider and relevant states. It does NOT perform any data operations. + Uses NotificationTracker to ensure idempotent delivery on retries. + """ + detail_schema = EncumbranceEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing license encumbrance lifting notification event') + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = _get_license_type_name(compact, license_type_abbreviation) + + # Get provider records to gather notification targets and provider information + provider_records, provider_record = _get_provider_records(compact, provider_id) + + target_license = provider_records.get_specific_license_record( + jurisdiction=jurisdiction, license_abbreviation=license_type_abbreviation + ) + + if target_license is None: + error_message = 'License record not found for lifting event' + logger.error(error_message) + raise CCInternalException(error_message) + + if ( + target_license.encumberedStatus is not None + and target_license.encumberedStatus != LicenseEncumberedStatusEnum.UNENCUMBERED + ): + logger.info( + 'License record is still encumbered, likely due to another adverse ' + 'action. Not sending encumbrance lift notifications', + license_encumbered_status=target_license.encumberedStatus, + ) + return + + # license is unencumbered, get latest effective lift date for all adverse actions + latest_effective_lift_date = provider_records.get_latest_effective_lift_date_for_license_adverse_actions( + license_jurisdiction=target_license.jurisdiction, + license_type_abbreviation=target_license.licenseTypeAbbreviation, + ) + + # State Notifications + # Send notification to the state where the license encumbrance was lifted + _send_primary_state_notification( + config.email_service_client.send_license_encumbrance_lifting_state_notification_email, + 'license encumbrance lifting', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.LICENSE_ENCUMBRANCE_LIFTED, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=latest_effective_lift_date, + ) + + # Send notifications to all other states live in the system for the compact + _send_additional_state_notifications( + config.email_service_client.send_license_encumbrance_lifting_state_notification_email, + 'license encumbrance lifting', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + event_type=EventType.LICENSE_ENCUMBRANCE_LIFTED, + event_time=event_time, + tracker=tracker, + encumbered_jurisdiction=jurisdiction, + license_type=license_type_name, + effective_date=latest_effective_lift_date, + ) + + logger.info('Successfully processed license encumbrance lifting notification event') diff --git a/backend/social-work-app/lambdas/python/data-events/handlers/home_state_change_events.py b/backend/social-work-app/lambdas/python/data-events/handlers/home_state_change_events.py new file mode 100644 index 0000000000..329bd65595 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/handlers/home_state_change_events.py @@ -0,0 +1,55 @@ +from cc_common.config import config, logger +from cc_common.data_model.schema.data_event.api import HomeJurisdictionChangeEventDetailSchema +from cc_common.email_service_client import HomeJurisdictionChangeNotificationTemplateVariables +from cc_common.utils import sqs_handler + + +@sqs_handler +def home_state_change_notification_listener(message: dict): + """ + Handle home state change events by sending notifications. + + For theSocial Workcompact, the home state for a practitioner is determined by + which license was issued or renewed most recently. If another home state uploads + or renews a license record for that same practitioner with a more recent date, + that state becomes the new home state for that practitioner, and this notification + listener is triggered. + """ + detail_schema = HomeJurisdictionChangeEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + former_home_jurisdiction = detail['formerHomeJurisdiction'] + license_type = detail['licenseType'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type=license_type, + event_time=event_time, + ): + logger.info('Processing provider home state change event') + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # Send notification to former state + config.email_service_client.send_provider_home_state_change_email( + compact=compact, + # in the case of social work, we only send the email notification to the former state. + jurisdiction=former_home_jurisdiction, + template_variables=HomeJurisdictionChangeNotificationTemplateVariables( + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + former_jurisdiction=former_home_jurisdiction, + current_jurisdiction=jurisdiction, + license_type=license_type, + provider_id=provider_id, + ), + ) + + logger.info('Successfully processed home state change event') diff --git a/backend/social-work-app/lambdas/python/data-events/handlers/investigation_events.py b/backend/social-work-app/lambdas/python/data-events/handlers/investigation_events.py new file mode 100644 index 0000000000..579011e6e6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/handlers/investigation_events.py @@ -0,0 +1,368 @@ +from typing import Any, Protocol +from uuid import UUID + +from cc_common.config import config, logger +from cc_common.data_model.schema.data_event.api import InvestigationEventDetailSchema +from cc_common.data_model.schema.provider import ProviderData +from cc_common.email_service_client import InvestigationNotificationTemplateVariables +from cc_common.exceptions import CCInternalException +from cc_common.license_util import LicenseUtility +from cc_common.utils import sqs_handler + + +class JurisdictionNotificationMethod(Protocol): + """Protocol for Jurisdiction investigation notification methods.""" + + def __call__( + self, *, compact: str, jurisdiction: str, template_variables: InvestigationNotificationTemplateVariables + ) -> dict[str, Any]: ... + + +def _send_primary_state_notification( + notification_method: JurisdictionNotificationMethod, + notification_type: str, + *, + provider_record: ProviderData, + jurisdiction: str, + compact: str, + **notification_kwargs, +) -> None: + """ + Send notification to the primary affected state. + + :param notification_method: The email service method to call + :param notification_type: Type of notification for logging + :param provider_record: The provider record + :param jurisdiction: The jurisdiction to notify + :param compact: The compact identifier + :param notification_kwargs: Additional arguments for the notification method + """ + logger.info(f'Sending {notification_type} notification to affected state', affected_jurisdiction=jurisdiction) + try: + notification_method( + compact=compact, + jurisdiction=jurisdiction, + template_variables=InvestigationNotificationTemplateVariables( + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + **notification_kwargs, + ), + ) + except Exception as e: + logger.error('Failed to send state notification', jurisdiction=jurisdiction, exception=str(e)) + raise + + +def _send_additional_state_notifications( + notification_method: JurisdictionNotificationMethod, + notification_type: str, + *, + provider_record: ProviderData, + provider_id: UUID, + excluded_jurisdiction: str, + compact: str, + **notification_kwargs, +) -> None: + """ + Send notifications to all other states that are live in the compact. + Uses config live compact jurisdictions. + + :param notification_method: The email service method to call + :param notification_type: Type of notification for logging + :param provider_record: The provider record + :param provider_id: The provider ID + :param excluded_jurisdiction: Jurisdiction to exclude from notifications + :param compact: The compact identifier + :param notification_kwargs: Additional arguments for the notification method + """ + notification_jurisdictions = set() + live_jurisdictions = config.live_compact_jurisdictions.get(compact) + if not live_jurisdictions: + message = 'No live jurisdictions found for compact' + logger.error(message, compact=compact) + raise CCInternalException(message) + + for live_jurisdiction in config.live_compact_jurisdictions.get(compact, []): + if live_jurisdiction.lower() != excluded_jurisdiction.lower(): + notification_jurisdictions.add(live_jurisdiction) + + # Send notifications to all other live states + template_variables = InvestigationNotificationTemplateVariables( + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + provider_id=provider_id, + **notification_kwargs, + ) + for notification_jurisdiction in notification_jurisdictions: + logger.info( + f'Sending {notification_type} notification to other state', + notification_jurisdiction=notification_jurisdiction, + ) + try: + notification_method( + compact=compact, + jurisdiction=notification_jurisdiction, + template_variables=template_variables, + ) + except Exception as e: + logger.error( + 'Failed to send notification to other state', + notification_jurisdiction=notification_jurisdiction, + exception=str(e), + ) + raise + + +@sqs_handler +def license_investigation_notification_listener(message: dict): + """ + Handle license investigation events by sending notifications. + + This handler processes 'license.investigation' events and sends notifications + to the affected provider and relevant states. + """ + detail_schema = InvestigationEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing license investigation event') + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # State Notifications + # Note: We do NOT send notifications to providers for investigations + # Send notification to the state where the license is under investigation + _send_primary_state_notification( + config.email_service_client.send_license_investigation_state_notification_email, + 'license investigation', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + # Send notifications to all other states with provider licenses or privileges + _send_additional_state_notifications( + config.email_service_client.send_license_investigation_state_notification_email, + 'license investigation', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + logger.info('Successfully processed license investigation event') + + +@sqs_handler +def license_investigation_closed_notification_listener(message: dict): + """ + Handle license investigation closed events by sending notifications. + + This handler processes 'license.investigationClosed' events and sends notifications + to the affected provider and relevant states. + """ + detail_schema = InvestigationEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing license investigation closed event') + + # If an encumbrance resulted from the investigation, we will let the encumbrance notification suffice. + # This is determined by the presence of an 'adverseActionId' in the event detail. + if detail.get('adverseActionId'): + logger.info('Investigation closed with an encumbrance, skipping investigation closed notifications.') + return + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # State Notifications + # Note: We do NOT send notifications to providers for investigations + # Send notification to the state where the license investigation was closed + _send_primary_state_notification( + config.email_service_client.send_license_investigation_closed_state_notification_email, + 'license investigation closed', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + # Send notifications to all other states with provider licenses or privileges + _send_additional_state_notifications( + config.email_service_client.send_license_investigation_closed_state_notification_email, + 'license investigation closed', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + logger.info('Successfully processed license investigation closed event') + + +@sqs_handler +def privilege_investigation_notification_listener(message: dict): + """ + Handle privilege investigation events by sending notifications. + + This handler processes 'privilege.investigation' events and sends notifications + to the affected provider and relevant states. + """ + detail_schema = InvestigationEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing privilege investigation event') + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # State Notifications + # Note: We do NOT send notifications to providers for investigations + # Send notification to the state where the privilege is under investigation + _send_primary_state_notification( + config.email_service_client.send_privilege_investigation_state_notification_email, + 'privilege investigation', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + # Send notifications to all other states with provider licenses or privileges + _send_additional_state_notifications( + config.email_service_client.send_privilege_investigation_state_notification_email, + 'privilege investigation', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + logger.info('Successfully processed privilege investigation event') + + +@sqs_handler +def privilege_investigation_closed_notification_listener(message: dict): + """ + Handle privilege investigation closed events by sending notifications. + + This handler processes 'privilege.investigationClosed' events and sends notifications + to the affected provider and relevant states. + """ + detail_schema = InvestigationEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + license_type_abbreviation = detail['licenseTypeAbbreviation'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + event_time=event_time, + ): + logger.info('Processing privilege investigation closed event') + + # If an encumbrance resulted from the investigation, we will let the encumbrance notification suffice. + # This is determined by the presence of an 'adverseActionId' in the event detail. + if detail.get('adverseActionId'): + logger.info('Investigation closed with an encumbrance, skipping investigation closed notifications.') + return + + # Get license type name from abbreviation (lookup once at the top) + license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # State Notifications + # Send notification to the state where the privilege investigation was closed + _send_primary_state_notification( + config.email_service_client.send_privilege_investigation_closed_state_notification_email, + 'privilege investigation closed', + provider_record=provider_record, + provider_id=provider_id, + jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + # Send notifications to all other states with provider licenses or privileges + _send_additional_state_notifications( + config.email_service_client.send_privilege_investigation_closed_state_notification_email, + 'privilege investigation closed', + provider_record=provider_record, + provider_id=provider_id, + excluded_jurisdiction=jurisdiction, + compact=compact, + investigation_jurisdiction=jurisdiction, + license_type=license_type_name, + ) + + logger.info('Successfully processed privilege investigation closed event') diff --git a/backend/social-work-app/lambdas/python/data-events/requirements-dev.in b/backend/social-work-app/lambdas/python/data-events/requirements-dev.in new file mode 100644 index 0000000000..5a61b7b0d2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb, s3]>=5.0.12, <6 diff --git a/backend/social-work-app/lambdas/python/data-events/requirements-dev.txt b/backend/social-work-app/lambdas/python/data-events/requirements-dev.txt new file mode 100644 index 0000000000..4b10e789cf --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/requirements-dev.txt @@ -0,0 +1,64 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/data-events/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/data-events/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/data-events/requirements.in b/backend/social-work-app/lambdas/python/data-events/requirements.in new file mode 100644 index 0000000000..68b7c56e7c --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common requirements.in file diff --git a/backend/social-work-app/lambdas/python/data-events/requirements.txt b/backend/social-work-app/lambdas/python/data-events/requirements.txt new file mode 100644 index 0000000000..7a1fc37aa2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/data-events/requirements.in +# diff --git a/backend/social-work-app/lambdas/python/data-events/tests/__init__.py b/backend/social-work-app/lambdas/python/data-events/tests/__init__.py new file mode 100644 index 0000000000..e58e97b5eb --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/__init__.py @@ -0,0 +1,62 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'false', + 'DATA_EVENT_TABLE_NAME': 'data-event-table', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'BULK_BUCKET_NAME': 'cc-license-data-bulk-bucket', + 'EVENT_BUS_NAME': 'license-data-events', + 'PROVIDER_TABLE_NAME': 'provider-table', + 'RATE_LIMITING_TABLE_NAME': 'rate-limiting-table', + 'EVENT_STATE_TABLE_NAME': 'event-state-table', + 'SSN_TABLE_NAME': 'ssn-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'ENVIRONMENT_NAME': 'test', + 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', + 'FAM_GIV_INDEX_NAME': 'famGiv', + 'LICENSE_GSI_NAME': 'licenseGSI', + 'PROVIDER_USER_POOL_ID': 'us-east-1-12345', + 'USERS_TABLE_NAME': 'staff-users-table', + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': 'email-notification-service-lambda', + 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'SSN_INDEX_NAME': 'ssnIndex', + 'USER_POOL_ID': 'us-east-1-12345', + 'LICENSE_PREPROCESSING_QUEUE_URL': 'license-preprocessing-queue-url', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': """[ + "al", "ak", "az", "ar", "ca", "co", "ct", "de", "dc", "fl", + "ga", "hi", "id", "il", "in", "ia", "ks", "ky", "la", "me", + "md", "ma", "mi", "mn", "ms", "mo", "mt", "ne", "nv", "nh", + "nj", "nm", "ny", "nc", "nd", "oh", "ok", "or", "pa", "pr", + "ri", "sc", "sd", "tn", "tx", "ut", "vt", "va", "vi", "wa", + "wv", "wi", "wy" + ]""", + 'LICENSE_TYPES': json.dumps( + { + 'socw': [ + {'name': 'cosmetologist', 'abbreviation': 'cos'}, + {'name': 'esthetician', 'abbreviation': 'esth'}, + ], + }, + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + from cc_common import config + + cls.config = config._Config() # noqa: SLF001 protected-access + config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/data-events/tests/function/__init__.py b/backend/social-work-app/lambdas/python/data-events/tests/function/__init__.py new file mode 100644 index 0000000000..4ba6588f2e --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/function/__init__.py @@ -0,0 +1,168 @@ +import json +import logging +import os +from decimal import Decimal + +import boto3 +from common_test.test_constants import DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + + # these must be imported within the tests, since they import modules which require + # environment variables that are not set until the TstLambdas class is initialized + from common_test.test_data_generator import TestDataGenerator + + self.test_data_generator = TestDataGenerator() + + self.build_resources() + + # Clear live_compact_jurisdictions cache so handlers read from the compact config table + from cc_common import config as cc_config + + cc_config.config.__dict__.pop('live_compact_jurisdictions', None) + + self.addCleanup(self.delete_resources) + + def build_resources(self): + self.create_data_event_table() + self.create_rate_limit_table() + self.create_event_state_table() + self.create_provider_table() + self.create_compact_configuration_table() + self._load_compact_configuration( + { + 'configuredStates': [ + {'postalAbbreviation': DEFAULT_LICENSE_JURISDICTION, 'isLive': True}, + {'postalAbbreviation': DEFAULT_PRIVILEGE_JURISDICTION, 'isLive': True}, + ], + } + ) + + def create_data_event_table(self): + self._data_event_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['DATA_EVENT_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def create_rate_limit_table(self): + self._rate_limit_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['RATE_LIMITING_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def create_event_state_table(self): + self._event_state_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerId', 'AttributeType': 'S'}, + {'AttributeName': 'eventTime', 'AttributeType': 'S'}, + ], + TableName=os.environ['EVENT_STATE_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': 'providerId-eventTime-index', + 'KeySchema': [ + {'AttributeName': 'providerId', 'KeyType': 'HASH'}, + {'AttributeName': 'eventTime', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + } + ], + ) + + def create_provider_table(self): + self._provider_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerFamGivMid', 'AttributeType': 'S'}, + {'AttributeName': 'providerDateOfUpdate', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSISK', 'AttributeType': 'S'}, + ], + TableName=os.environ['PROVIDER_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['PROV_FAM_GIV_MID_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerFamGivMid', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerDateOfUpdate', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['LICENSE_GSI_NAME'], + 'KeySchema': [ + {'AttributeName': 'licenseGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + + def create_compact_configuration_table(self): + """Create the compact configuration table for testing.""" + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + ) + + def _load_compact_configuration(self, overrides: dict): + """Load compact config so get_live_compact_jurisdictions returns the given live states + (default: license + privilege jurisdictions).""" + with open('../common/tests/resources/dynamo/compact.json') as f: + compact_data = json.load(f, parse_float=Decimal) + compact_data.update(overrides) + self._compact_configuration_table.put_item(Item=compact_data) + + def delete_resources(self): + self._data_event_table.delete() + self._rate_limit_table.delete() + self._event_state_table.delete() + self._provider_table.delete() + self._compact_configuration_table.delete() diff --git a/backend/social-work-app/lambdas/python/data-events/tests/function/test_data_events.py b/backend/social-work-app/lambdas/python/data-events/tests/function/test_data_events.py new file mode 100644 index 0000000000..f43f9888d6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/function/test_data_events.py @@ -0,0 +1,114 @@ +import json +from decimal import Decimal + +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestHandleDataEvents(TstFunction): + def test_handle_data_event(self): + from handlers.data_events import handle_data_events + + with open('tests/resources/message.json') as f: + message = f.read() + + event = {'Records': [{'messageId': '123', 'body': message}]} + + resp = handle_data_events(event, self.mock_context) + + self.assertEqual({'batchItemFailures': []}, resp) + key = { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.validation-error#TIME#1730255454#EVENT#44ec3255-8d59-a6ae-0783-5563a9318a58', + } + saved_event = self._data_event_table.get_item(Key=key)['Item'] + # Drop dynamic value + del saved_event['eventExpiry'] + + self.assertEqual( + { + **key, + 'eventTime': '2024-10-30T02:30:54.586569+00:00', + 'eventType': 'license.validation-error', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'recordNumber': Decimal('4'), + 'errors': {'licenseType': ['Missing data for required field.']}, + 'validData': {}, + }, + saved_event, + ) + + def test_handle_data_event_sanitizes_license_ingest_events(self): + from handlers.data_events import handle_data_events + + # this test file represents a license.ingest event + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = f.read() + + event = {'Records': [{'messageId': '123', 'body': message}]} + + resp = handle_data_events(event, self.mock_context) + + self.assertEqual({'batchItemFailures': []}, resp) + key = { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.ingest#TIME#1720727865#EVENT#44ec3255-8d59-a6ae-0783-5563a9318a58', + } + saved_event = self._data_event_table.get_item(Key=key)['Item'] + # Drop dynamic value + del saved_event['eventExpiry'] + + self.assertEqual( + { + **key, + 'eventTime': '2024-07-11T19:57:45+00:00', + 'eventType': 'license.ingest', + 'compact': 'socw', + 'licenseType': 'cosmetologist', + 'jurisdiction': 'oh', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'dateOfExpiration': '2025-04-04', + 'dateOfIssuance': '2010-06-06', + 'dateOfRenewal': '2020-04-04', + }, + saved_event, + ) + + def test_handle_data_event_with_empty_field(self): + from handlers.data_events import handle_data_events + + with open('tests/resources/message.json') as f: + message = json.load(f) + # Set an error field with an empty string + message['detail']['errors'] = {'': ['Unknown field.']} + + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + resp = handle_data_events(event, self.mock_context) + + self.assertEqual({'batchItemFailures': []}, resp) + key = { + 'pk': 'COMPACT#socw#JURISDICTION#oh', + 'sk': 'TYPE#license.validation-error#TIME#1730255454#EVENT#44ec3255-8d59-a6ae-0783-5563a9318a58', + } + saved_event = self._data_event_table.get_item(Key=key)['Item'] + # Drop dynamic value + del saved_event['eventExpiry'] + + self.assertEqual( + { + **key, + 'eventTime': '2024-10-30T02:30:54.586569+00:00', + 'eventType': 'license.validation-error', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'recordNumber': Decimal('4'), + 'errors': {'': ['Unknown field.']}, + 'validData': {}, + }, + saved_event, + ) diff --git a/backend/social-work-app/lambdas/python/data-events/tests/function/test_encumbrance_events.py b/backend/social-work-app/lambdas/python/data-events/tests/function/test_encumbrance_events.py new file mode 100644 index 0000000000..f1db85b836 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/function/test_encumbrance_events.py @@ -0,0 +1,1171 @@ +import json +import uuid +from datetime import date, datetime +from unittest.mock import ANY, patch +from uuid import UUID + +from common_test.test_constants import ( + DEFAULT_ADVERSE_ACTION_ID, + DEFAULT_CLINICAL_PRIVILEGE_ACTION_CATEGORY, + DEFAULT_COMPACT, + DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + DEFAULT_EFFECTIVE_DATE, + DEFAULT_LICENSE_JURISDICTION, + DEFAULT_LICENSE_TYPE, + DEFAULT_LICENSE_TYPE_ABBREVIATION, + DEFAULT_PRIVILEGE_JURISDICTION, + DEFAULT_PROVIDER_ID, +) +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestEncumbranceEvents(TstFunction): + """Test suite for license encumbrance event handlers.""" + + def _generate_license_encumbrance_message(self, message_overrides=None): + """Generate a test SQS message for license encumbrance events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'effectiveDate': DEFAULT_EFFECTIVE_DATE, + 'adverseActionId': DEFAULT_ADVERSE_ACTION_ID, + 'adverseActionCategory': DEFAULT_CLINICAL_PRIVILEGE_ACTION_CATEGORY, + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _generate_license_encumbrance_lifting_message(self, message_overrides=None): + """Generate a test SQS message for license encumbrance lifting events.""" + return self._generate_license_encumbrance_message(message_overrides) + + def _create_sqs_event(self, message): + """Create a proper SQS event structure with the message in the body.""" + return {'Records': [{'messageId': str(uuid.uuid4()), 'body': json.dumps(message)}]} + + def _generate_privilege_encumbrance_message(self, message_overrides=None): + """Generate a test SQS message for privilege encumbrance events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'effectiveDate': DEFAULT_EFFECTIVE_DATE, + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _generate_privilege_encumbrance_lifting_message(self, message_overrides=None): + """Generate a test SQS message for privilege encumbrance lifting events.""" + return self._generate_privilege_encumbrance_message(message_overrides) + + @patch('cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_state_notification_email') + def test_privilege_encumbrance_listener_processes_event(self, mock_state_email): + """Test that privilege encumbrance listener processes events and sends state notifications.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_privilege_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: encumbered state + other live compact jurisdictions (default: oh, ne) + expected_template_variables_ne = EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_template_variables_oh = EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_state_calls = [ + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'template_variables': expected_template_variables_ne, + }, + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'template_variables': expected_template_variables_oh, + }, + ] + + # Verify all state notifications were sent (encumbered + other live states) + self.assertEqual(2, mock_state_email.call_count) + actual_state_calls = [call.kwargs for call in mock_state_email.call_args_list] + + # Sort both lists for comparison + expected_state_calls_sorted = sorted(expected_state_calls, key=lambda x: x['jurisdiction']) + actual_state_calls_sorted = sorted(actual_state_calls, key=lambda x: x['jurisdiction']) + + self.assertEqual(expected_state_calls_sorted, actual_state_calls_sorted) + + @patch('cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_state_notification_email') + def test_privilege_encumbrance_listener_identifies_notification_jurisdictions(self, mock_state_email): + """Test that privilege encumbrance listener correctly identifies states to notify.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + # The encumbrance occurs in DEFAULT_PRIVILEGE_JURISDICTION ('ne'); live = [oh, ne] + message = self._generate_privilege_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: encumbered + other live compact jurisdictions + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + def test_privilege_encumbrance_listener_handles_provider_retrieval_failure(self): + """Test that privilege encumbrance listener handles provider retrieval failures.""" + from handlers.encumbrance_events import privilege_encumbrance_notification_listener + + # Don't create any provider records - should cause retrieval failure + message = self._generate_privilege_encumbrance_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = privilege_encumbrance_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + expected_failure = {'batchItemFailures': [{'itemIdentifier': event['Records'][0]['messageId']}]} + self.assertEqual(expected_failure, result) + + @patch('cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_state_notification_email') + def test_privilege_encumbrance_listener_excludes_encumbered_jurisdiction_from_notifications(self, mock_state_email): + """Test that the jurisdiction where encumbrance occurred is not duplicated in notifications.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_privilege_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify exactly 2 notifications (encumbered state + other live state; ne appears only once) + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch('cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_state_notification_email') + def test_privilege_encumbrance_listener_notifies_inactive_licenses_and_privileges(self, mock_state_email): + """Test that inactive licenses and privileges generate notifications.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_privilege_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify 2 notifications (encumbered state + other live state) + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_lifting_state_notification_email' + ) + def test_privilege_encumbrance_lifting_notification_listener_processes_event(self, mock_state_email): + """Test that privilege encumbrance lifting listener processes events and sends state notifications.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_lifting_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'effectiveLiftDate': date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + 'jurisdiction': 'ne', + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + message = self._generate_privilege_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: lifting jurisdiction + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_lifting_state_notification_email' + ) + def test_privilege_encumbrance_lifting_notification_listener_identifies_notification_jurisdictions( + self, mock_state_email + ): + """Test that privilege encumbrance lifting listener correctly identifies states to notify + (live compact jurisdictions).""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_lifting_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'effectiveLiftDate': date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + # The encumbrance lifting occurs in DEFAULT_PRIVILEGE_JURISDICTION ('ne'); live = [oh, ne] + message = self._generate_privilege_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: lifting jurisdiction + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_lifting_state_notification_email' + ) + def test_privilege_encumbrance_lifting_notification_listener_determines_latest_effective_lift_date( + self, mock_state_email + ): + """Test that privilege encumbrance lifting listener correctly determines latest effective lift date.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_lifting_notification_listener + + # In this test, a privilege was encumbered, and its associated license was also encumbered. The + # encumbrance was lifted for the privilege first, but it was still encumbered by nature of its license + # being encumbered. The license encumbrance was then lifted, so the latest effective lift date should match + # with the license adverse action effective lift date + privilege_effective_lift_date = date.fromisoformat('2024-05-05') + license_effective_lift_date = date.fromisoformat('2025-06-06') + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'effectiveLiftDate': privilege_effective_lift_date, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + # Create active licenses in multiple jurisdictions (excluding the lifting jurisdiction 'ne') + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': 'co', + 'jurisdictionUploadedLicenseStatus': 'active', + } + ) + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'effectiveLiftDate': license_effective_lift_date, + 'jurisdiction': 'co', + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': 'ky', + 'jurisdictionUploadedLicenseStatus': 'active', + } + ) + + # The encumbrance lifting occurs in DEFAULT_PRIVILEGE_JURISDICTION ('ne') + message = self._generate_privilege_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: lifting jurisdiction + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=license_effective_lift_date, + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_lifting_state_notification_email' + ) + def test_privilege_encumbrance_lifting_notification_listener_determines_latest_license_effective_lift_date_when_no_privilege_encumbrance( # noqa: E501 + self, mock_state_email + ): + """Test that privilege encumbrance lifting listener correctly determines latest effective lift date.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import privilege_encumbrance_lifting_notification_listener + + # In this test, a privilege's associated license was encumbered, so the privilege was encumbered as a result. + # The license encumbrance was then lifted, so the latest effective lift date should match + # with the license adverse action effective lift date + license_effective_lift_date = date.fromisoformat('2025-06-06') + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Create active licenses in multiple jurisdictions (excluding the lifting jurisdiction 'ne') + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': 'co', + 'jurisdictionUploadedLicenseStatus': 'active', + } + ) + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'effectiveLiftDate': license_effective_lift_date, + 'jurisdiction': 'co', + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': 'ky', + 'jurisdictionUploadedLicenseStatus': 'active', + } + ) + + # The encumbrance lifting occurs in DEFAULT_PRIVILEGE_JURISDICTION ('ne') + message = self._generate_privilege_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: lifting jurisdiction + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='ne', + license_type='cosmetologist', + effective_date=license_effective_lift_date, + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + def test_privilege_encumbrance_lifting_notification_listener_handles_provider_retrieval_failure(self): + """Test that privilege encumbrance lifting listener handles provider retrieval failures.""" + from handlers.encumbrance_events import privilege_encumbrance_lifting_notification_listener + + # Don't create any provider records - should cause retrieval failure + message = self._generate_privilege_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = privilege_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + expected_failure = {'batchItemFailures': [{'itemIdentifier': event['Records'][0]['messageId']}]} + self.assertEqual(expected_failure, result) + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_state_notification_email') + def test_license_encumbrance_notification_listener_processes_event(self, mock_state_email): + """Test that license encumbrance notification listener processes events and sends state notifications.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import license_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license that is being encumbered (in DEFAULT_LICENSE_JURISDICTION = 'oh') + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: encumbered + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_state_notification_email') + def test_license_encumbrance_notification_listener_identifies_notification_jurisdictions(self, mock_state_email): + """Test that license encumbrance notification listener correctly identifies states to notify.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import license_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license that is being encumbered (in DEFAULT_LICENSE_JURISDICTION = 'oh') + self.test_data_generator.put_default_license_record_in_provider_table() + + # The encumbrance occurs in DEFAULT_LICENSE_JURISDICTION ('oh'); live = [oh, ne] + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: encumbered + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + def test_license_encumbrance_notification_listener_handles_provider_retrieval_failure(self): + """Test that license encumbrance notification listener handles provider retrieval failures.""" + from handlers.encumbrance_events import license_encumbrance_notification_listener + + # Don't create any provider records - should cause retrieval failure + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = license_encumbrance_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + expected_failure = {'batchItemFailures': [{'itemIdentifier': event['Records'][0]['messageId']}]} + self.assertEqual(expected_failure, result) + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_state_notification_email') + def test_license_encumbrance_notification_listener_excludes_encumbered_jurisdiction_from_notifications( + self, mock_state_email + ): + """Test that the jurisdiction where license encumbrance occurred is not duplicated in notifications.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import license_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license that is being encumbered (in DEFAULT_LICENSE_JURISDICTION = 'oh') + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify exactly 2 notifications (encumbered state + other live state; oh appears only once) + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_state_notification_email') + def test_license_encumbrance_notification_listener_notifies_all_licenses_and_privileges_including_inactive( + self, mock_state_email + ): + """Test that all licenses and privileges generate notifications, including inactive ones.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import license_encumbrance_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license that is being encumbered (in DEFAULT_LICENSE_JURISDICTION = 'oh') + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify 2 notifications (encumbered + other live state) + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_lifting_state_notification_email' + ) + def test_license_encumbrance_lifting_notification_listener_processes_event(self, mock_state_email): + """Test that license encumbrance lifting notification listener processes events.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import license_encumbrance_lifting_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license where encumbrance is being lifted (in DEFAULT_LICENSE_JURISDICTION = 'oh') + self.test_data_generator.put_default_license_record_in_provider_table() + + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'effectiveLiftDate': date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + message = self._generate_license_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: lifting jurisdiction + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + call.kwargs['template_variables'], + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_lifting_state_notification_email' + ) + def test_license_encumbrance_lifting_notification_listener_identifies_notification_jurisdictions( + self, mock_state_email + ): + """Test that license encumbrance lifting notification listener correctly identifies states to notify.""" + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + from handlers.encumbrance_events import license_encumbrance_lifting_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license where encumbrance is being lifted (in DEFAULT_LICENSE_JURISDICTION = 'oh') + self.test_data_generator.put_default_license_record_in_provider_table() + + # Create active licenses in multiple jurisdictions (excluding the lifting jurisdiction 'oh') + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': 'ne', + 'jurisdictionUploadedLicenseStatus': 'active', + } + ) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': 'ky', + 'jurisdictionUploadedLicenseStatus': 'active', + } + ) + + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'effectiveLiftDate': date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + # The encumbrance lifting occurs in DEFAULT_LICENSE_JURISDICTION ('oh'); live = [oh, ne] + message = self._generate_license_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: lifting jurisdiction + other live states + self.assertEqual(2, mock_state_email.call_count) + + calls = mock_state_email.call_args_list + call_jurisdictions = [call.kwargs['jurisdiction'] for call in calls] + self.assertEqual(sorted(call_jurisdictions), ['ne', 'oh']) + + # Verify all calls have the correct template_variables structure + for call in calls: + self.assertEqual(call.kwargs['compact'], DEFAULT_COMPACT) + self.assertEqual( + EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + call.kwargs['template_variables'], + ) + + def test_license_encumbrance_lifting_notification_listener_handles_provider_retrieval_failure(self): + """Test that license encumbrance lifting notification listener handles provider retrieval failures.""" + from handlers.encumbrance_events import license_encumbrance_lifting_notification_listener + + # Don't create any provider records - should cause retrieval failure + message = self._generate_license_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = license_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + expected_failure = {'batchItemFailures': [{'itemIdentifier': event['Records'][0]['messageId']}]} + self.assertEqual(expected_failure, result) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_lifting_state_notification_email' + ) + @patch('cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_state_notification_email') + def test_license_encumbrance_notification_listeners_handle_no_additional_jurisdictions( + self, mock_enc_state, mock_lift_state + ): + """ + Test that license encumbrance notification listeners handle case where compact has only one live + jurisdiction (no additional states to notify). + """ + from cc_common.config import config + from handlers.encumbrance_events import ( + license_encumbrance_lifting_notification_listener, + license_encumbrance_notification_listener, + ) + + # Only one live jurisdiction so no "additional" state notifications + config.__dict__['live_compact_jurisdictions'] = {DEFAULT_COMPACT: [DEFAULT_LICENSE_JURISDICTION]} + try: + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license that is being encumbered/lifted (in DEFAULT_LICENSE_JURISDICTION = 'oh') + self.test_data_generator.put_default_license_record_in_provider_table() + + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'effectiveLiftDate': date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + + # Test license encumbrance notification + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + result = license_encumbrance_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Test license encumbrance lifting notification + message = self._generate_license_encumbrance_lifting_message() + event = self._create_sqs_event(message) + result = license_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify license encumbrance notifications: only state 'oh' (no additional live jurisdictions) + from cc_common.email_service_client import EncumbranceNotificationTemplateVariables + + mock_enc_state.assert_called_once_with( + compact=DEFAULT_COMPACT, + jurisdiction='oh', + template_variables=EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + + # Verify license lifting notifications: only state 'oh' + mock_lift_state.assert_called_once_with( + compact=DEFAULT_COMPACT, + jurisdiction='oh', + template_variables=EncumbranceNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + encumbered_jurisdiction='oh', + license_type='cosmetologist', + effective_date=date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + provider_id=UUID(DEFAULT_PROVIDER_ID), + ), + ) + finally: + config.__dict__.pop('live_compact_jurisdictions', None) + + def _when_testing_privilege_lift_handler_with_encumbered_privilege(self, encumbered_status, mock_state_email): + from cc_common.data_model.schema.common import PrivilegeEncumberedStatusEnum + from handlers.encumbrance_events import privilege_encumbrance_lifting_notification_listener + + # Set up test data: provider and a license so find_best_license_in_current_known_licenses succeeds + self.test_data_generator.put_default_provider_record_in_provider_table() + + if encumbered_status == PrivilegeEncumberedStatusEnum.ENCUMBERED: + # Privilege still encumbered: privilege adverse action with no effectiveLiftDate + # (handler returns early and sends no notifications) + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + self.test_data_generator.put_default_license_record_in_provider_table() + else: + # License still encumbered: privilege adverse action lifted, but license encumbered + # (handler passes privilege check then skips due to license encumberedStatus) + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'effectiveLiftDate': date.fromisoformat(DEFAULT_EFFECTIVE_DATE), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'licenseType': DEFAULT_LICENSE_TYPE, + } + ) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={'encumberedStatus': 'encumbered'} + ) + + # Generate privilege encumbrance lifting event + message = self._generate_privilege_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures (handler completes successfully) + self.assertEqual({'batchItemFailures': []}, result) + + # Verify NO notifications were sent because privilege is still encumbered + mock_state_email.assert_not_called() + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_lifting_state_notification_email' + ) + def test_privilege_encumbrance_lifting_notification_listener_skips_notifications_when_privilege_still_encumbered( + self, mock_state_email + ): + """Test that privilege encumbrance lifting notifications are NOT sent when privilege is still encumbered.""" + from cc_common.data_model.schema.common import PrivilegeEncumberedStatusEnum + + self._when_testing_privilege_lift_handler_with_encumbered_privilege( + PrivilegeEncumberedStatusEnum.ENCUMBERED, mock_state_email + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_encumbrance_lifting_state_notification_email' + ) + def test_privilege_encumbrance_lifting_notification_listener_skips_notifications_when_privilege_license_encumbered( + self, mock_state_email + ): + """Test that privilege encumbrance lifting notifications are NOT sent when privilege is LICENSE_ENCUMBERED.""" + from cc_common.data_model.schema.common import PrivilegeEncumberedStatusEnum + + self._when_testing_privilege_lift_handler_with_encumbered_privilege( + PrivilegeEncumberedStatusEnum.LICENSE_ENCUMBERED, mock_state_email + ) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_lifting_state_notification_email' + ) + def test_license_encumbrance_lifting_notification_listener_skips_notifications_when_license_still_encumbered( + self, mock_state_email + ): + """Test that license encumbrance lifting notifications are NOT sent when license is still encumbered.""" + from handlers.encumbrance_events import license_encumbrance_lifting_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Create a license that is still ENCUMBERED (has another adverse action) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'encumberedStatus': 'encumbered', # Still encumbered due to another adverse action + } + ) + + # Generate license encumbrance lifting event + message = self._generate_license_encumbrance_lifting_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_encumbrance_lifting_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures (handler completes successfully) + self.assertEqual({'batchItemFailures': []}, result) + + # Verify NO notifications were sent because license is still encumbered + mock_state_email.assert_not_called() + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_state_notification_email') + def test_license_encumbrance_notification_listener_skips_already_sent_notifications_and_retries_failed( + self, mock_state_email + ): + """ + Test that license encumbrance notification listener skips notifications that were already sent successfully + and only retries notifications that failed in a previous attempt. + """ + from cc_common.event_state_client import EventType, NotificationTracker, RecipientType + from handlers.encumbrance_events import license_encumbrance_notification_listener + + # Set up test data (live = [oh, ne]; primary is oh, additional is ne) + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + + # Mock previous attempt where the notification to oh succeeded and to ne failed + tracker = NotificationTracker(compact=DEFAULT_COMPACT, message_id=event['Records'][0]['messageId']) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=DEFAULT_PROVIDER_ID, + event_type=EventType.LICENSE_ENCUMBRANCE, + event_time=DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + jurisdiction=DEFAULT_LICENSE_JURISDICTION, + ) + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=DEFAULT_PROVIDER_ID, + event_type=EventType.LICENSE_ENCUMBRANCE, + event_time=DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + error_message='something failed', + jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + ) + + # Execute listener, which should only retry notification to ne + license_encumbrance_notification_listener(event, self.mock_context) + + # Re-instantiate tracker to get latest notification attempts + updated_tracker = NotificationTracker(compact=DEFAULT_COMPACT, message_id=event['Records'][0]['messageId']) + + notification_records = updated_tracker._attempts # noqa: SLF001 + + # ne should now be SUCCESS + self.assertEqual('SUCCESS', notification_records['NOTIFICATION#state#ne']['status']) + + # Verify only the email to ne was sent (retry) + mock_state_email.assert_called_once_with( + compact=DEFAULT_COMPACT, + jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + template_variables=ANY, + ) + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_encumbrance_state_notification_email') + def test_license_encumbrance_notification_listener_creates_notification_events_to_track_successful_notifications( + self, + mock_state_email, # noqa: ARG002 + ): + """ + Test that license encumbrance notification listener stores successful notification events for tracking in the + event of handler retries. + """ + from cc_common.event_state_client import NotificationStatus, NotificationTracker + from handlers.encumbrance_events import license_encumbrance_notification_listener + + # Set up test data (live = [oh, ne]) + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_encumbrance_message() + event = self._create_sqs_event(message) + + # Execute + license_encumbrance_notification_listener(event, self.mock_context) + + # Re-instantiate tracker to get latest notification attempts + updated_tracker = NotificationTracker(compact=DEFAULT_COMPACT, message_id=event['Records'][0]['messageId']) + + notification_records = updated_tracker._attempts # noqa: SLF001 + + expected_sks = [ + 'NOTIFICATION#state#ne', + 'NOTIFICATION#state#oh', + ] + + self.assertEqual(expected_sks, sorted(notification_records.keys())) + for sk in expected_sks: + self.assertEqual(NotificationStatus.SUCCESS, notification_records.get(sk).get('status')) diff --git a/backend/social-work-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py b/backend/social-work-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py new file mode 100644 index 0000000000..fe190d48ea --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py @@ -0,0 +1,91 @@ +import json +from datetime import datetime +from unittest.mock import patch +from uuid import UUID + +from common_test.test_constants import ( + DEFAULT_COMPACT, + DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + DEFAULT_LICENSE_JURISDICTION, + DEFAULT_LICENSE_TYPE, + DEFAULT_PROVIDER_ID, +) +from moto import mock_aws + +from . import TstFunction + +TEST_FORMER_LICENSE_JURISDICTION = 'az' + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestHomeStateChangeEvents(TstFunction): + """Test suite for investigation event handlers.""" + + def _generate_license_home_state_change_message(self, message_overrides=None): + """Generate a test SQS message for license home state change events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'formerHomeJurisdiction': TEST_FORMER_LICENSE_JURISDICTION, + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _create_sqs_event(self, message): + """Create a proper SQS event structure with the message in the body.""" + return {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + @patch('cc_common.email_service_client.EmailServiceClient.send_provider_home_state_change_email') + def test_license_homes_state_change_listener_sends_notification_to_former_state(self, mock_state_email): + """Test that license home state change listener sends an email notification to the former state.""" + from cc_common.email_service_client import HomeJurisdictionChangeNotificationTemplateVariables + from handlers.home_state_change_events import home_state_change_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license for the former home state + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={'jurisdiction': TEST_FORMER_LICENSE_JURISDICTION} + ) + # Add license for the current home state + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_home_state_change_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = home_state_change_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + expected_template_variables = HomeJurisdictionChangeNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + former_jurisdiction=TEST_FORMER_LICENSE_JURISDICTION, + current_jurisdiction=DEFAULT_LICENSE_JURISDICTION, + license_type=DEFAULT_LICENSE_TYPE, + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_state_call = [ + { + 'compact': DEFAULT_COMPACT, + # we only send to the former home state + 'jurisdiction': TEST_FORMER_LICENSE_JURISDICTION, + 'template_variables': expected_template_variables, + }, + ] + + # Verify state notification was sent + self.assertEqual(1, mock_state_email.call_count) + actual_state_calls = [call.kwargs for call in mock_state_email.call_args_list] + + self.assertEqual(expected_state_call, actual_state_calls) diff --git a/backend/social-work-app/lambdas/python/data-events/tests/function/test_investigation_events.py b/backend/social-work-app/lambdas/python/data-events/tests/function/test_investigation_events.py new file mode 100644 index 0000000000..75bb79612a --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/function/test_investigation_events.py @@ -0,0 +1,475 @@ +import json +from datetime import datetime +from unittest.mock import patch +from uuid import UUID, uuid4 + +from common_test.test_constants import ( + DEFAULT_COMPACT, + DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + DEFAULT_LICENSE_JURISDICTION, + DEFAULT_LICENSE_TYPE_ABBREVIATION, + DEFAULT_PRIVILEGE_JURISDICTION, + DEFAULT_PROVIDER_ID, +) +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestInvestigationEvents(TstFunction): + """Test suite for investigation event handlers.""" + + def _generate_license_investigation_message(self, message_overrides=None): + """Generate a test SQS message for license investigation events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'license', + 'investigationId': str(uuid4()), + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _generate_license_investigation_closed_message(self, message_overrides=None): + """Generate a test SQS message for license investigation closed events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'license', + 'investigationId': str(uuid4()), + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _generate_privilege_investigation_message(self, message_overrides=None): + """Generate a test SQS message for privilege investigation events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'privilege', + 'investigationId': str(uuid4()), + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _generate_privilege_investigation_closed_message(self, message_overrides=None): + """Generate a test SQS message for privilege investigation closed events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'privilege', + 'investigationId': str(uuid4()), + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _create_sqs_event(self, message): + """Create a proper SQS event structure with the message in the body.""" + return {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_investigation_state_notification_email') + def test_license_investigation_listener_processes_event_with_provider(self, mock_state_email): + """Test that license investigation listener processes events for provider.""" + from cc_common.email_service_client import InvestigationNotificationTemplateVariables + from handlers.investigation_events import license_investigation_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + + # Add the license that is under investigation + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_investigation_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_investigation_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: investigation jurisdiction + other live compact jurisdictions from compact config + # Default test setup has live = [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION] = [oh, ne] + expected_template_variables_oh = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_LICENSE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_template_variables_ne = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_LICENSE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_state_calls = [ + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'template_variables': expected_template_variables_oh, + }, + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'template_variables': expected_template_variables_ne, + }, + ] + + # Verify all state notifications were sent (investigation state + other live states) + self.assertEqual(2, mock_state_email.call_count) + actual_state_calls = [call.kwargs for call in mock_state_email.call_args_list] + + # Sort both lists for comparison + expected_state_calls_sorted = sorted(expected_state_calls, key=lambda x: x['jurisdiction']) + actual_state_calls_sorted = sorted(actual_state_calls, key=lambda x: x['jurisdiction']) + + self.assertEqual(expected_state_calls_sorted, actual_state_calls_sorted) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_license_investigation_closed_state_notification_email' + ) + def test_license_investigation_closed_listener_processes_event_with_provider(self, mock_state_email): + """Test that license investigation closed listener processes events for provider.""" + from cc_common.email_service_client import InvestigationNotificationTemplateVariables + from handlers.investigation_events import license_investigation_closed_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + + # Add the license that was under investigation + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_investigation_closed_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = license_investigation_closed_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: investigation jurisdiction + other live compact jurisdictions + expected_template_variables_oh = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_LICENSE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_template_variables_ne = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_LICENSE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_state_calls = [ + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'template_variables': expected_template_variables_oh, + }, + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'template_variables': expected_template_variables_ne, + }, + ] + + # Verify all state notifications were sent (investigation state + other live states) + self.assertEqual(2, mock_state_email.call_count) + actual_state_calls = [call.kwargs for call in mock_state_email.call_args_list] + + # Sort both lists for comparison + expected_state_calls_sorted = sorted(expected_state_calls, key=lambda x: x['jurisdiction']) + actual_state_calls_sorted = sorted(actual_state_calls, key=lambda x: x['jurisdiction']) + + self.assertEqual(expected_state_calls_sorted, actual_state_calls_sorted) + + @patch('cc_common.email_service_client.EmailServiceClient.send_privilege_investigation_state_notification_email') + def test_privilege_investigation_listener_processes_event_with_provider(self, mock_state_email): + """Test that privilege investigation listener processes events for provider.""" + from cc_common.email_service_client import InvestigationNotificationTemplateVariables + from handlers.investigation_events import privilege_investigation_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_privilege_investigation_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_investigation_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: investigation jurisdiction + other live compact jurisdictions + expected_template_variables_ne = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_template_variables_oh = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_state_calls = [ + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'template_variables': expected_template_variables_ne, + }, + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'template_variables': expected_template_variables_oh, + }, + ] + + # Verify all state notifications were sent (investigation state + other live states) + self.assertEqual(2, mock_state_email.call_count) + actual_state_calls = [call.kwargs for call in mock_state_email.call_args_list] + + # Sort both lists for comparison + expected_state_calls_sorted = sorted(expected_state_calls, key=lambda x: x['jurisdiction']) + actual_state_calls_sorted = sorted(actual_state_calls, key=lambda x: x['jurisdiction']) + + self.assertEqual(expected_state_calls_sorted, actual_state_calls_sorted) + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_investigation_closed_state_notification_email' + ) + def test_privilege_investigation_closed_listener_processes_event_with_provider(self, mock_state_email): + """Test that privilege investigation closed listener processes events for provider.""" + from cc_common.email_service_client import InvestigationNotificationTemplateVariables + from handlers.investigation_events import privilege_investigation_closed_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_privilege_investigation_closed_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_investigation_closed_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify state notifications: investigation jurisdiction + other live compact jurisdictions + expected_template_variables_ne = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_template_variables_oh = InvestigationNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + investigation_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + license_type='cosmetologist', + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_state_calls = [ + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'template_variables': expected_template_variables_ne, + }, + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'template_variables': expected_template_variables_oh, + }, + ] + + # Verify all state notifications were sent (investigation state + other live states) + self.assertEqual(2, mock_state_email.call_count) + actual_state_calls = [call.kwargs for call in mock_state_email.call_args_list] + + # Sort both lists for comparison + expected_state_calls_sorted = sorted(expected_state_calls, key=lambda x: x['jurisdiction']) + actual_state_calls_sorted = sorted(actual_state_calls, key=lambda x: x['jurisdiction']) + + self.assertEqual(expected_state_calls_sorted, actual_state_calls_sorted) + + def test_license_investigation_listener_handles_missing_provider_records(self): + """Test that license investigation listener handles missing provider records gracefully.""" + from handlers.investigation_events import license_investigation_notification_listener + + # Don't set up any test data - provider records will be missing + message = self._generate_license_investigation_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = license_investigation_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + self.assertEqual(result['batchItemFailures'][0]['itemIdentifier'], '123') + + def test_privilege_investigation_listener_handles_missing_provider_records(self): + """Test that privilege investigation listener handles missing provider records gracefully.""" + from handlers.investigation_events import privilege_investigation_notification_listener + + # Don't set up any test data - provider records will be missing + message = self._generate_privilege_investigation_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = privilege_investigation_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + self.assertEqual(result['batchItemFailures'][0]['itemIdentifier'], '123') + + @patch('cc_common.email_service_client.EmailServiceClient.send_license_investigation_state_notification_email') + def test_license_investigation_listener_handles_email_service_failure(self, mock_state_email): + """Test that license investigation listener handles email service failures gracefully.""" + from handlers.investigation_events import license_investigation_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + self.test_data_generator.put_default_license_record_in_provider_table() + + # Make the email service raise an exception + mock_state_email.side_effect = Exception('Email service failure') + + message = self._generate_license_investigation_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = license_investigation_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + self.assertEqual(result['batchItemFailures'][0]['itemIdentifier'], '123') + + @patch('cc_common.email_service_client.EmailServiceClient.send_privilege_investigation_state_notification_email') + def test_privilege_investigation_listener_handles_email_service_failure(self, mock_state_email): + """Test that privilege investigation listener handles email service failures gracefully.""" + from handlers.investigation_events import privilege_investigation_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + self.test_data_generator.put_default_license_record_in_provider_table() + + # Make the email service raise an exception + mock_state_email.side_effect = Exception('Email service failure') + + message = self._generate_privilege_investigation_message() + event = self._create_sqs_event(message) + + # SQS handler wrapper catches exceptions and returns batch item failures + result = privilege_investigation_notification_listener(event, self.mock_context) + + # Should return batch item failure for the message + self.assertEqual(result['batchItemFailures'][0]['itemIdentifier'], '123') + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_license_investigation_closed_state_notification_email' + ) + def test_license_investigation_closed_listener_skips_notifications_when_encumbrance_exists(self, mock_state_email): + """ + Test that license investigation closed listener does NOT send notifications when adverseActionId is present. + When an investigation closes with an encumbrance, we rely on the encumbrance notification instead. + """ + from handlers.investigation_events import license_investigation_closed_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + self.test_data_generator.put_default_license_record_in_provider_table() + + # Create message with adverseActionId (indicating an encumbrance was created) + message = self._generate_license_investigation_closed_message() + message['detail']['adverseActionId'] = str(uuid4()) + event = self._create_sqs_event(message) + + # Execute the handler + result = license_investigation_closed_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify NO notifications were sent (encumbrance notification will handle it) + mock_state_email.assert_not_called() + + @patch( + 'cc_common.email_service_client.EmailServiceClient.send_privilege_investigation_closed_state_notification_email' + ) + def test_privilege_investigation_closed_listener_skips_notifications_when_encumbrance_exists( + self, mock_state_email + ): + """ + Test that privilege investigation closed listener does NOT send notifications when adverseActionId is present. + When an investigation closes with an encumbrance, we rely on the encumbrance notification instead. + """ + from handlers.investigation_events import privilege_investigation_closed_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'compactConnectRegisteredEmailAddress': 'provider@example.com'} + ) + self.test_data_generator.put_default_license_record_in_provider_table() + + # Create message with adverseActionId (indicating an encumbrance was created) + message = self._generate_privilege_investigation_closed_message() + message['detail']['adverseActionId'] = str(uuid4()) + event = self._create_sqs_event(message) + + # Execute the handler + result = privilege_investigation_closed_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify NO notifications were sent (encumbrance notification will handle it) + mock_state_email.assert_not_called() diff --git a/backend/social-work-app/lambdas/python/data-events/tests/resources/events.json b/backend/social-work-app/lambdas/python/data-events/tests/resources/events.json new file mode 100644 index 0000000000..e585ad2bc0 --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/resources/events.json @@ -0,0 +1,82 @@ +[ + { + "eventTime": "2024-10-30T04:47:55.843000+00:00", + "validData": {}, + "errors": { + "dateOfRenewal": [ + "Not a valid date." + ] + }, + "sk": "JURISDICTION#oh#TIME#1730263675#EVENT#182d8d8b-7fee-6e0c-2e3c-1189a47d5a0c", + "compact": "socw", + "eventType": "license.validation-error", + "jurisdiction": "oh", + "recordNumber": 5, + "pk": "COMPACT#socw#TYPE#license.validation-error" + }, + { + "eventTime": "2024-10-30T04:47:55.843000+00:00", + "validData": {}, + "errors": { + "licenseType": "'licenseType' must be one of ['cosmetologist', 'esthetician']" + }, + "sk": "JURISDICTION#oh#TIME#1730263675#EVENT#3128fbc9-d1fb-7bf7-5fbc-35ec700aaae8", + "compact": "socw", + "eventType": "license.validation-error", + "jurisdiction": "oh", + "recordNumber": 3, + "pk": "COMPACT#socw#TYPE#license.validation-error" + }, + { + "eventTime": "2024-10-30T04:47:55.843000+00:00", + "validData": {}, + "errors": { + "licenseType": "'licenseType' must be one of ['cosmetologist', 'esthetician']" + }, + "sk": "JURISDICTION#oh#TIME#1730263675#EVENT#871f86a4-c668-8fee-1a9a-21a4d5b3fb8f", + "compact": "socw", + "eventType": "license.validation-error", + "jurisdiction": "oh", + "recordNumber": 2, + "pk": "COMPACT#socw#TYPE#license.validation-error" + }, + { + "eventTime": "2024-10-30T04:47:55.843000+00:00", + "validData": {}, + "errors": { + "status": [ + "Must be one of: active, inactive." + ] + }, + "sk": "JURISDICTION#oh#TIME#1730263675#EVENT#b6ccb0f1-515f-9642-2c0e-9406a73d77e0", + "compact": "socw", + "eventType": "license.validation-error", + "jurisdiction": "oh", + "recordNumber": 4, + "pk": "COMPACT#socw#TYPE#license.validation-error" + }, + { + "eventTime": "2024-10-30T04:47:55.843000+00:00", + "validData": {}, + "errors": { + "licenseType": "'licenseType' must be one of ['cosmetologist', 'esthetician']" + }, + "sk": "JURISDICTION#oh#TIME#1730263675#EVENT#d9d03f28-2e29-cede-2e7f-c4b684c315fc", + "compact": "socw", + "eventType": "license.validation-error", + "jurisdiction": "oh", + "recordNumber": 1, + "pk": "COMPACT#socw#TYPE#license.validation-error" + }, + { + "eventTime": "2024-10-30T04:48:05.976000+00:00", + "errors": [ + "'utf-8' codec can't decode byte 0x83 in position 0: invalid start byte" + ], + "sk": "JURISDICTION#oh#TIME#1730263685#EVENT#d6fbe045-4a9c-a76a-ad46-6dbe6b78c800", + "compact": "socw", + "eventType": "license.ingest-failure", + "jurisdiction": "oh", + "pk": "COMPACT#socw#TYPE#license.ingest-failure" + } +] diff --git a/backend/social-work-app/lambdas/python/data-events/tests/resources/message.json b/backend/social-work-app/lambdas/python/data-events/tests/resources/message.json new file mode 100644 index 0000000000..dd598f2d3a --- /dev/null +++ b/backend/social-work-app/lambdas/python/data-events/tests/resources/message.json @@ -0,0 +1,22 @@ +{ + "version": "0", + "id": "44ec3255-8d59-a6ae-0783-5563a9318a58", + "detail-type": "license.validation-error", + "source": "org.compactconnect.bulk-ingest.socw/oh/1234", + "account": "000000000000", + "time": "2024-07-11T19:57:45Z", + "region": "us-east-1", + "resources": [], + "detail": { + "eventTime": "2024-10-30T02:30:54.586569+00:00", + "compact": "socw", + "jurisdiction": "oh", + "recordNumber": 4, + "validData": {}, + "errors": { + "licenseType": [ + "Missing data for required field." + ] + } + } +} diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/handlers/__init__.py b/backend/social-work-app/lambdas/python/disaster-recovery/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/handlers/cleanup_records.py b/backend/social-work-app/lambdas/python/disaster-recovery/handlers/cleanup_records.py new file mode 100644 index 0000000000..ca64926bd8 --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/handlers/cleanup_records.py @@ -0,0 +1,104 @@ +import time + +import boto3 +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError +from cc_common.config import logger + + +def cleanup_records(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + As part of synchronizing tables during a DR event, we clear the current records from the target + table to put it in a clean state. After which the next step in the recovery process will copy over all the + existing records from the recovery point table into the target table. + + In the event that the deletion process takes longer than the 15-minute time limit window for lambda, we return a + 'deleteStatus' field of 'IN_PROGRESS', causing the step function to loop around and continue the cleanup process. + If all the records have been cleaned up, we return a 'deleteStatus' of 'COMPLETE', causing the step function to + proceed to the copy step. + """ + start_time = time.time() + max_execution_time = 12 * 60 # 12 minutes in seconds + + # Get destination table ARN from event + destination_table_arn = event['destinationTableArn'] + + # Extract table name from ARN (format: arn:aws:dynamodb:region:account:table/table-name) + table_name = destination_table_arn.split('/')[-1] + + # Guard rail: ensure explicit table name was passed in by caller + specified_table_name = event.get('tableNameRecoveryConfirmation') + if specified_table_name != table_name: + logger.error('DR execution guard flag missing or invalid') + return { + 'deleteStatus': 'FAILED', + 'error': f'Invalid table name specified. tableNameRecoveryConfirmation field must be set to {table_name}', + } + + logger.info(f'Starting cleanup of records from table: {table_name}') + + # Initialize DynamoDB resource + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(table_name) + + total_deleted = event.get('deletedCount', 0) + + last_evaluated_key = None + + try: + while True: + # Check if we're approaching the time limit + current_time = time.time() + elapsed_time = current_time - start_time + + if elapsed_time > max_execution_time: + logger.info(f'Approaching time limit after {elapsed_time:.2f} seconds. Returning IN_PROGRESS status.') + # Note we don't return the last evaluated key here, since we are deleting all records, the lambda + # can just resume the scan on the next iteration without the need for a key. + return { + 'deleteStatus': 'IN_PROGRESS', + 'deletedCount': total_deleted, + # pass this through so it is available for following steps + 'destinationTableArn': destination_table_arn, + 'sourceTableArn': event['sourceTableArn'], + 'tableNameRecoveryConfirmation': event['tableNameRecoveryConfirmation'], + } + + # Scan the table to get records to delete + scan_kwargs = {'Limit': 2000} + + if last_evaluated_key: + scan_kwargs['ExclusiveStartKey'] = last_evaluated_key + + response = table.scan(**scan_kwargs) + items = response.get('Items', []) + + # Delete items using batch_writer + with table.batch_writer() as batch: + for item in items: + # Extract the key attributes (pk and sk) + key = {'pk': item['pk'], 'sk': item['sk']} + batch.delete_item(Key=key) + total_deleted += 1 + + logger.info(f'Deleted batch of {len(items)} records. Total deleted so far: {total_deleted}') + + # Update last_evaluated_key for next iteration + last_evaluated_key = response.get('LastEvaluatedKey') + + # If no more pages, we're done + if not last_evaluated_key: + logger.info(f'Cleanup complete. Total records deleted: {total_deleted}') + return { + 'deleteStatus': 'COMPLETE', + 'deletedCount': total_deleted, + # pass this through so it is available for following steps + 'destinationTableArn': destination_table_arn, + 'sourceTableArn': event['sourceTableArn'], + 'tableNameRecoveryConfirmation': event['tableNameRecoveryConfirmation'], + } + + except ClientError as e: + logger.error(f'Error during cleanup: {str(e)}') + # raise exception so step function will retry + raise e diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/handlers/copy_records.py b/backend/social-work-app/lambdas/python/disaster-recovery/handlers/copy_records.py new file mode 100644 index 0000000000..5c71d4f5fd --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/handlers/copy_records.py @@ -0,0 +1,190 @@ +import json +import os +import time +from base64 import b64decode, b64encode + +import boto3 +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError +from cc_common.config import logger + + +def encrypt_pagination_key(key_data: dict, kms_key_id: str) -> str: + """Encrypt pagination key using KMS to prevent SSN exposure in logs. + + In the event that there are so many records to recover that it will take longer + than the 15-minute timeout period of lambdas, we paginate by sending the last + evaluated key in the output, looping around the step function, and then continuing + the process from where we left off. In the case of SSN records, we need to encrypt + the output of this lambda if pagination occurs so that the values are not readable + in the step function logs. + + :param dict key_data: The pagination key dictionary to encrypt. + :param str kms_key_id: The KMS key ID to use for encryption. + :returns: Base64-encoded encrypted pagination key. + :rtype: str + :raises ClientError: If KMS encryption fails. + """ + try: + kms_client = boto3.client('kms') + plaintext = json.dumps(key_data).encode('utf-8') + response = kms_client.encrypt(KeyId=kms_key_id, Plaintext=plaintext) + return b64encode(response['CiphertextBlob']).decode('utf-8') + except ClientError as e: + logger.error(f'Failed to encrypt pagination key: {str(e)}') + raise + + +def decrypt_pagination_key(encrypted_key: str, kms_key_id: str) -> dict: + """Decrypt pagination key using KMS. + + In the event that there are so many records to recover that it will take longer + than the 15-minute timeout period of lambdas, we paginate by sending the last + evaluated key in the output, looping around the step function, and then continuing + the process from where we left off. In the case of SSN records, we need to decrypt + the input of this lambda to be able to determine what was the last key we should + continue with. + + :param str encrypted_key: Base64-encoded encrypted pagination key + :param str kms_key_id: The KMS key ID used for decrypting the lambda input + :returns: Decrypted pagination key dictionary + :rtype: dict + :raises ClientError: If KMS decryption fails + :raises ValueError: If decrypted data is not valid JSON + """ + try: + kms_client = boto3.client('kms') + ciphertext = b64decode(encrypted_key.encode('utf-8')) + response = kms_client.decrypt(CiphertextBlob=ciphertext, KeyId=kms_key_id) + return json.loads(response['Plaintext'].decode('utf-8')) + except ClientError as e: + logger.error(f'Failed to decrypt pagination key with KMS key {kms_key_id}: {str(e)}') + raise + + +def copy_records(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + As part of synchronizing tables during a DR event, we copy all records from the restored + table (source) to the original table (destination) to complete the data synchronization. + + In the event that the copy process takes longer than the 15-minute time limit window for lambda, we return a + 'copyStatus' field of 'IN_PROGRESS', causing the step function to loop around and continue the copy process using + the lastEvaluatedKey found in the response. + If all the records have been copied, we return a 'copyStatus' of 'COMPLETE', causing the step function to + complete the sync workflow. + """ + start_time = time.time() + max_execution_time = 12 * 60 # 12 minutes in seconds + + # Check if SSN encryption is enabled via environment variable + ssn_encryption_key_id = os.environ.get('SSN_ENCRYPTION_KMS_KEY_ID') + is_ssn_table = ssn_encryption_key_id is not None + + if is_ssn_table: + logger.info('SSN encryption enabled for disaster recovery') + + # Get source and destination table ARNs from event + source_table_arn = event['sourceTableArn'] + destination_table_arn = event['destinationTableArn'] + + # Extract table names from ARNs (format: arn:aws:dynamodb:region:account:table/table-name) + source_table_name = source_table_arn.split('/')[-1] + destination_table_name = destination_table_arn.split('/')[-1] + + # Guard rail: ensure explicit table name was passed in by caller + specified_table_name = event.get('tableNameRecoveryConfirmation') + if specified_table_name != destination_table_name: + logger.error('DR execution guard flag missing or invalid') + return { + 'copyStatus': 'FAILED', + 'error': f'Invalid table name specified. ' + f'tableNameRecoveryConfirmation field must be set to {destination_table_name}', + } + + # Get any pagination key from previous execution + last_evaluated_key = event.get('copyLastEvaluatedKey') + if last_evaluated_key is not None: + try: + if is_ssn_table: + # Decrypt the pagination key for SSN tables + last_evaluated_key = decrypt_pagination_key(last_evaluated_key, ssn_encryption_key_id) + else: + # Use existing base64 decoding for non-SSN tables + last_evaluated_key = json.loads(b64decode(last_evaluated_key).decode('utf-8')) + except (ClientError, ValueError) as e: + logger.error(f'Failed to process pagination key: {str(e)}') + return { + 'copyStatus': 'FAILED', + 'error': f'Failed to process pagination key: {str(e)}', + } + + logger.info(f'Starting copy of records from {source_table_name} to {destination_table_name}') + if last_evaluated_key: + logger.info('Last evaluated key found. Continuing copy from last evaluated key.') + + # Initialize DynamoDB resource + dynamodb = boto3.resource('dynamodb') + source_table = dynamodb.Table(source_table_name) + destination_table = dynamodb.Table(destination_table_name) + + total_copied = event.get('copiedCount', 0) + + try: + while True: + # Check if we're approaching the time limit + current_time = time.time() + elapsed_time = current_time - start_time + + if elapsed_time > max_execution_time: + logger.info(f'Approaching time limit after {elapsed_time:.2f} seconds. Returning IN_PROGRESS status.') + + # Encrypt pagination key for SSN tables, use base64 encoding for others + if is_ssn_table and last_evaluated_key: + encrypted_key = encrypt_pagination_key(last_evaluated_key, ssn_encryption_key_id) + else: + encrypted_key = b64encode(json.dumps(last_evaluated_key).encode('utf-8')).decode('utf-8') + + return { + 'copyStatus': 'IN_PROGRESS', + 'copyLastEvaluatedKey': encrypted_key, + 'copiedCount': total_copied, + 'sourceTableArn': source_table_arn, + 'destinationTableArn': destination_table_arn, + 'tableNameRecoveryConfirmation': event['tableNameRecoveryConfirmation'], + } + + # Scan the source table to get records to copy + scan_kwargs = {'Limit': 2000} + + if last_evaluated_key: + scan_kwargs['ExclusiveStartKey'] = last_evaluated_key + + response = source_table.scan(**scan_kwargs) + items = response.get('Items', []) + + # Copy items to destination table using batch_writer + with destination_table.batch_writer() as batch: + for item in items: + batch.put_item(Item=item) + total_copied += 1 + + logger.info(f'Copied batch of {len(items)} records. Total copied so far: {total_copied}') + + # Update last_evaluated_key for next iteration + last_evaluated_key = response.get('LastEvaluatedKey') + + # If no more pages, we're done + if not last_evaluated_key: + logger.info(f'Copy complete. Total records copied: {total_copied}') + return { + 'copyStatus': 'COMPLETE', + 'copiedCount': total_copied, + 'sourceTableArn': source_table_arn, + 'destinationTableArn': destination_table_arn, + 'tableNameRecoveryConfirmation': event['tableNameRecoveryConfirmation'], + } + + except ClientError as e: + logger.error(f'Error during copy: {str(e)}') + # raise exception so step function will retry + raise e diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/handlers/rollback_license_upload.py b/backend/social-work-app/lambdas/python/disaster-recovery/handlers/rollback_license_upload.py new file mode 100644 index 0000000000..bf4fd471bf --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/handlers/rollback_license_upload.py @@ -0,0 +1,918 @@ +import json +import time +from dataclasses import dataclass, field +from datetime import datetime + +from aws_lambda_powertools.utilities.typing import LambdaContext +from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError +from cc_common.config import config, logger +from cc_common.data_model.provider_record_util import ProviderRecordUtility, ProviderUserRecords +from cc_common.data_model.schema.common import LICENSE_UPLOAD_UPDATE_CATEGORIES +from cc_common.data_model.schema.license import LicenseData +from cc_common.data_model.schema.provider import ProviderData +from cc_common.data_model.update_tier_enum import UpdateTierEnum +from cc_common.event_batch_writer import EventBatchWriter +from cc_common.exceptions import CCInternalException, CCNotFoundException +from marshmallow import ValidationError + +# Maximum time window for rollback (1 week in seconds) +# this is set as a safety net to prevent accidental rollback over large time period +# it can be modified if needed +MAX_ROLLBACK_WINDOW_SECONDS = 7 * 24 * 60 * 60 + + +class ProviderRollbackFailedException(Exception): + """Custom exception that is thrown when a provider fails to rollback""" + + def __init__(self, message: str): + self.message = message + super().__init__(message) + + +# Data classes for rollback operations +@dataclass +class IneligibleUpdate: + """Represents an update that makes a provider ineligible for rollback.""" + + record_type: str # 'licenseUpdate' + type_of_update: str + update_time: str + reason: str + license_type: str | None = None # License type if applicable + + +@dataclass +class ProviderSkippedDetails: + """Details for a provider that was skipped.""" + + provider_id: str + reason: str + ineligible_updates: list[IneligibleUpdate] = field(default_factory=list) + + +@dataclass +class ProviderFailedDetails: + """Details for a provider that failed to revert.""" + + provider_id: str + error: str + + +@dataclass +class RevertedLicense: + """Details of a reverted license for event publishing.""" + + jurisdiction: str + license_type: str + action: str + + +@dataclass +class ProviderRevertedSummary: + """Summary for a provider that was successfully reverted.""" + + provider_id: str + licenses_reverted: list[RevertedLicense] = field(default_factory=list) + updates_deleted: list[str] = field(default_factory=list) # List of SKs for deleted update records + + +@dataclass +class RollbackResults: + """Complete results of a rollback operation.""" + + execution_name: str + skipped_provider_details: list[ProviderSkippedDetails] = field(default_factory=list) + failed_provider_details: list[ProviderFailedDetails] = field(default_factory=list) + reverted_provider_summaries: list[ProviderRevertedSummary] = field(default_factory=list) + + def to_dict(self) -> dict: + """Convert to dictionary for S3 storage.""" + return { + 'executionName': self.execution_name, + 'skippedProviderDetails': [ + { + 'providerId': detail.provider_id, + 'reason': detail.reason, + 'ineligibleUpdates': [ + { + 'recordType': update.record_type, + 'typeOfUpdate': update.type_of_update, + 'updateTime': update.update_time, + 'reason': update.reason, + 'licenseType': update.license_type, + } + for update in detail.ineligible_updates + ], + } + for detail in self.skipped_provider_details + ], + 'failedProviderDetails': [ + { + 'providerId': detail.provider_id, + 'error': detail.error, + } + for detail in self.failed_provider_details + ], + 'revertedProviderSummaries': [ + { + 'providerId': str(summary.provider_id), + 'licensesReverted': [ + { + 'jurisdiction': license_record.jurisdiction, + 'licenseType': license_record.license_type, + 'action': license_record.action, + } + for license_record in summary.licenses_reverted + ], + 'updatesDeleted': summary.updates_deleted, + } + for summary in self.reverted_provider_summaries + ], + } + + @classmethod + def from_dict(cls, data: dict) -> 'RollbackResults': + """Create from dictionary loaded from S3.""" + return cls( + execution_name=data['executionName'], + skipped_provider_details=[ + ProviderSkippedDetails( + provider_id=detail['providerId'], + reason=detail['reason'], + ineligible_updates=[ + IneligibleUpdate( + record_type=update['recordType'], + type_of_update=update['typeOfUpdate'], + update_time=update['updateTime'], + reason=update['reason'], + license_type=update['licenseType'], + ) + for update in detail.get('ineligibleUpdates', []) + ], + ) + for detail in data.get('skippedProviderDetails', []) + ], + failed_provider_details=[ + ProviderFailedDetails( + provider_id=detail['providerId'], + error=detail['error'], + ) + for detail in data.get('failedProviderDetails', []) + ], + reverted_provider_summaries=[ + ProviderRevertedSummary( + provider_id=summary['providerId'], + licenses_reverted=[ + RevertedLicense( + jurisdiction=reverted_license['jurisdiction'], + license_type=reverted_license['licenseType'], + action=reverted_license['action'], + ) + for reverted_license in summary.get('licensesReverted', []) + ], + updates_deleted=summary.get('updatesDeleted', []), + ) + for summary in data.get('revertedProviderSummaries', []) + ], + ) + + +def rollback_license_upload(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Rollback invalid license uploads for a compact/jurisdiction/time window. + + This function queries the licenseUploadDateGSI to find all affected records, validates + rollback eligibility, reverts records to their pre-upload state, and publishes events. + Results are written to S3 to avoid state management in the step function. + + Input event structure: + { + 'compact': 'socw', + 'jurisdiction': 'oh', + 'startDateTime': '2024-01-01T00:00:00Z', + 'endDateTime': '2024-01-01T23:59:59Z', + 'rollbackReason': 'Invalid data uploaded', + 'executionName': 'unique-execution-id', + 'providersProcessed': 0, + 'continueFromProviderId': None + } + + Returns: + { + 'rollbackStatus': 'IN_PROGRESS' | 'COMPLETE', + 'providersProcessed': int, + 'providersReverted': int, + 'providersSkipped': int, + 'providersFailed': int, + 'continueFromProviderId': str | None, + } + """ + execution_start_time = time.time() + max_execution_time = 12 * 60 # 12 minutes in seconds + + # Extract and validate input parameters + compact = event['compact'] + jurisdiction = event['jurisdiction'] + start_datetime_str = event['startDateTime'] + end_datetime_str = event['endDateTime'] + rollback_reason = event['rollbackReason'] + execution_name = event['executionName'] + providers_processed = event.get('providersProcessed', 0) + continue_from_provider_id = event.get('continueFromProviderId') + + # Parse and validate datetime parameters + try: + start_datetime = datetime.fromisoformat(start_datetime_str) + end_datetime = datetime.fromisoformat(end_datetime_str) + except ValueError as e: + logger.error(f'Invalid datetime format: {str(e)}') + return { + 'rollbackStatus': 'FAILED', + 'error': f'Invalid datetime format: {str(e)}', + } + + # Validate time window + if start_datetime >= end_datetime: + logger.error('Start time must be before end time') + return { + 'rollbackStatus': 'FAILED', + 'error': 'Start time must be before end time', + } + + time_window_seconds = (end_datetime - start_datetime).total_seconds() + if time_window_seconds > MAX_ROLLBACK_WINDOW_SECONDS: + logger.error(f'Time window exceeds maximum of {MAX_ROLLBACK_WINDOW_SECONDS / 86400} days') + return { + 'rollbackStatus': 'FAILED', + 'error': f'Time window cannot exceed {MAX_ROLLBACK_WINDOW_SECONDS / 86400} days', + } + + logger.info( + 'Starting license upload rollback', + compact=compact, + jurisdiction=jurisdiction, + start_datetime=start_datetime_str, + end_datetime=end_datetime_str, + execution_name=execution_name, + ) + + # Initialize S3 client and bucket + results_s3_key = f'licenseUploadRollbacks/{execution_name}/results.json' + + # Load existing results if this is a continuation + existing_results = _load_results_from_s3(results_s3_key, execution_name) + + # Initialize counters + providers_reverted = len(existing_results.reverted_provider_summaries) + providers_skipped = len(existing_results.skipped_provider_details) + providers_failed = len(existing_results.failed_provider_details) + + try: + # Query GSI for affected records across the time window + affected_provider_ids = _query_gsi_for_affected_providers( + compact, + jurisdiction, + start_datetime, + end_datetime, + ) + + # Convert to sorted list for consistent ordering across invocations + affected_provider_ids_list = sorted(affected_provider_ids) + + # If continuing from a previous invocation, slice the list to start from that provider + if continue_from_provider_id: + try: + start_index = affected_provider_ids_list.index(continue_from_provider_id) + affected_provider_ids_list = affected_provider_ids_list[start_index:] + logger.info( + f'Continuing from provider {continue_from_provider_id} (index {start_index}). ' + f'{len(affected_provider_ids_list)} providers remaining to process.' + ) + except ValueError: + # Provider ID in event input not found in list + # Log error and raise exception + logger.error( + f'Continue-from provider {continue_from_provider_id} not found in affected providers list.', + continue_from_provider_id=continue_from_provider_id, + affected_provider_ids_list=affected_provider_ids_list, + ) + raise + + # Process each provider + for provider_id in affected_provider_ids_list: + # Check time limit + elapsed_time = time.time() - execution_start_time + if elapsed_time > max_execution_time: + logger.info(f'Approaching time limit after {elapsed_time:.2f} seconds. Returning IN_PROGRESS status.') + + # Write current results to S3 + _write_results_to_s3(results_s3_key, existing_results) + + return { + 'rollbackStatus': 'IN_PROGRESS', + 'providersProcessed': providers_processed, + 'providersReverted': providers_reverted, + 'providersSkipped': providers_skipped, + 'providersFailed': providers_failed, + 'continueFromProviderId': provider_id, # Continue from next provider + 'compact': compact, + 'jurisdiction': jurisdiction, + 'startDateTime': start_datetime_str, + 'endDateTime': end_datetime_str, + 'rollbackReason': rollback_reason, + 'executionName': execution_name, + } + + # Process the provider + result = _process_provider_rollback( + provider_id=provider_id, + compact=compact, + jurisdiction=jurisdiction, + start_datetime=start_datetime, + end_datetime=end_datetime, + rollback_reason=rollback_reason, + execution_name=execution_name, + ) + + providers_processed += 1 + + # Update results based on outcome + if isinstance(result, ProviderRevertedSummary): + providers_reverted += 1 + existing_results.reverted_provider_summaries.append(result) + logger.info('Provider reverted successfully', provider_id=provider_id) + elif isinstance(result, ProviderSkippedDetails): + providers_skipped += 1 + existing_results.skipped_provider_details.append(result) + logger.info('Provider skipped due to ineligibility', provider_id=provider_id) + elif isinstance(result, ProviderFailedDetails): + providers_failed += 1 + existing_results.failed_provider_details.append(result) + logger.info('Provider failed to revert', provider_id=provider_id, error=result.error) + + logger.info( + 'processed provider', + total_providers_processed=providers_processed, + providers_reverted=providers_reverted, + providers_skipped=providers_skipped, + providers_failed=providers_failed, + ) + + # All providers processed successfully + logger.info( + 'Rollback complete', + providers_processed=providers_processed, + providers_skipped=providers_skipped, + providers_reverted=providers_reverted, + providers_failed=providers_failed, + ) + + # Write final results to S3 + _write_results_to_s3(results_s3_key, existing_results) + + return { + 'rollbackStatus': 'COMPLETE', + 'providersProcessed': providers_processed, + 'providersReverted': providers_reverted, + 'providersSkipped': providers_skipped, + 'providersFailed': providers_failed, + 'resultsS3Key': f's3://{config.disaster_recovery_results_bucket_name}/{results_s3_key}', + } + + except ClientError as e: + logger.error(f'Error during rollback: {str(e)}') + raise + + +def _query_gsi_for_affected_providers( + compact: str, + jurisdiction: str, + start_datetime: datetime, + end_datetime: datetime, +) -> set[str]: + """ + Query the licenseUploadDateGSI to find all affected provider IDs. + + Since the time window might span multiple months, we need to query each month separately. + """ + affected_provider_ids = set() + + # Generate list of year-month strings to query + current_date = start_datetime.replace(day=1) + end_month = end_datetime.replace(day=1) + + year_months = [] + while current_date <= end_month: + year_months.append(current_date.strftime('%Y-%m')) + # Move to next month + if current_date.month == 12: + current_date = current_date.replace(year=current_date.year + 1, month=1) + else: + current_date = current_date.replace(month=current_date.month + 1) + + start_epoch = int(start_datetime.timestamp()) + end_epoch = int(end_datetime.timestamp()) + + # Query each month + for year_month in year_months: + gsi_pk = f'C#{compact.lower()}#J#{jurisdiction.lower()}#D#{year_month}' + + query_kwargs = { + 'IndexName': config.license_upload_date_index_name, + 'KeyConditionExpression': ( + Key('licenseUploadDateGSIPK').eq(gsi_pk) + & Key('licenseUploadDateGSISK').between(f'TIME#{start_epoch}#', f'TIME#{end_epoch}#~') + ), + } + + while True: + response = config.provider_table.query(**query_kwargs) + + # Extract provider IDs from the results + for item in response.get('Items', []): + # The providerId is in the SK: TIME#{epoch}#LT#{license_type}#PID#{provider_id} + provider_id = item['providerId'] + affected_provider_ids.add(provider_id) + + # Check for pagination + last_evaluated_key = response.get('LastEvaluatedKey') + if not last_evaluated_key: + break + + query_kwargs['ExclusiveStartKey'] = last_evaluated_key + + logger.info(f'Found {len(affected_provider_ids)} unique providers affected by upload window') + return affected_provider_ids + + +def _process_provider_rollback( + provider_id: str, + compact: str, + jurisdiction: str, + start_datetime: datetime, + end_datetime: datetime, + rollback_reason: str, + execution_name: str, +) -> ProviderRevertedSummary | ProviderSkippedDetails | ProviderFailedDetails: + """ + Process rollback for a single provider. + + Returns one of: + - ProviderRevertedSummary: If provider was successfully reverted + - ProviderSkippedDetails: If provider was skipped due to ineligibility + - ProviderFailedDetails: If an error occurred during processing + """ + logger.info('Processing provider rollback', provider_id=provider_id) + + try: + # Build transactions and check eligibility in a single pass + # If ineligible updates are found, this will return a ProviderSkippedDetails + result = _build_and_execute_revert_transactions( + upload_window_start_datetime=start_datetime, + upload_window_end_datetime=end_datetime, + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + ) + + # If provider was skipped due to ineligibility, return early + if isinstance(result, ProviderSkippedDetails): + return result + except ProviderRollbackFailedException as e: # noqa BLE001 + logger.error('Error processing provider rollback', provider_id=provider_id, exc_info=e) + return ProviderFailedDetails( + provider_id=provider_id, + error=f'Failed to rollback updates for provider. Manual review required: {str(e)}', + ) + + # Publish events for successful rollback + _publish_revert_events(result, compact, rollback_reason, start_datetime, end_datetime, execution_name) + return result + + +def _extract_sk_from_transaction_item(transaction_item: dict) -> str | None: + """ + Extract the sort key (SK) from a transaction item. + + Transaction items can be Put, Delete, or Update operations. + Returns the SK if found, None otherwise. + """ + if 'Put' in transaction_item: + return transaction_item['Put']['Item'].get('sk') + if 'Delete' in transaction_item: + return transaction_item['Delete']['Key'].get('sk') + if 'Update' in transaction_item: + return transaction_item['Update']['Key'].get('sk') + return None + + +def _perform_transaction(transaction_items: list[dict], provider_id: str) -> None: + logger.info(f'Executing {len(transaction_items)} transaction items in batches of 100') + + for i in range(0, len(transaction_items), 100): + batch = transaction_items[i : i + 100] + # Use Table resource's client for automatic type conversion + try: + config.provider_table.meta.client.transact_write_items(TransactItems=batch) + logger.info(f'Executed batch {i // 100 + 1} with {len(batch)} items') + except ClientError as e: + # Extract all SKs from the failed transaction batch for debugging + failed_sks = [_extract_sk_from_transaction_item(item) for item in batch] + # filter out null values + failed_sks = [sk for sk in failed_sks if sk is not None] + + logger.error( + 'Transaction batch failed for provider', + provider_id=provider_id, + batch_number=i // 100 + 1, + batch_size=len(batch), + failed_sks=failed_sks, + error=str(e), + ) + raise ProviderRollbackFailedException(message=str(e)) from e + + +def _check_for_orphaned_update_records( + provider_records: ProviderUserRecords, +) -> IneligibleUpdate | None: + """ + Check if there are any license update records without associated top-level license records. + + :param provider_records: The provider's records + :return: IneligibleUpdate if orphaned updates are found, None otherwise + """ + # Get all license update records + all_license_updates = provider_records.get_all_license_update_records() + + # Extract unique (jurisdiction, license_type) pairs from update records + license_keys_from_updates: set[tuple[str, str]] = set() + + for update in all_license_updates: + license_keys_from_updates.add((update.jurisdiction, update.licenseType)) + + # Check if each license key has a corresponding top-level license record + for license_jurisdiction, license_type in license_keys_from_updates: + # Try to find the license record + license_record = next( + ( + record + for record in provider_records.get_license_records() + if record.jurisdiction == license_jurisdiction and record.licenseType == license_type + ), + None, + ) + + if license_record is None: + # Found an orphaned update record + return IneligibleUpdate( + record_type='licenseUpdate', + type_of_update='Orphaned', + update_time='N/A', + license_type=license_type, + reason=f'License update record(s) exist for license in jurisdiction ' + f'{license_jurisdiction} with type {license_type}, but no corresponding top-level ' + f'license record was found. This indicates data inconsistency. Manual review required.', + ) + + return None + + +def _build_and_execute_revert_transactions( + upload_window_start_datetime: datetime, + upload_window_end_datetime: datetime, + compact: str, + jurisdiction: str, + provider_id: str, +) -> ProviderRevertedSummary | ProviderSkippedDetails: + """ + Build and execute DynamoDB transactions to revert provider records. + + This function processes all records in a single pass: + - Checks eligibility (returns ProviderSkippedDetails if ineligible) + - Builds transaction items + - Executes transactions + + Returns either a summary of what was reverted or details about why the provider was skipped. + """ + # Split transaction lists into first tier/second tier lists (license/provider first tier, updates second) + # then merge the two lists into a single list of transaction items + primary_record_transaction_items = [] # License and provider records + update_record_transactions_items = [] # Update records (license updates) + table_name = config.provider_table_name + reverted_licenses = [] + updates_deleted_sks = [] # List of SKs for deleted update records + ineligible_updates: list[IneligibleUpdate] = [] + + # Helper functions for cleaner item building + def add_put(item: dict, update_record: bool): + """ + Add a Put operation to the appropriate list. + + :param item: The item to put + :param update_record: True if the item is an update record, False if it is a primary record + """ + transaction_item = { + 'Put': { + 'TableName': table_name, + 'Item': item, + } + } + if update_record: + update_record_transactions_items.append(transaction_item) + else: + primary_record_transaction_items.append(transaction_item) + + def add_delete(pk: str, sk: str, update_record: bool): + """ + Add a Delete operation. + + :param pk: Partition key + :param sk: Sort key - used to determine if this is an update record + :param update_record: True if the item is an update record, False if it is a primary record + """ + transaction_item = { + 'Delete': { + 'TableName': table_name, + 'Key': {'pk': pk, 'sk': sk}, + } + } + if update_record: + update_record_transactions_items.append(transaction_item) + else: + primary_record_transaction_items.append(transaction_item) + + # Fetch all provider records including all update tiers + try: + provider_records = config.data_client.get_provider_user_records( + compact=compact, + provider_id=provider_id, + # tier three includes all update records for the provider + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + except ValidationError as e: + logger.info('provider record data failed schema validation. Skipping provider', exc_info=e) + raise ProviderRollbackFailedException(message=f'Validation error: {str(e)}') from e + + # Step 1: Check for license update records without top-level license records + orphaned_update_check = _check_for_orphaned_update_records(provider_records) + if orphaned_update_check is not None: + ineligible_updates.append(orphaned_update_check) + + # Step 2: Process each license record for the jurisdiction + license_records = provider_records.get_license_records(filter_condition=lambda x: x.jurisdiction == jurisdiction) + + for license_record in license_records: + # Get license updates for this license after start_datetime + license_updates_after_start = provider_records.get_update_records_for_license( + jurisdiction=license_record.jurisdiction, + license_type=license_record.licenseType, + filter_condition=lambda x: x.createDate >= upload_window_start_datetime, + ) + + # check license updates for eligibility + license_updates_in_window = [] + for license_update in license_updates_after_start: + if ( + license_update.updateType not in LICENSE_UPLOAD_UPDATE_CATEGORIES + or license_update.createDate > upload_window_end_datetime + ): + # Non-upload-related license updates make provider ineligible + ineligible_updates.append( + IneligibleUpdate( + record_type='licenseUpdate', + type_of_update=license_update.updateType, + update_time=license_update.createDate.isoformat(), + license_type=license_update.licenseType, + reason='License was updated with a change unrelated to license upload or the update ' + 'occurred after rollback end time. Manual review required.', + ) + ) + else: + # Upload-related update within window - mark for deletion + license_updates_in_window.append(license_update) + serialized_license_update = license_update.serialize_to_database_record() + add_delete(serialized_license_update['pk'], serialized_license_update['sk'], update_record=True) + updates_deleted_sks.append(serialized_license_update['sk']) + logger.info( + 'Will delete license update record if provider is eligible for rollback', + update_type=license_update.updateType, + license_type=license_update.licenseType, + ) + + # if license record was created during the window, delete it + if ( + license_record.firstUploadDate is not None + and upload_window_start_datetime <= license_record.firstUploadDate <= upload_window_end_datetime + ): + serialized_license_record = license_record.serialize_to_database_record() + add_delete(serialized_license_record['pk'], serialized_license_record['sk'], update_record=False) + logger.info('Will delete license record (created during upload) if provider is eligible for rollback') + reverted_licenses.append( + RevertedLicense( + jurisdiction=license_record.jurisdiction, + license_type=license_record.licenseType, + action='DELETE', + ) + ) + # license was not first uploaded during the upload window, revert it to last previous state before the upload + else: + # if the provider is ineligible for rollback, the list of license updates may be empty, and we need to + # defensively check for that here and continue to the next license + if not license_updates_in_window: + continue + + # Find the earliest update in the window to get the previous state + license_updates_in_window.sort(key=lambda x: x.createDate) + earliest_update_in_window = license_updates_in_window[0] + + # License existed before - revert to previous state + reverted_license_data = license_record.to_dict() + reverted_license_data.update(earliest_update_in_window.previous) + + reverted_license = LicenseData.create_new(reverted_license_data) + serialized_reverted_license = reverted_license.serialize_to_database_record() + + add_put(serialized_reverted_license, update_record=False) + logger.info('Reverting license record to pre-upload state') + + reverted_licenses.append( + RevertedLicense( + jurisdiction=license_record.jurisdiction, + license_type=license_record.licenseType, + action='REVERT', + ) + ) + + # Check if provider is ineligible for rollback + if ineligible_updates: + logger.info( + 'Provider not eligible for automatic rollback', + provider_id=provider_id, + ineligible_updates=ineligible_updates, + ) + return ProviderSkippedDetails( + provider_id=provider_id, + reason='Provider has updates that are either unrelated to license upload or occurred after' + ' rollback end time. Manual review required.', + ineligible_updates=ineligible_updates, + ) + + # process primary records first, then update records + transaction_items = primary_record_transaction_items + update_record_transactions_items + + if not transaction_items: + # This should never happen, as it means that somehow the GSI query returned this provider id within + # the search results, but the provider was not either skipped over or had something to revert as we expect. + # If we do get here, we will exit the lambda in a failed state, as there is something unexpected happening that + # needs to be investigated before we attempt to roll back any other providers. + message = ( + 'No transaction items to execute for provider. This is an unexpected state that should be ' + 'investigated before attempting to roll back any other providers' + ) + logger.error(message, provider_id=provider_id) + raise CCInternalException(message=f'{message} provider_id: {provider_id}') + + _perform_transaction(transaction_items, provider_id) + try: + # Now read all the license records for the provider and update the provider record + provider_records_after_rollback = config.data_client.get_provider_user_records( + compact=compact, provider_id=provider_id + ) + top_level_provider_record: ProviderData = provider_records_after_rollback.get_provider_record() + except (CCNotFoundException, CCInternalException) as e: + # This would most likely happen if the top level provider record was somehow deleted by another process. + # We don't ever expect to get into this state, so we are going to let this bubble to the top and end the entire + # process, to ensure we are not putting the system into a worse state. + logger.error( + 'Expected top level provider record not found after rollback. ' + 'Ending workflow to prevent risk of data corruption.', + provider_id=provider_id, + exc_info=e, + ) + raise + + # Create a new list for provider record updates (all first tier items) + primary_record_transaction_items.clear() + + try: + best_license = provider_records_after_rollback.find_best_license_in_current_known_licenses() + updated_provider_record = ProviderRecordUtility.populate_provider_record( + current_provider_record=top_level_provider_record, + license_record=best_license.to_dict(), + ) + add_put(updated_provider_record.serialize_to_database_record(), update_record=False) + except CCNotFoundException: + # All licenses for the provider were removed as part of the rollback, meaning the provider + # needs to be removed as well. We first check to make sure there are no other record types + if len(provider_records_after_rollback.provider_records) > 1: + # We never expect this to happen, since license records should not have been removed if there were any + # privilege or other non-upload records found for the provider. If we hit this case, we will end the + # entire process to ensure we are not putting the system into a worse state. + message = ( + 'No licenses found for provider after rollback, but other record types still exist. ' + 'Killing process to prevent potential data corruption.' + ) + logger.error(message, provider_id=provider_id) + raise CCInternalException(message=str(message)) # noqa: B904 + + logger.info('Only top level provider record found. Deleting record', provider_id=provider_id) + serialized_provider_record = top_level_provider_record.serialize_to_database_record() + add_delete(pk=serialized_provider_record['pk'], sk=serialized_provider_record['sk'], update_record=False) + + _perform_transaction(primary_record_transaction_items, provider_id) + + logger.info( + 'Completed rollback for provider', + provider_id=provider_id, + licenses_reverted=reverted_licenses, + updates_deleted=updates_deleted_sks, + ) + return ProviderRevertedSummary( + provider_id=provider_id, + licenses_reverted=reverted_licenses, + updates_deleted=updates_deleted_sks, + ) + + +def _publish_revert_events( + revert_summary: ProviderRevertedSummary, + compact: str, + rollback_reason: str, + start_datetime: datetime, + end_datetime: datetime, + execution_name: str, +): + """ + Publish revert events for all reverted licenses. + + :param revert_summary: Summary of reverted provider records + :param compact: The compact name + :param rollback_reason: The reason for the rollback + :param start_datetime: The start time of the rollback window + :param end_datetime: The end time of the rollback window + :param execution_name: The execution name for the rollback operation + """ + with EventBatchWriter(config.events_client) as event_writer: + # Publish license revert events + for reverted_license in revert_summary.licenses_reverted: + try: + config.event_bus_client.publish_license_revert_event( + source='org.compactconnect.disaster-recovery', + compact=compact, + provider_id=revert_summary.provider_id, + jurisdiction=reverted_license.jurisdiction, + license_type=reverted_license.license_type, + rollback_reason=rollback_reason, + start_time=start_datetime, + end_time=end_datetime, + execution_name=execution_name, + event_batch_writer=event_writer, + ) + except Exception as e: # noqa BLE001 + # this event publishing is not business critical, so we log the error and move on + logger.error( + 'Unable to publish license revert event', + compact=compact, + provider_id=revert_summary.provider_id, + jurisdiction=reverted_license.jurisdiction, + license_type=reverted_license.license_type, + rollback_reason=rollback_reason, + start_time=start_datetime, + end_time=end_datetime, + error=str(e), + ) + + +def _load_results_from_s3(key: str, execution_name: str) -> RollbackResults: + """Load existing results from S3.""" + try: + response = config.s3_client.get_object(Bucket=config.disaster_recovery_results_bucket_name, Key=key) + data = json.loads(response['Body'].read().decode('utf-8')) + return RollbackResults.from_dict(data) + except config.s3_client.exceptions.NoSuchKey: + # First execution, no existing results + return RollbackResults(execution_name=execution_name) + except Exception as e: + logger.error(f'Error loading results from S3: {str(e)}') + raise + + +def _write_results_to_s3(key: str, results: RollbackResults): + """Write results to S3 with server-side encryption.""" + try: + config.s3_client.put_object( + Bucket=config.disaster_recovery_results_bucket_name, + Key=key, + Body=json.dumps(results.to_dict(), indent=2), + ContentType='application/json', + ) + logger.info('Results written to S3', bucket=config.disaster_recovery_results_bucket_name, key=key) + # handle json serialization errors + except TypeError as e: + logger.error(f'Error writing results to S3: {str(e)}') + raise + # handle other errors by logging the full object and raising the exception + except Exception as e: + logger.error(f'Error writing results to S3: {str(e)}', results=results.to_dict()) + raise diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/requirements-dev.in b/backend/social-work-app/lambdas/python/disaster-recovery/requirements-dev.in new file mode 100644 index 0000000000..5a61b7b0d2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb, s3]>=5.0.12, <6 diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/requirements-dev.txt b/backend/social-work-app/lambdas/python/disaster-recovery/requirements-dev.txt new file mode 100644 index 0000000000..8336576cc6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/requirements-dev.txt @@ -0,0 +1,64 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/disaster-recovery/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/disaster-recovery/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/requirements.in b/backend/social-work-app/lambdas/python/disaster-recovery/requirements.in new file mode 100644 index 0000000000..68b7c56e7c --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common requirements.in file diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/requirements.txt b/backend/social-work-app/lambdas/python/disaster-recovery/requirements.txt new file mode 100644 index 0000000000..9ad49d395f --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/disaster-recovery/requirements.in +# diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/tests/__init__.py b/backend/social-work-app/lambdas/python/disaster-recovery/tests/__init__.py new file mode 100644 index 0000000000..68d65f67ef --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/tests/__init__.py @@ -0,0 +1,105 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'true', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'DISASTER_RECOVERY_RESULTS_BUCKET_NAME': 'rollback-results-bucket', + 'EVENT_BUS_NAME': 'license-data-events', + 'PROVIDER_TABLE_NAME': 'provider-table', + 'RATE_LIMITING_TABLE_NAME': 'rate-limiting-table', + 'SSN_TABLE_NAME': 'ssn-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'ENVIRONMENT_NAME': 'test', + 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', + 'FAM_GIV_INDEX_NAME': 'famGiv', + 'LICENSE_GSI_NAME': 'licenseGSI', + 'LICENSE_UPLOAD_DATE_INDEX_NAME': 'licenseUploadDateGSI', + 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'SSN_INDEX_NAME': 'ssnIndex', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': json.dumps( + [ + 'al', + 'ak', + 'az', + 'ar', + 'ca', + 'co', + 'ct', + 'de', + 'dc', + 'fl', + 'ga', + 'hi', + 'id', + 'il', + 'in', + 'ia', + 'ks', + 'ky', + 'la', + 'me', + 'md', + 'ma', + 'mi', + 'mn', + 'ms', + 'mo', + 'mt', + 'ne', + 'nv', + 'nh', + 'nj', + 'nm', + 'ny', + 'nc', + 'nd', + 'oh', + 'ok', + 'or', + 'pa', + 'pr', + 'ri', + 'sc', + 'sd', + 'tn', + 'tx', + 'ut', + 'vt', + 'va', + 'vi', + 'wa', + 'wv', + 'wi', + 'wy', + ] + ), + 'LICENSE_TYPES': json.dumps( + { + 'socw': [ + {'name': 'cosmetologist', 'abbreviation': 'cos'}, + {'name': 'esthetician', 'abbreviation': 'esth'}, + ], + }, + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/__init__.py b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/__init__.py new file mode 100644 index 0000000000..4cf83bde7f --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/__init__.py @@ -0,0 +1,129 @@ +import logging +import os + +import boto3 +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + self.mock_destination_table_name = 'Test-PersistentStack-ProviderTableEC5D0597-TQ2RIO6VVBRE' + self.mock_destination_table_arn = ( + f'arn:aws:dynamodb:us-east-1:767398110685:table/{self.mock_destination_table_name}' + ) + self.mock_source_table_name = 'Recovered-ProviderTableEC5D0597-TQ2RIO6VVBRE' + self.mock_source_table_arn = f'arn:aws:dynamodb:us-east-1:767398110685:table/{self.mock_source_table_name}' + self.build_resources() + + # these must be imported within the tests, since they import modules which require + # environment variables that are not set until the TstLambdas class is initialized + import cc_common.config + from common_test.test_data_generator import TestDataGenerator + + cc_common.config.config = cc_common.config._Config() # noqa: SLF001 protected-access + self.config = cc_common.config.config + self.test_data_generator = TestDataGenerator + + self.addCleanup(self.delete_resources) + + def build_resources(self): + # in the case of DR, the lambda sync solution should be table agnostic, since we are performing the same + # cleanup and restoration process regardless of the table that is being recovered + self.mock_source_table = self.create_mock_table(table_name=self.mock_source_table_name) + self.mock_destination_table = self.create_mock_table(table_name=self.mock_destination_table_name) + self.create_provider_table() + self.create_rollback_results_bucket() + self.create_event_bus() + + def create_rollback_results_bucket(self): + self._rollback_results_bucket = boto3.resource('s3').create_bucket( + Bucket=os.environ['DISASTER_RECOVERY_RESULTS_BUCKET_NAME'] + ) + + def create_event_bus(self): + self._event_bus = boto3.client('events').create_event_bus(Name=os.environ['EVENT_BUS_NAME']) + + def create_mock_table(self, table_name: str): + return boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=table_name, + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def create_provider_table(self): + self._provider_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerFamGivMid', 'AttributeType': 'S'}, + {'AttributeName': 'providerDateOfUpdate', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSISK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSISK', 'AttributeType': 'S'}, + ], + TableName=os.environ['PROVIDER_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['PROV_FAM_GIV_MID_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerFamGivMid', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerDateOfUpdate', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['LICENSE_GSI_NAME'], + 'KeySchema': [ + {'AttributeName': 'licenseGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': 'licenseUploadDateGSI', + 'KeySchema': [ + {'AttributeName': 'licenseUploadDateGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseUploadDateGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': { + 'ProjectionType': 'INCLUDE', + 'NonKeyAttributes': [ + 'providerId', + ], + }, + }, + ], + ) + + def delete_resources(self): + self.mock_source_table.delete() + self.mock_destination_table.delete() + self._provider_table.delete() + self._rollback_results_bucket.objects.delete() + self._rollback_results_bucket.delete() + boto3.client('events').delete_event_bus(Name=os.environ['EVENT_BUS_NAME']) diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_cleanup_records.py b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_cleanup_records.py new file mode 100644 index 0000000000..1c1fe3aa1f --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_cleanup_records.py @@ -0,0 +1,99 @@ +from unittest.mock import patch + +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestCleanupRecords(TstFunction): + """Test suite for attestation endpoints.""" + + def _generate_test_event(self) -> dict: + return { + 'destinationTableArn': self.mock_destination_table_arn, + 'sourceTableArn': self.mock_source_table_arn, + 'tableNameRecoveryConfirmation': self.mock_destination_table_name, + } + + def test_lambda_returns_failed_delete_status_when_guard_rail_fails(self): + from handlers.cleanup_records import cleanup_records + + event = self._generate_test_event() + event['tableNameRecoveryConfirmation'] = 'invalid-table-name' + response = cleanup_records(event, self.mock_context) + + self.assertEqual( + { + 'deleteStatus': 'FAILED', + 'error': 'Invalid table name specified. tableNameRecoveryConfirmation field must be set to ' + 'Test-PersistentStack-ProviderTableEC5D0597-TQ2RIO6VVBRE', + }, + response, + ) + + def test_lambda_returns_complete_delete_status_when_all_records_cleaned_up(self): + from handlers.cleanup_records import cleanup_records + + event = self._generate_test_event() + response = cleanup_records(event, self.mock_context) + + self.assertEqual('COMPLETE', response['deleteStatus']) + + def test_lambda_iterates_over_all_records_to_clean_up(self): + from handlers.cleanup_records import cleanup_records + + for i in range(5000): + self.mock_destination_table.put_item( + Item={ + 'pk': str(i), + 'sk': str(i), + 'data': f'test_{i}', + } + ) + + event = self._generate_test_event() + response = cleanup_records(event, self.mock_context) + + self.assertEqual( + { + 'deletedCount': 5000, + 'deleteStatus': 'COMPLETE', + 'destinationTableArn': self.mock_destination_table_arn, + 'sourceTableArn': self.mock_source_table_arn, + 'tableNameRecoveryConfirmation': self.mock_destination_table_name, + }, + response, + ) + + @patch('handlers.cleanup_records.time') + def test_lambda_returns_in_progress_when_time_limit_reached(self, mock_time): + from handlers.cleanup_records import cleanup_records + + # current time, start time + 1 second, start time 12 minutes + 2 seconds + mock_time.time.side_effect = [0, 1, 12 * 60 + 2] + + for i in range(5000): + self.mock_destination_table.put_item( + Item={ + 'pk': str(i), + 'sk': str(i), + 'data': f'test_{i}', + } + ) + + event = self._generate_test_event() + response = cleanup_records(event, self.mock_context) + + self.assertEqual( + { + # in this test cases, the table items are very small, so we expect all 2000 to be returned within the + # single page (under the 1MB page limit) + 'deletedCount': 2000, + 'deleteStatus': 'IN_PROGRESS', + 'destinationTableArn': self.mock_destination_table_arn, + 'sourceTableArn': self.mock_source_table_arn, + 'tableNameRecoveryConfirmation': self.mock_destination_table_name, + }, + response, + ) diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_copy_records.py b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_copy_records.py new file mode 100644 index 0000000000..058d1c15cf --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_copy_records.py @@ -0,0 +1,232 @@ +import os +from unittest.mock import patch + +import boto3 +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestCopyRecords(TstFunction): + """Test suite for DR copy records step.""" + + def _generate_test_event(self) -> dict: + return { + 'sourceTableArn': self.mock_source_table_arn, + 'destinationTableArn': self.mock_destination_table_arn, + 'tableNameRecoveryConfirmation': self.mock_destination_table_name, + } + + def test_copy_records_returns_complete_status_when_records_copied_over(self): + from handlers.copy_records import copy_records + + event = self._generate_test_event() + + response = copy_records(event, self.mock_context) + + self.assertEqual( + 'COMPLETE', + response['copyStatus'], + ) + + def test_lambda_returns_failed_copy_status_when_guard_rail_fails(self): + from handlers.copy_records import copy_records + + event = self._generate_test_event() + event['tableNameRecoveryConfirmation'] = 'invalid-table-name' + response = copy_records(event, self.mock_context) + + self.assertEqual( + { + 'copyStatus': 'FAILED', + 'error': 'Invalid table name specified. tableNameRecoveryConfirmation field must be set to ' + 'Test-PersistentStack-ProviderTableEC5D0597-TQ2RIO6VVBRE', + }, + response, + ) + + def test_copy_records_copies_all_records_over_from_source_to_destination_table(self): + from handlers.copy_records import copy_records + + source_items = [] + for i in range(5000): + source_item = { + 'pk': str(i), + 'sk': str(i), + 'data': f'test_{i}', + } + source_items.append(source_item) + self.mock_source_table.put_item(Item=source_item) + + event = self._generate_test_event() + + response = copy_records(event, self.mock_context) + + self.assertEqual( + { + 'copyStatus': 'COMPLETE', + 'copiedCount': 5000, + 'sourceTableArn': self.mock_source_table_arn, + 'destinationTableArn': self.mock_destination_table_arn, + 'tableNameRecoveryConfirmation': self.mock_destination_table_name, + }, + response, + ) + + # now get all records from destination table using pagination + last_evaluated_key = None + copied_items = [] + while True: + scan_kwargs = {} + if last_evaluated_key: + scan_kwargs['ExclusiveStartKey'] = last_evaluated_key + + # get all records from destination table + response = self.mock_destination_table.scan(**scan_kwargs) + items = response.get('Items', []) + + if not items: + break + + copied_items.extend(items) + last_evaluated_key = response.get('LastEvaluatedKey') + if not last_evaluated_key: + break + + self.assertEqual(5000, len(copied_items)) + source_items.sort(key=lambda x: x['pk']) + copied_items.sort(key=lambda x: x['pk']) + self.assertEqual(source_items, copied_items) + + @patch('handlers.copy_records.time') + def test_copy_records_returns_in_progress_with_pagination_key_if_max_time_elapsed(self, mock_time): + from handlers.copy_records import copy_records + + source_items = [] + for i in range(5000): + source_item = { + 'pk': str(i), + 'sk': str(i), + 'data': f'test_{i}', + } + source_items.append(source_item) + self.mock_source_table.put_item(Item=source_item) + + # Lambda functions have a timeout of 15 minutes, so we set a cutoff of 12 minutes before we loop around + # the step function to reset the timeout. This mock allows us to test that branch of logic. + # the first time the mock_time function is called, it will return current time + # the second time the mock_time function is called, it will return + 1 second + # the third time the mock_time function is called, it will return 12 minutes + 1 second (cutoff is 12 minutes) + # this should cause the lambda to return an IN_PROGRESS status with a pagination key + mock_time.time.side_effect = [0, 1, 12 * 60 + 2] # current time, 12 minutes + 2 seconds + + event = self._generate_test_event() + + response = copy_records(event, self.mock_context) + + self.assertEqual( + { + 'copyStatus': 'IN_PROGRESS', + # in this test cases, the table items are very small, so we expect all 2000 to be returned within the + # single page (under the 1MB page limit) + 'copiedCount': 2000, + 'copyLastEvaluatedKey': 'eyJwayI6ICIyNzk4IiwgInNrIjogIjI3OTgifQ==', + 'sourceTableArn': self.mock_source_table_arn, + 'destinationTableArn': self.mock_destination_table_arn, + 'tableNameRecoveryConfirmation': self.mock_destination_table_name, + }, + response, + ) + + def test_copy_records_uses_pagination_key_if_provided(self): + from handlers.copy_records import copy_records + + source_items = [] + for i in range(5000): + source_item = { + 'pk': str(i), + 'sk': str(i), + 'data': f'test_{i}', + } + source_items.append(source_item) + self.mock_source_table.put_item(Item=source_item) + + event = self._generate_test_event() + # this is the key generated by the previous test, in which only 2000 records were processed + # by using this same key, we expect the remaining 3000 should be processed in this test. + event['copyLastEvaluatedKey'] = 'eyJwayI6ICIyNzk4IiwgInNrIjogIjI3OTgifQ==' + event['copiedCount'] = 2000 + + response = copy_records(event, self.mock_context) + + self.assertEqual( + { + 'copyStatus': 'COMPLETE', + # the 2000 that is passed into the event should be added to the remaining 3000 that get copied over + 'copiedCount': 5000, + 'sourceTableArn': self.mock_source_table_arn, + 'destinationTableArn': self.mock_destination_table_arn, + 'tableNameRecoveryConfirmation': self.mock_destination_table_name, + }, + response, + ) + + def test_pagination_key_can_be_encrypted_and_decrypted(self): + from handlers.copy_records import decrypt_pagination_key, encrypt_pagination_key + + # Create a mock KMS key + kms_client = boto3.client('kms', region_name='us-east-1') + key_response = kms_client.create_key(Description='Test SSN encryption key') + key_id = key_response['KeyMetadata']['KeyId'] + + # Test data + test_key_data = {'pk': 'test-ssn-123', 'sk': 'test-sk-456'} + + # Encrypt then decrypt + encrypted_key = encrypt_pagination_key(test_key_data, key_id) + decrypted_key = decrypt_pagination_key(encrypted_key, key_id) + + # Verify the decrypted data matches the original + self.assertEqual(test_key_data, decrypted_key) + + @patch('handlers.copy_records.time') + def test_copy_records_encrypts_pagination_key_when_ssn_encryption_enabled_and_time_limit_reached(self, mock_time): + from handlers.copy_records import copy_records, decrypt_pagination_key + + # Create a mock KMS key + kms_client = boto3.client('kms', region_name='us-east-1') + key_response = kms_client.create_key(Description='Test SSN encryption key') + key_id = key_response['KeyMetadata']['KeyId'] + + # Add test data + source_items = [] + for i in range(3000): + source_item = { + 'pk': str(i), + 'sk': str(i), + 'data': f'test_{i}', + } + source_items.append(source_item) + self.mock_source_table.put_item(Item=source_item) + + # Mock time to trigger timeout on third call, then reset it back for the subsequent call + mock_time.time.side_effect = [0, 1, 12 * 60 + 2, 0, 1] + + with patch.dict(os.environ, {'SSN_ENCRYPTION_KMS_KEY_ID': key_id}): + event = self._generate_test_event() + response = copy_records(event, self.mock_context) + + self.assertEqual('IN_PROGRESS', response['copyStatus']) + encrypted_pagination_key = response['copyLastEvaluatedKey'] + decrypted_key = decrypt_pagination_key(encrypted_pagination_key, key_id) + # because we use strings for the keys, the keys are sorted in lexicographical order + # this key is the 2000th record by that order + self.assertEqual({'pk': '2798', 'sk': '2798'}, decrypted_key) + + # now call again with the key and expect to get back the remaining records + second_call_response = copy_records(response, self.mock_context) + + # Should copy over the remaining records successfully + self.assertEqual('COMPLETE', second_call_response['copyStatus']) + self.assertEqual(3000, second_call_response['copiedCount']) diff --git a/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_rollback_license_upload.py b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_rollback_license_upload.py new file mode 100644 index 0000000000..01e608ee03 --- /dev/null +++ b/backend/social-work-app/lambdas/python/disaster-recovery/tests/function/test_rollback_license_upload.py @@ -0,0 +1,1148 @@ +""" +Tests for the license upload rollback handler. + +These tests verify the rollback functionality including: +- GSI queries for affected providers +- Eligibility validation +- Revert plan determination +- Transaction execution +- Event publishing +- S3 result management +""" + +import json +from datetime import datetime, timedelta +from unittest.mock import ANY, Mock, patch + +import pytest +from cc_common.data_model.update_tier_enum import UpdateTierEnum +from cc_common.exceptions import CCNotFoundException +from moto import mock_aws + +from . import TstFunction + +MOCK_DATETIME_STRING = '2025-10-23T08:15:00+00:00' +MOCK_ORIGINAL_GIVEN_NAME = 'originalGiven' +MOCK_ORIGINAL_FAMILY_NAME = 'originalFamily' +MOCK_UPDATED_GIVEN_NAME = 'updatedGiven' +MOCK_UPDATED_FAMILY_NAME = 'updatedFamily' +MOCK_PROVIDER_ID = 'ba880c7c-5ed3-4be4-8ad5-c8558f58ef6f' +MOCK_EXECUTION_NAME = 'test-execution-123' + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_DATETIME_STRING)) +class TestRollbackLicenseUpload(TstFunction): + """Test class for license upload rollback handler.""" + + def setUp(self): + """Set up test fixtures before each test method.""" + super().setUp() + # Create sample test data + self.compact = 'socw' + self.license_jurisdiction = 'oh' + self.provider_id = MOCK_PROVIDER_ID + # default upload time between start and end time + self.default_upload_datetime = datetime.fromisoformat(MOCK_DATETIME_STRING) - timedelta(hours=1) + self.default_start_datetime = self.default_upload_datetime - timedelta(days=1) + self.default_end_datetime = self.default_upload_datetime + from cc_common.data_model.schema.common import UpdateCategory + + self.update_categories = UpdateCategory + + self.provider_data = self._add_provider_record() + + def _generate_test_event(self): + return { + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'startDateTime': self.default_start_datetime.isoformat(), + 'endDateTime': self.default_end_datetime.isoformat(), + 'rollbackReason': 'Test rollback', + 'executionName': MOCK_EXECUTION_NAME, + 'providersProcessed': 0, + } + + def _add_provider_record(self, provider_id: str | None = None): + if provider_id is None: + provider_id = self.provider_id + + # add provider record to provider table + return self.test_data_generator.put_default_provider_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'dateOfUpdate': self.default_start_datetime - timedelta(days=30), + } + ) + + # Helper methods for setting up test scenarios + def _when_provider_had_license_created_from_upload(self): + """ + Set up a scenario where a provider had a license created during the upload window. + Returns the created license data. + """ + return self.test_data_generator.put_default_license_record_in_provider_table( + { + 'providerId': self.provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'firstUploadDate': self.default_upload_datetime, + 'dateOfUpdate': self.default_upload_datetime, + } + ) + + def _when_provider_had_license_updated_from_upload( + self, upload_datetime: datetime = None, license_upload_datetime: datetime = None, provider_id: str = None + ): + """ + Set up a scenario where a provider had an existing license updated during the upload window. + Returns the license and its update record. + """ + if upload_datetime is None: + upload_datetime = self.default_upload_datetime + if license_upload_datetime is None: + # by default, the license was originally uploaded a day before the bad upload + license_upload_datetime = self.default_start_datetime - timedelta(days=1) + if provider_id is None: + provider_id = self.provider_id + + # Create original license before upload window, unless different time is provided + original_license = self.test_data_generator.put_default_license_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'familyName': MOCK_ORIGINAL_FAMILY_NAME, + 'givenName': MOCK_ORIGINAL_GIVEN_NAME, + 'dateOfUpdate': self.default_start_datetime - timedelta(days=30), + # simulate license record that has not expired yet + 'dateOfExpiration': (self.default_start_datetime + timedelta(days=30)).date(), + 'firstUploadDate': license_upload_datetime, + 'licenseStatus': 'active', + } + ) + + # Create update record within upload window to simulate license deactivation + license_update = self.test_data_generator.put_default_license_update_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'licenseType': original_license.licenseType, + 'updateType': self.update_categories.DEACTIVATION, + 'createDate': upload_datetime, + 'effectiveDate': upload_datetime, + 'previous': { + 'dateOfExpiration': original_license.dateOfExpiration, + 'licenseStatus': 'active', + **original_license.to_dict(), + }, + 'updatedValues': { + # simulate accidentally changing the expiration to last year + 'dateOfExpiration': (upload_datetime - timedelta(days=365)).date(), + 'licenseStatus': 'inactive', + 'familyName': MOCK_UPDATED_FAMILY_NAME, + 'givenName': MOCK_UPDATED_GIVEN_NAME, + }, + } + ) + + # Update the license record to reflect the new expiration and status + updated_license = self.test_data_generator.put_default_license_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'familyName': MOCK_UPDATED_FAMILY_NAME, + 'givenName': MOCK_UPDATED_GIVEN_NAME, + 'dateOfUpdate': upload_datetime, + 'dateOfExpiration': (upload_datetime - timedelta(days=365)).date(), + 'licenseStatus': 'inactive', + 'firstUploadDate': license_upload_datetime, + } + ) + + return original_license, license_update, updated_license + + def _when_license_was_updated_twice(self, provider_id: str = None): + """ + Set up a scenario where a provider had an existing license updated twice during the upload window. + Returns the original license, both update records, and the final updated license. + """ + first_upload_datetime = self.default_start_datetime + timedelta(minutes=30) + second_upload_datetime = self.default_start_datetime + timedelta(hours=1) + if provider_id is None: + provider_id = self.provider_id + + # License was originally uploaded before the upload window + license_upload_datetime = self.default_start_datetime - timedelta(days=1) + + # Create original license before upload window + original_license = self.test_data_generator.put_default_license_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'familyName': MOCK_ORIGINAL_FAMILY_NAME, + 'givenName': MOCK_ORIGINAL_GIVEN_NAME, + 'dateOfExpiration': (self.default_start_datetime + timedelta(days=30)).date(), + 'firstUploadDate': license_upload_datetime, + 'licenseStatus': 'active', + } + ) + + # old update record before upload window + existing_update = self.test_data_generator.put_default_license_update_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'licenseType': original_license.licenseType, + 'updateType': self.update_categories.LICENSE_UPLOAD_UPDATE_OTHER, + # last update was 5 days before upload, this should be ignored + 'createDate': first_upload_datetime - timedelta(days=5), + 'effectiveDate': first_upload_datetime, + 'previous': { + **original_license.to_dict(), + 'familyName': 'someFamilyName', + 'givenName': 'someGivenName', + }, + 'updatedValues': { + 'familyName': original_license.familyName, + 'givenName': original_license.givenName, + }, + } + ) + + # Create first update record within upload window (e.g., RENEWAL) + first_update = self.test_data_generator.put_default_license_update_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'licenseType': original_license.licenseType, + 'updateType': self.update_categories.RENEWAL, + 'createDate': first_upload_datetime, + 'effectiveDate': first_upload_datetime, + 'previous': { + 'dateOfExpiration': original_license.dateOfExpiration, + 'licenseStatus': original_license.licenseStatus, + **original_license.to_dict(), + }, + 'updatedValues': { + 'dateOfExpiration': (first_upload_datetime + timedelta(days=365)).date(), + 'dateOfRenewal': first_upload_datetime.date(), + }, + } + ) + + # Create intermediate license state after first update + intermediate_license = self.test_data_generator.put_default_license_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'familyName': MOCK_ORIGINAL_FAMILY_NAME, + 'givenName': MOCK_ORIGINAL_GIVEN_NAME, + 'dateOfUpdate': first_upload_datetime, + 'dateOfExpiration': (first_upload_datetime + timedelta(days=365)).date(), + 'dateOfRenewal': first_upload_datetime.date(), + 'firstUploadDate': license_upload_datetime, + 'licenseStatus': 'active', + } + ) + + # Create second update record within upload window (e.g., DEACTIVATION) + second_update = self.test_data_generator.put_default_license_update_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'licenseType': original_license.licenseType, + 'updateType': self.update_categories.DEACTIVATION, + 'createDate': second_upload_datetime, + 'effectiveDate': second_upload_datetime, + 'previous': { + 'dateOfExpiration': intermediate_license.dateOfExpiration, + 'licenseStatus': intermediate_license.licenseStatus, + **intermediate_license.to_dict(), + }, + 'updatedValues': { + 'dateOfExpiration': (second_upload_datetime - timedelta(days=365)).date(), + 'licenseStatus': 'inactive', + 'familyName': MOCK_UPDATED_FAMILY_NAME, + 'givenName': MOCK_UPDATED_GIVEN_NAME, + }, + } + ) + + # Update the license record to reflect the final state after second update + final_license = self.test_data_generator.put_default_license_record_in_provider_table( + { + 'providerId': provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'familyName': MOCK_UPDATED_FAMILY_NAME, + 'givenName': MOCK_UPDATED_GIVEN_NAME, + 'dateOfUpdate': second_upload_datetime, + 'dateOfExpiration': (second_upload_datetime - timedelta(days=365)).date(), + 'firstUploadDate': license_upload_datetime, + 'licenseStatus': 'inactive', + } + ) + + return existing_update, original_license, first_update, second_update, final_license + + def _when_provider_had_license_update_after_upload(self, after_upload_datetime: datetime = None): + """ + Set up a scenario where a provider had a non-upload-related license update AFTER the upload window. + This makes them ineligible for automatic rollback. + Returns the license and its update record. + """ + if after_upload_datetime is None: + after_upload_datetime = self.default_end_datetime + timedelta(hours=1) + + # Create a non-upload-related update (e.g., encumbrance) after the window + return self.test_data_generator.put_default_license_update_record_in_provider_table( + { + 'providerId': self.provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'updateType': self.update_categories.ENCUMBRANCE, # Not an upload-related category + 'createDate': after_upload_datetime, + 'effectiveDate': after_upload_datetime, + } + ) + + def _when_provider_top_level_record_needs_reverted(self, before_upload_datetime: datetime = None): + """ + Set up a scenario where the provider's top-level record needs to be reverted. + Returns the provider record. + """ + if before_upload_datetime is None: + before_upload_datetime = self.default_start_datetime - timedelta(days=30) + + # Existing license updated during window + self._when_provider_had_license_updated_from_upload() + + # Create provider record with old values + provider = self.test_data_generator.put_default_provider_record_in_provider_table( + { + 'providerId': self.provider_id, + 'compact': self.compact, + 'familyName': MOCK_ORIGINAL_FAMILY_NAME, + 'givenName': MOCK_ORIGINAL_GIVEN_NAME, + 'dateOfUpdate': before_upload_datetime, + } + ) + + # Simulate that the provider record was updated during upload + updated_provider = self.test_data_generator.put_default_provider_record_in_provider_table( + { + 'providerId': self.provider_id, + 'compact': self.compact, + 'familyName': MOCK_UPDATED_FAMILY_NAME, + 'givenName': MOCK_UPDATED_GIVEN_NAME, + 'dateOfUpdate': self.default_upload_datetime, + } + ) + + return provider, updated_provider + + def test_provider_top_level_record_reset_to_prior_values_when_upload_reverted(self): + """Test that provider top-level record is reset to values before upload.""" + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: + # Provider record was updated during upload + old_provider, new_provider = self._when_provider_top_level_record_needs_reverted() + + # Execute: Perform rollback + event = self._generate_test_event() + + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed successfully + self.assertEqual(result['rollbackStatus'], 'COMPLETE') + self.assertEqual(1, result['providersReverted']) + + # Verify: Provider record has been reset to old values + provider_records = self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + ) + provider_record = provider_records.get_provider_record() + self.assertEqual(old_provider.givenName, provider_record.givenName) + self.assertEqual(old_provider.familyName, provider_record.familyName) + + def test_provider_top_level_record_deleted_when_license_created_during_bad_upload(self): + """Test that provider top-level record is deleted if the license record + is also deleted when reverting upload.""" + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: + # License and provider records were created during upload + self._when_provider_had_license_created_from_upload() + + # Execute: Perform rollback + event = self._generate_test_event() + + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed successfully + self.assertEqual(result['rollbackStatus'], 'COMPLETE') + self.assertEqual(1, result['providersReverted']) + + # Verify: All provider records have been deleted + with pytest.raises(CCNotFoundException): + self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + ) + + def test_provider_license_record_reset_to_prior_values_when_upload_reverted(self): + """Test that license record is reset to values before upload.""" + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: License was updated during upload (e.g., renewed), but was first uploaded before start time + original_license, license_update, updated_license = self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + + # Store the original expiration date from the update's previous values + original_expiration = license_update.previous['dateOfExpiration'] + + # Execute: Perform rollback + event = self._generate_test_event() + + result = rollback_license_upload(event, Mock()) + + # should return complete message + self.assertEqual(result['rollbackStatus'], 'COMPLETE') + self.assertEqual(result['providersReverted'], 1) + + # Verify: License record has been reset to original values + provider_records = self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + licenses = provider_records.get_license_records() + self.assertEqual(len(licenses), 1) + license_record = licenses[0] + self.assertEqual(license_record.dateOfExpiration, original_expiration) + + # Verify: Update record has been deleted + license_updates = provider_records.get_all_license_update_records() + self.assertEqual(len(license_updates), 0, 'License update records should be deleted') + + def test_provider_license_record_reverted_to_earliest_update_previous_values_when_multiple_updates(self): + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: License was updated twice during upload window, but was first uploaded before start time + existing_update, original_license, first_update, second_update, final_license = ( + self._when_license_was_updated_twice() + ) + + # Execute: Perform rollback + event = self._generate_test_event() + + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed successfully + self.assertEqual(result['rollbackStatus'], 'COMPLETE') + self.assertEqual(result['providersReverted'], 1) + + # Verify: License record has been reset to the values from the first (earliest) update's previous field + provider_records = self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + licenses = provider_records.get_license_records() + self.assertEqual(len(licenses), 1) + license_record = licenses[0] + # license should look the same as it did before the updates that were rolled back + self.assertEqual(original_license.serialize_to_database_record(), license_record.serialize_to_database_record()) + + # Verify: Both update records have been deleted + license_updates = provider_records.get_all_license_update_records() + # license update that existed before upload should still be there + self.assertEqual(len(license_updates), 1, 'Expected one existing license update to remain') + self.assertEqual( + existing_update.serialize_to_database_record(), license_updates[0].serialize_to_database_record() + ) + + def test_provider_license_updates_and_license_record_within_time_period_removed_when_upload_reverted(self): + """Test that license update records and license record within the time window are deleted.""" + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: License was uploaded and then updated during upload + self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime + timedelta(hours=1) + ) + + # Verify update record exists before rollback + provider_records_before = self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + licenses_before = provider_records_before.get_license_records() + self.assertEqual(len(licenses_before), 1, 'Should have license record before rollback') + license_updates_before = provider_records_before.get_all_license_update_records() + self.assertEqual(len(license_updates_before), 1, 'Should have update record before rollback') + + # Execute: Perform rollback + event = self._generate_test_event() + + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed successfully + self.assertEqual(result['rollbackStatus'], 'COMPLETE') + + # Verify: All records within time window have been deleted + with pytest.raises(CCNotFoundException): + self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + + def test_provider_skipped_if_license_updates_detected_after_end_of_time_window_when_upload_reverted(self): + """Test that provider is skipped if non-upload-related license updates exist after time window.""" + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: Provider had valid license before upload, and update occurred during upload window + self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + # update also occurred after upload window + self._when_provider_had_license_update_after_upload() + + event = self._generate_test_event() + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed but provider was skipped + self.assertEqual('COMPLETE', result['rollbackStatus']) + self.assertEqual(0, result['providersReverted']) + self.assertEqual(1, result['providersSkipped']) + + # Verify: License record and update still exist (not rolled back) + provider_records = self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + licenses = provider_records.get_license_records() + self.assertEqual(len(licenses), 1, 'License should still exist') + license_updates = provider_records.get_all_license_update_records() + self.assertEqual(2, len(license_updates), 'License updates should still exist') + + # Validation tests + def test_rollback_validates_datetime_format(self): + from handlers.rollback_license_upload import rollback_license_upload + + event = self._generate_test_event() + event['startDateTime'] = 'invalid-datetime' + + result = rollback_license_upload(event, Mock()) + + self.assertEqual(result['rollbackStatus'], 'FAILED') + self.assertIn('Invalid datetime format', result['error']) + + def test_rollback_validates_time_window_order(self): + from handlers.rollback_license_upload import rollback_license_upload + + event = self._generate_test_event() + event['startDateTime'] = self.default_end_datetime.isoformat() + event['endDateTime'] = self.default_start_datetime.isoformat() + + result = rollback_license_upload(event, Mock()) + + self.assertEqual(result['rollbackStatus'], 'FAILED') + self.assertIn('Start time must be before end time', result['error']) + + def test_rollback_validates_maximum_time_window(self): + from handlers.rollback_license_upload import rollback_license_upload + + start = self.config.current_standard_datetime - timedelta(days=8) # More than 7 days + end = self.config.current_standard_datetime + + event = self._generate_test_event() + event['startDateTime'] = start.isoformat() + event['endDateTime'] = end.isoformat() + + result = rollback_license_upload(event, Mock()) + + self.assertEqual(result['rollbackStatus'], 'FAILED') + self.assertIn('cannot exceed', result['error']) + + def _perform_rollback_and_get_s3_object(self): + from handlers.rollback_license_upload import rollback_license_upload + + # Execute: Perform rollback + event = self._generate_test_event() + + rollback_license_upload(event, Mock()) + + # Read object from S3 and verify its contents match what is expected + s3_key = f'licenseUploadRollbacks/{MOCK_EXECUTION_NAME}/results.json' + s3_obj = self.config.s3_client.get_object(Bucket=self.config.disaster_recovery_results_bucket_name, Key=s3_key) + return json.loads(s3_obj['Body'].read().decode('utf-8')) + + # Tests for checking data written to S3 + def test_expected_s3_object_stored_when_provider_license_record_reset_to_prior_values(self): + # Setup: License was updated during upload (e.g., renewed), but was first uploaded before start time + original_license, license_update, updated_license = self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + + results_data = self._perform_rollback_and_get_s3_object() + + # Verify the structure of the results + self.assertEqual( + { + 'executionName': MOCK_EXECUTION_NAME, + 'failedProviderDetails': [], + 'revertedProviderSummaries': [ + { + 'licensesReverted': [ + { + 'action': 'REVERT', + 'jurisdiction': original_license.jurisdiction, + 'licenseType': original_license.licenseType, + } + ], + 'providerId': self.provider_id, + # NOTE: if the test update data is modified, the sha here will need to be updated + 'updatesDeleted': [ + 'socw#UPDATE#3#license/oh/cos/2025-10-23T07:15:00+00:00/ecd7b0d5fbe7c32dff89c9864ebb8daf' + ], + } + ], + 'skippedProviderDetails': [], + }, + results_data, + ) + + def test_expected_s3_object_stored_when_provider_license_record_deleted_from_rollback(self): + # Setup: License was updated during upload (e.g., renewed), but was first uploaded before start time + new_license = self._when_provider_had_license_created_from_upload() + + results_data = self._perform_rollback_and_get_s3_object() + + # Verify the structure of the results + self.assertEqual( + { + 'executionName': MOCK_EXECUTION_NAME, + 'failedProviderDetails': [], + 'revertedProviderSummaries': [ + { + 'licensesReverted': [ + { + 'action': 'DELETE', + 'jurisdiction': new_license.jurisdiction, + 'licenseType': new_license.licenseType, + } + ], + 'providerId': self.provider_id, + 'updatesDeleted': [], + } + ], + 'skippedProviderDetails': [], + }, + results_data, + ) + + def test_expected_s3_object_stored_when_provider_skipped_due_to_extra_license_updates(self): + # Setup: Provider had valid license before upload, and update occurred during upload window + original_license, license_update, updated_license = self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + # update also occurred after upload window + encumbrance_update = self._when_provider_had_license_update_after_upload() + + results_data = self._perform_rollback_and_get_s3_object() + + # Verify the structure of the results + expected_reason_message = ( + 'License was updated with a change unrelated to license upload or the update ' + 'occurred after rollback end time. Manual review required.' + ) + self.assertEqual( + { + 'executionName': MOCK_EXECUTION_NAME, + 'failedProviderDetails': [], + 'revertedProviderSummaries': [], + 'skippedProviderDetails': [ + { + 'ineligibleUpdates': [ + { + 'updateTime': encumbrance_update.createDate.isoformat(), + 'licenseType': original_license.licenseType, + 'reason': expected_reason_message, + 'recordType': 'licenseUpdate', + 'typeOfUpdate': encumbrance_update.updateType, + } + ], + 'providerId': MOCK_PROVIDER_ID, + 'reason': 'Provider has updates that are either ' + 'unrelated to license upload or ' + 'occurred after rollback end time. ' + 'Manual review required.', + } + ], + }, + results_data, + ) + + def test_expected_s3_object_stored_when_provider_schema_validation_fails_during_rollback(self): + """Test that failed provider details are correctly stored in S3 results when a validation exception occurs.""" + # Setup: License was updated during upload, but one update record has invalid field + original_license, license_update, updated_license = self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + serialized_license = updated_license.serialize_to_database_record() + serialized_license['jurisdictionUploadedLicenseStatus'] = 'foo' + self.config.provider_table.put_item(Item=serialized_license) + + results_data = self._perform_rollback_and_get_s3_object() + + # Verify the structure of the results contains failed provider details + self.assertEqual( + { + 'executionName': MOCK_EXECUTION_NAME, + 'failedProviderDetails': [ + { + 'error': 'Failed to rollback updates for provider. Manual review required: Validation error: ' + "{'jurisdictionUploadedLicenseStatus': ['Must be one of: active, inactive.']}", + 'providerId': self.provider_id, + } + ], + 'revertedProviderSummaries': [], + 'skippedProviderDetails': [], + }, + results_data, + ) + + def test_rollback_handles_loading_existing_s3_results_and_appends_new_data(self): + """Test that rollback can load existing S3 results and append new data without deleting previous data.""" + from uuid import uuid4 + + existing_skipped_provider_id = str(uuid4()) + existing_reverted_provider_id = str(uuid4()) + existing_failed_provider_id = str(uuid4()) + + # Setup: Create provider with license that will be reverted + self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + + # Create initial S3 results with data in all fields + s3_key = f'licenseUploadRollbacks/{MOCK_EXECUTION_NAME}/results.json' + + # Create existing results data in the format that from_dict expects (camelCase for all keys) + existing_results_data = { + 'executionName': MOCK_EXECUTION_NAME, + 'skippedProviderDetails': [ + { + 'providerId': existing_skipped_provider_id, + 'reason': 'Existing skipped provider reason', + 'ineligibleUpdates': [ + { + 'recordType': 'licenseUpdate', + 'typeOfUpdate': 'ENCUMBRANCE', + 'updateTime': (self.default_start_datetime - timedelta(days=2)).isoformat(), + 'reason': 'Existing ineligible update reason', + 'licenseType': 'cosmetologist', + } + ], + } + ], + 'failedProviderDetails': [ + { + 'providerId': existing_failed_provider_id, + 'error': 'Existing failure error message', + } + ], + 'revertedProviderSummaries': [ + { + 'providerId': existing_reverted_provider_id, + 'licensesReverted': [ + { + 'jurisdiction': 'tx', + 'licenseType': 'cosmetologist', + 'action': 'REVERT', + } + ], + 'updatesDeleted': ['existing-update-sha-1'], + } + ], + } + + # Write existing results to S3 + self.config.s3_client.put_object( + Bucket=self.config.disaster_recovery_results_bucket_name, + Key=s3_key, + Body=json.dumps(existing_results_data, indent=2), + ContentType='application/json', + ) + + final_results_data = self._perform_rollback_and_get_s3_object() + + # Verify: All existing data is preserved and new data is appended + self.assertEqual( + { + 'executionName': MOCK_EXECUTION_NAME, + 'skippedProviderDetails': [ + { + 'providerId': existing_skipped_provider_id, + 'reason': 'Existing skipped provider reason', + 'ineligibleUpdates': [ + { + 'recordType': 'licenseUpdate', + 'typeOfUpdate': 'ENCUMBRANCE', + 'updateTime': (self.default_start_datetime - timedelta(days=2)).isoformat(), + 'reason': 'Existing ineligible update reason', + 'licenseType': 'cosmetologist', + } + ], + } + ], + 'failedProviderDetails': [ + { + 'providerId': existing_failed_provider_id, + 'error': 'Existing failure error message', + } + ], + 'revertedProviderSummaries': [ + { + 'providerId': existing_reverted_provider_id, + 'licensesReverted': [ + { + 'jurisdiction': 'tx', + 'licenseType': 'cosmetologist', + 'action': 'REVERT', + } + ], + 'updatesDeleted': ['existing-update-sha-1'], + }, + { + 'providerId': self.provider_id, + 'licensesReverted': [ + { + 'action': 'REVERT', + 'jurisdiction': self.license_jurisdiction, + 'licenseType': ANY, + } + ], + 'updatesDeleted': ANY, + }, + ], + }, + final_results_data, + ) + + @patch('handlers.rollback_license_upload.time') + def test_rollback_handles_pagination_when_provider_id_present_in_event_input(self, mock_time): + """Test that rollback can paginate across multiple invocations using continueFromProviderId.""" + from handlers.rollback_license_upload import rollback_license_upload + + # Lambda functions have a timeout of 15 minutes, so we set a cutoff of 12 minutes before we loop around + # the step function to reset the timeout. This mock allows us to test that branch of logic. + # the first time the mock_time function is called, it will return current time + # the second time the mock_time function is called, it will return + 1 second + # the third time the mock_time function is called, it will return 12 minutes + 2 seconds (cutoff is 12 minutes) + # this should cause the lambda to return an IN_PROGRESS status with a pagination key + mock_time.time.side_effect = [0, 1, 12 * 60 + 2] # current time, 12 minutes + 2 seconds + + # Setup: Create two providers with licenses that will be reverted + # Provider IDs in sorted order (to ensure consistent pagination behavior) + mock_first_provider_id = '11111111-5ed3-4be4-8ad5-c8558f587890' + mock_second_provider_id = '22222222-5ed3-4be4-8ad5-c8558f587890' + + # Add first provider + self._add_provider_record(provider_id=mock_first_provider_id) + self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1), provider_id=mock_first_provider_id + ) + + # Add second provider + self._add_provider_record(provider_id=mock_second_provider_id) + self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1), + provider_id=mock_second_provider_id, + ) + + # Execute: First invocation (should timeout after processing first provider) + event = self._generate_test_event() + + result_first = rollback_license_upload(event, Mock()) + + # Assert: First invocation returned IN_PROGRESS status + self.assertEqual(result_first['rollbackStatus'], 'IN_PROGRESS') + self.assertEqual(1, result_first['providersProcessed']) + self.assertEqual(1, result_first['providersReverted']) + self.assertEqual(0, result_first['providersSkipped']) + self.assertEqual(0, result_first['providersFailed']) + self.assertEqual(mock_second_provider_id, result_first['continueFromProviderId']) + + # Execute: Second invocation (continue from where we left off) + # Reset mock time for second invocation + mock_time.time.side_effect = [0, 1] # Won't timeout this time + + result_second = rollback_license_upload(result_first, Mock()) + + # Assert: Second invocation completed successfully + self.assertEqual(result_second['rollbackStatus'], 'COMPLETE') + self.assertEqual(2, result_second['providersProcessed']) + self.assertEqual(2, result_second['providersReverted']) + self.assertEqual(0, result_second['providersSkipped']) + self.assertEqual(0, result_second['providersFailed']) + + # Verify: S3 results contain both providers + s3_key = f'licenseUploadRollbacks/{MOCK_EXECUTION_NAME}/results.json' + s3_obj = self.config.s3_client.get_object(Bucket=self.config.disaster_recovery_results_bucket_name, Key=s3_key) + final_results_data = json.loads(s3_obj['Body'].read().decode('utf-8')) + + # Should have 2 reverted providers + self.assertEqual( + { + 'executionName': MOCK_EXECUTION_NAME, + 'failedProviderDetails': [], + 'revertedProviderSummaries': [ + { + 'licensesReverted': [ + { + 'action': 'REVERT', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + } + ], + 'providerId': mock_first_provider_id, + 'updatesDeleted': [ + 'socw#UPDATE#3#license/oh/cos/2025-10-23T07:15:00+00:00/ecd7b0d5fbe7c32dff89c9864ebb8daf' + ], + }, + { + 'licensesReverted': [ + { + 'action': 'REVERT', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + } + ], + 'providerId': mock_second_provider_id, + 'updatesDeleted': [ + 'socw#UPDATE#3#license/oh/cos/2025-10-23T07:15:00+00:00/ecd7b0d5fbe7c32dff89c9864ebb8daf' + ], + }, + ], + 'skippedProviderDetails': [], + }, + final_results_data, + ) + + @patch('handlers.rollback_license_upload.config.event_bus_client') + def test_event_bus_client_called_with_expected_arguments_for_license_revert_events(self, mock_event_bus_client): + """Test that only license revert event is published (privilege reactivation not supported).""" + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: License was updated during upload + # This scenario will trigger license revert event + original_license, license_update, updated_license = self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + + # Execute: Perform rollback + event = self._generate_test_event() + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed successfully + self.assertEqual(result['rollbackStatus'], 'COMPLETE') + self.assertEqual(result['providersReverted'], 1) + + # Verify: publish_license_revert_event was called with expected arguments + expected_license_kwargs = { + 'source': 'org.compactconnect.disaster-recovery', + 'compact': self.compact, + 'provider_id': self.provider_id, + 'jurisdiction': self.license_jurisdiction, + 'license_type': original_license.licenseType, + 'rollback_reason': 'Test rollback', + 'start_time': self.default_start_datetime, + 'end_time': self.default_end_datetime, + 'execution_name': MOCK_EXECUTION_NAME, + 'event_batch_writer': ANY, + } + mock_event_bus_client.publish_license_revert_event.assert_called_once_with(**expected_license_kwargs) + + def test_transaction_failure_is_logged_and_provider_marked_as_failed(self): + """Test that transaction failures are properly logged and the provider is marked as failed.""" + from botocore.exceptions import ClientError + + # Setup: License updated during upload (revert will perform DELETE of update record and PUT of reverted license) + self._when_provider_had_license_updated_from_upload( + license_upload_datetime=self.default_start_datetime - timedelta(hours=1) + ) + + # Mock the transaction to fail with a ClientError + mock_error = ClientError( + error_response={'Error': {'Code': 'TransactionCanceledException', 'Message': 'Transaction cancelled'}}, + operation_name='TransactWriteItems', + ) + + # Patch at the handler module level to ensure it works across the full test suite + with patch( + 'handlers.rollback_license_upload.config.provider_table.meta.client.transact_write_items', + side_effect=mock_error, + ): + results_data = self._perform_rollback_and_get_s3_object() + + # Verify: Provider was marked as failed + self.assertEqual(1, len(results_data['failedProviderDetails'])) + self.assertEqual(self.provider_id, results_data['failedProviderDetails'][0]['providerId']) + self.assertIn('TransactionCanceledException', results_data['failedProviderDetails'][0]['error']) + + # Verify: No providers were reverted or skipped + self.assertEqual(0, len(results_data['revertedProviderSummaries'])) + self.assertEqual(0, len(results_data['skippedProviderDetails'])) + + def test_orphaned_license_updates_cause_provider_to_be_skipped(self): + """Test that orphaned license update records (without top-level license records) + cause provider to be skipped.""" + from uuid import uuid4 + + from handlers.rollback_license_upload import rollback_license_upload + + orphaned_provider_id = str(uuid4()) + + # Setup: License was uploaded and then updated during upload + # Create update record within upload window to simulate license deactivation + orphaned_license_update = self.test_data_generator.put_default_license_update_record_in_provider_table( + { + 'providerId': orphaned_provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'updateType': self.update_categories.DEACTIVATION, + 'createDate': self.default_upload_datetime, + 'effectiveDate': self.default_upload_datetime, + 'updatedValues': { + # simulate accidentally changing the expiration to last year + 'dateOfExpiration': (self.default_upload_datetime - timedelta(days=365)).date(), + 'licenseStatus': 'inactive', + 'familyName': MOCK_UPDATED_FAMILY_NAME, + 'givenName': MOCK_UPDATED_GIVEN_NAME, + }, + } + ) + + # Verify update record exists before rollback + provider_records_before = self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=orphaned_provider_id, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + licenses_before = provider_records_before.get_license_records() + self.assertEqual(len(licenses_before), 0, 'Should not have license record before rollback') + license_updates_before = provider_records_before.get_all_license_update_records() + self.assertEqual(len(license_updates_before), 1, 'Should have orphaned update record before rollback') + + # Execute: Perform rollback + event = self._generate_test_event() + + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed with provider skipped + self.assertEqual(result['rollbackStatus'], 'COMPLETE') + self.assertEqual(result['providersSkipped'], 1, 'Provider with orphaned updates should be skipped') + self.assertEqual(result['providersReverted'], 0, 'No providers should be reverted') + self.assertEqual(result['providersFailed'], 0, 'No providers should have failed') + + # Verify S3 results contain the orphaned update details + s3_key = f'licenseUploadRollbacks/{MOCK_EXECUTION_NAME}/results.json' + s3_obj = self.config.s3_client.get_object(Bucket=self.config.disaster_recovery_results_bucket_name, Key=s3_key) + results_data = json.loads(s3_obj['Body'].read().decode('utf-8')) + + # Verify the structure of the results + expected_reason = ( + f'License update record(s) exist for license in jurisdiction ' + f'{self.license_jurisdiction} with type {orphaned_license_update.licenseType}, ' + f'but no corresponding top-level license record was found. ' + f'This indicates data inconsistency. Manual review required.' + ) + + self.assertEqual(1, len(results_data['skippedProviderDetails'])) + skipped_detail = results_data['skippedProviderDetails'][0] + + self.assertEqual(orphaned_provider_id, skipped_detail['providerId']) + self.assertIn('Manual review required', skipped_detail['reason']) + + # Check ineligible updates details + self.assertEqual(1, len(skipped_detail['ineligibleUpdates'])) + ineligible_update = skipped_detail['ineligibleUpdates'][0] + + self.assertEqual('licenseUpdate', ineligible_update['recordType']) + self.assertEqual('Orphaned', ineligible_update['typeOfUpdate']) + self.assertEqual(orphaned_license_update.licenseType, ineligible_update['licenseType']) + self.assertEqual(expected_reason, ineligible_update['reason']) + + # Verify no providers were reverted or failed + self.assertEqual(0, len(results_data['revertedProviderSummaries'])) + self.assertEqual(0, len(results_data['failedProviderDetails'])) + + def test_provider_skipped_when_encumbrance_update_created_within_upload_window(self): + from handlers.rollback_license_upload import rollback_license_upload + + # Setup: License was created during upload window + self._when_provider_had_license_created_from_upload() + + # Create an encumbrance update that happens WITHIN the upload window + # but is NOT an upload-related update type + encumbrance_time = self.default_upload_datetime + timedelta(minutes=1) + self.test_data_generator.put_default_license_update_record_in_provider_table( + { + 'providerId': self.provider_id, + 'compact': self.compact, + 'jurisdiction': self.license_jurisdiction, + 'updateType': self.update_categories.ENCUMBRANCE, # Not an upload-related category + 'createDate': encumbrance_time, + 'effectiveDate': encumbrance_time, + 'updatedValues': { + 'encumberedStatus': 'encumbered', + }, + } + ) + + # Execute: Perform rollback + event = self._generate_test_event() + result = rollback_license_upload(event, Mock()) + + # Assert: Rollback completed but provider was skipped + self.assertEqual('COMPLETE', result['rollbackStatus']) + self.assertEqual(0, result['providersReverted']) + self.assertEqual(1, result['providersSkipped']) + + # Verify: License record and encumbrance update still exist (not rolled back) + provider_records = self.config.data_client.get_provider_user_records( + compact=self.compact, + provider_id=self.provider_id, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + licenses = provider_records.get_license_records() + self.assertEqual(len(licenses), 1, 'License should still exist') + license_updates = provider_records.get_all_license_update_records() + self.assertEqual(1, len(license_updates), 'Encumbrance update should still exist') + + # Verify S3 results contain skip details + s3_key = f'licenseUploadRollbacks/{MOCK_EXECUTION_NAME}/results.json' + s3_obj = self.config.s3_client.get_object(Bucket=self.config.disaster_recovery_results_bucket_name, Key=s3_key) + results_data = json.loads(s3_obj['Body'].read().decode('utf-8')) + + self.assertEqual(1, len(results_data['skippedProviderDetails'])) + skipped_detail = results_data['skippedProviderDetails'][0] + self.assertEqual(self.provider_id, skipped_detail['providerId']) + self.assertIn('Manual review required', skipped_detail['reason']) diff --git a/backend/social-work-app/lambdas/python/feature-flag/custom_resource_handler.py b/backend/social-work-app/lambdas/python/feature-flag/custom_resource_handler.py new file mode 100644 index 0000000000..cb46deb960 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/custom_resource_handler.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +from abc import ABC, abstractmethod +from typing import TypedDict + +from aws_lambda_powertools.logging.lambda_context import build_lambda_context_model +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import logger + + +class CustomResourceResponse(TypedDict, total=False): + """Return body for the custom resource handler.""" + + PhysicalResourceId: str + Data: dict + NoEcho: bool + + +class CustomResourceHandler(ABC): + """Base class for custom resource migrations. + + This class provides a framework for implementing CloudFormation custom resources. + It handles the routing of CloudFormation events to appropriate methods and provides a consistent + logging pattern. + + Subclasses must implement the on_create, on_update, and on_delete methods. + + Instances of this class are callable and can be used directly as Lambda handlers. + """ + + def __init__(self, handler_name: str): + """Initialize the custom resource handler. + + :type handler_name: str + """ + self.handler_name = handler_name + + def __call__(self, event: dict, _context: LambdaContext) -> CustomResourceResponse | None: + return self._on_event(event, _context) + + def _on_event(self, event: dict, _context: LambdaContext) -> CustomResourceResponse | None: + """CloudFormation event handler using the CDK provider framework. + See: https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.custom_resources/README.html + + This method routes the event to the appropriate handler method based on the request type. + + :param event: The lambda event with properties in ResourceProperties + :type event: dict + :param _context: Lambda context + :type _context: LambdaContext + :return: Optional result from the handler method + :rtype: Optional[CustomResourceResponse] + :raises ValueError: If the request type is not supported + """ + + # @logger.inject_lambda_context doesn't work on instance methods, so we'll build the context manually + lambda_context = build_lambda_context_model(_context) + logger.structure_logs(**lambda_context.__dict__) + + logger.info(f'{self.handler_name} handler started') + + properties = event.get('ResourceProperties', {}) + request_type = event['RequestType'] + + match request_type: + case 'Create': + try: + resp = self.on_create(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} creation', exc_info=e) + raise + case 'Update': + try: + resp = self.on_update(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} update', exc_info=e) + raise + case 'Delete': + try: + resp = self.on_delete(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} delete', exc_info=e) + raise + case _: + raise ValueError(f'Unexpected request type: {request_type}') + + logger.info(f'{self.handler_name} handler complete') + return resp + + @abstractmethod + def on_create(self, properties: dict) -> CustomResourceResponse | None: + """Handle Create events. + + This method should be implemented by subclasses to perform the migration when a resource is being created. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ + + @abstractmethod + def on_update(self, properties: dict) -> CustomResourceResponse | None: + """Handle Update events. + + This method should be implemented by subclasses to perform the migration when a resource is being updated. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ + + @abstractmethod + def on_delete(self, properties: dict) -> CustomResourceResponse | None: + """Handle Delete events. + + This method should be implemented by subclasses to handle deletion of the migration. In many cases, this can + be a no-op as the migration is temporary and deletion should have no effect. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ diff --git a/backend/social-work-app/lambdas/python/feature-flag/feature_flag_client.py b/backend/social-work-app/lambdas/python/feature-flag/feature_flag_client.py new file mode 100644 index 0000000000..cdc56b74f1 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/feature_flag_client.py @@ -0,0 +1,626 @@ +# ruff: noqa: N801, N815 invalid-name + +import json +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any + +import boto3 +import requests +from botocore.exceptions import ClientError +from marshmallow import Schema, ValidationError +from marshmallow.fields import Dict as DictField +from marshmallow.fields import Nested, String +from marshmallow.validate import Length +from statsig_python_core import Statsig, StatsigOptions, StatsigUser + + +@dataclass +class FeatureFlagRequest: + """Request object for feature flag evaluation""" + + flagName: str # noqa: N815 + context: dict[str, Any] + + +@dataclass +class FeatureFlagResult: + """Result of a feature flag check""" + + enabled: bool + flag_name: str + metadata: dict[str, Any] | None = None + + +class FeatureFlagClient(ABC): + """ + Abstract base class for feature flag clients. + + This interface provides a consistent way to interact with different + feature flag providers (StatSig, LaunchDarkly, etc.) while hiding + the underlying implementation details. + """ + + def __init__(self, request_schema: Schema): + """ + Initialize the feature flag client with a provider-specific schema. + + :param request_schema: Schema instance for validating requests + """ + self._request_schema = request_schema + + def validate_request(self, request_body: dict[str, Any]) -> dict[str, Any]: + """ + Validate the feature flag check request using the provider-specific schema. + + :param request_body: Raw request body dictionary + :return: Validated request data + :raises FeatureFlagValidationException: If validation fails + """ + try: + return self._request_schema.load(request_body) + except ValidationError as e: + raise FeatureFlagValidationException(f'Invalid request: {e.messages}') from e + + @abstractmethod + def check_flag(self, request: FeatureFlagRequest) -> FeatureFlagResult: + """ + Check if a feature flag is enabled for the given request. + + :param request: FeatureFlagRequest containing flag name and context + :return: FeatureFlagResult indicating if flag is enabled + :raises FeatureFlagException: If flag check fails + """ + + @abstractmethod + def upsert_flag( + self, flag_name: str, auto_enable: bool = False, custom_attributes: dict[str, Any] | None = None + ) -> dict[str, Any]: + """ + Create or update a feature flag in the provider. + + In test environment: Creates a new flag if it doesn't exist. + In beta/prod: Updates existing flag to add current environment if auto_enable is True. + + :param flag_name: Name of the feature flag to create + :param auto_enable: If True, enable the flag in the current environment + :param custom_attributes: Optional custom attributes for targeting rules + :return: Dictionary containing flag data (including 'id' field) + :raises FeatureFlagException: If operation fails + """ + + @abstractmethod + def get_flag(self, flag_name: str) -> dict[str, Any] | None: + """ + Retrieve a feature flag by name. + + :param flag_name: Name of the feature flag to retrieve + :return: Flag data dictionary, or None if not found + :raises FeatureFlagException: If retrieval fails + """ + + @abstractmethod + def delete_flag(self, flag_name: str) -> bool | None: + """ + Delete a feature flag or remove current environment from it. + + If the flag has multiple environments, only the current environment is removed. + If the flag has only the current environment, the entire flag is deleted. + + :param flag_name: Name of the feature flag to delete + :return: True if flag was fully deleted, False if only environment was removed, None if flag doesn't exist + :raises FeatureFlagException: If operation fails + """ + + def _get_secret(self, secret_name: str) -> dict[str, Any]: + """ + Retrieve a secret from AWS Secrets Manager and return it as a JSON object. + + :param secret_name: Name of the secret in AWS Secrets Manager + :return: Dictionary containing the secret data + :raises FeatureFlagException: If secret retrieval fails + """ + try: + # Create a Secrets Manager client + session = boto3.session.Session() + client = session.client(service_name='secretsmanager') + + # Retrieve the secret value + response = client.get_secret_value(SecretId=secret_name) + + # Parse the secret string as JSON + return json.loads(response['SecretString']) + + except ClientError as e: + error_code = e.response['Error']['Code'] + raise FeatureFlagException(f"Failed to retrieve secret '{secret_name}': {error_code}") from e + except json.JSONDecodeError as e: + raise FeatureFlagException(f"Secret '{secret_name}' does not contain valid JSON") from e + except Exception as e: + raise FeatureFlagException(f"Unexpected error retrieving secret '{secret_name}': {e}") from e + + +# Custom exceptions +class FeatureFlagException(Exception): + """Base exception for feature flag operations""" + + +class FeatureFlagValidationException(FeatureFlagException): + """Exception raised when feature flag validation fails""" + + +# Implementing Classes + +STATSIG_DEVELOPMENT_TIER = 'development' +STATSIG_STAGING_TIER = 'staging' +STATSIG_PRODUCTION_TIER = 'production' + +STATSIG_ENVIRONMENT_MAPPING = { + 'prod': STATSIG_PRODUCTION_TIER, + 'beta': STATSIG_STAGING_TIER, + 'test': STATSIG_DEVELOPMENT_TIER, +} + +# StatSig Console API configuration +STATSIG_API_BASE_URL = 'https://statsigapi.net/console/v1' +STATSIG_API_VERSION = '20240601' + + +class StatSigContextSchema(Schema): + """ + StatSig-specific schema for feature flag context validation. + + Includes optional userId and customAttributes. + """ + + userId = String(required=False, allow_none=False, validate=Length(1, 100)) + customAttributes = DictField(required=False, allow_none=False, load_default=dict) + + +class StatSigFeatureFlagCheckRequestSchema(Schema): + """ + StatSig-specific schema for feature flag check requests. + + Includes optional context with userId and customAttributes. + """ + + context = Nested(StatSigContextSchema, required=False, allow_none=False, load_default=dict) + + +class StatSigFeatureFlagClient(FeatureFlagClient): + """ + StatSig implementation of the FeatureFlagClient interface. + + This client uses StatSig's Python SDK to check feature flags. + """ + + def __init__(self, environment: str): + """ + Initialize the StatSig client. + + :param environment: The CompactConnect environment the system is running in ('test', 'beta', 'prod') + """ + # Initialize parent class with StatSig-specific schema + super().__init__(StatSigFeatureFlagCheckRequestSchema()) + + self.environment = environment + self.statsig_client = None + self._is_initialized = False + + # Retrieve StatSig configuration from AWS Secrets Manager + secret_name = f'compact-connect/env/{environment}/statsig/credentials' + try: + secret_data = self._get_secret(secret_name) + self._server_secret_key = secret_data.get('serverKey') + self._console_api_key = secret_data.get('consoleKey') + + if not self._server_secret_key: + raise FeatureFlagException(f"Secret '{secret_name}' does not contain required 'serverKey' field") + + # If console API key not provided, try to get it from secret + if not self._console_api_key: + raise FeatureFlagException(f"Secret '{secret_name}' does not contain required 'consoleKey' field") + + except Exception as e: + if isinstance(e, FeatureFlagException): + raise + raise FeatureFlagException( + f"Failed to retrieve StatSig configuration from secret '{secret_name}': {e}" + ) from e + + self._initialize_statsig() + + def _initialize_statsig(self): + """Initialize the StatSig SDK if not already initialized""" + if self._is_initialized: + return + + try: + # default to development for all other environments (ie sandbox environments) + tier = STATSIG_ENVIRONMENT_MAPPING.get(self.environment.lower(), STATSIG_DEVELOPMENT_TIER) + options = StatsigOptions() + options.environment = tier + + self.statsig_client = Statsig(self._server_secret_key, options=options) + self.statsig_client.initialize().wait() + self._is_initialized = True + + except Exception as e: + raise FeatureFlagException(f'Failed to initialize StatSig client: {e}') from e + + def _create_statsig_user(self, context: dict[str, Any]) -> StatsigUser: + """Convert context dictionary to StatsigUser + + The SDK requires a StatSig user object to be passed in whenever checking a feature gate. + See https://docs.statsig.com/concepts/user/#why-is-an-id-always-required-for-server-sdks + """ + # note that we hardcode the user id if it is not provided by the caller, which means percentage based + # rules will have no effect, since StatSig always returns the same result for the same user. If callers + # want to have percentage based rules take effect, they need to pass in user ids with their requests. + user_data = { + 'user_id': context.get('userId') or 'default_cc_user', + } + + # Add custom attributes if provided + custom_attributes = context.get('customAttributes', {}) + if custom_attributes: + user_data.update({'custom': custom_attributes}) + + return StatsigUser(**user_data) + + def check_flag(self, request: FeatureFlagRequest) -> FeatureFlagResult: + """ + Check if a feature flag is enabled using StatSig. + + :param request: FeatureFlagRequest containing flag name and context + :return: FeatureFlagResult indicating if flag is enabled + :raises FeatureFlagException: If flag check fails + """ + if not request.flagName: + raise FeatureFlagValidationException('Flag name cannot be empty') + + try: + self._initialize_statsig() + + # Create StatSig user from context + statsig_user = self._create_statsig_user(request.context) + + # Check the gate (feature flag) using StatSig + enabled = self.statsig_client.check_gate(statsig_user, request.flagName) + + return FeatureFlagResult( + enabled=enabled, + flag_name=request.flagName, + ) + + except (FeatureFlagException, FeatureFlagValidationException) as e: + # If it's already a FeatureFlagException, re-raise it + raise e + except Exception as e: + # Otherwise, wrap it in a FeatureFlagException + raise FeatureFlagException(f"Failed to check feature flag '{request.flagName}': {e}") from e + + def _make_console_api_request( + self, method: str, endpoint: str, data: dict[str, Any] | None = None + ) -> requests.Response: + """ + Make a request to the StatSig Console API. + + :param method: HTTP method (GET, POST, PATCH, DELETE) + :param endpoint: API endpoint (e.g., '/gates') + :param data: Optional request payload + :return: Response object + :raises FeatureFlagException: If API key not configured or request fails + """ + if not self._console_api_key: + raise FeatureFlagException( + 'Console API key not configured. Required for management operations (create, update, delete).' + ) + + url = f'{STATSIG_API_BASE_URL}{endpoint}' + headers = { + 'STATSIG-API-KEY': self._console_api_key, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + } + + try: + if method == 'GET': + response = requests.get(url, headers=headers, timeout=30) + elif method == 'POST': + response = requests.post(url, headers=headers, json=data, timeout=30) + elif method == 'PATCH': + response = requests.patch(url, headers=headers, json=data, timeout=30) + elif method == 'DELETE': + response = requests.delete(url, headers=headers, timeout=30) + else: + raise ValueError(f'Unsupported HTTP method: {method}') + + return response + + except requests.exceptions.RequestException as e: + raise FeatureFlagException(f'StatSig Console API request failed: {e}') from e + + def upsert_flag( + self, flag_name: str, auto_enable: bool = False, custom_attributes: dict[str, Any] | None = None + ) -> dict[str, Any]: + """ + Create or update a feature gate in StatSig. + + Each environment has its own rule (e.g., 'test-rule', 'beta-rule', 'prod-rule'). + - If auto_enable is False: passPercentage is set to 0 (disabled) + - If auto_enable is True: passPercentage is set to 100 (enabled) + + :param flag_name: Name of the feature gate + :param auto_enable: If True, enable the flag (passPercentage=100); if False, disable it (passPercentage=0) + :param custom_attributes: Optional custom attributes for targeting + :return: Flag data (with 'id' field) + :raises FeatureFlagException: If operation fails + """ + # Check if gate already exists + existing_gate = self.get_flag(flag_name) + + if not existing_gate: + # Create new gate with environment-specific rule + return self._create_new_gate(flag_name, auto_enable, custom_attributes) + + # Gate exists - check if environment rule exists + gate_id = existing_gate.get('id') + rule_name = f'{self.environment.lower()}-rule' + environment_rule = self._find_environment_rule(existing_gate, rule_name) + + # we only set the environment rule if it doesn't already exist + # else we leave it alone to avoid overwriting manual changes + if not environment_rule: + self._add_environment_rule(gate_id, existing_gate, auto_enable, custom_attributes) + + return existing_gate + + def _create_new_gate( + self, flag_name: str, auto_enable: bool, custom_attributes: dict[str, Any] | None = None + ) -> dict[str, Any]: + """ + Create a new feature gate in StatSig with an environment-specific rule. + + :param flag_name: Name of the feature gate + :param auto_enable: If True, passPercentage=100; if False, passPercentage=0 + :param custom_attributes: Optional custom attributes for targeting (only applied if auto_enable=True) + :return: Created gate data (with 'id' field) + :raises FeatureFlagException: If creation fails + """ + # Get the StatSig environment tier for the current environment + statsig_tier = STATSIG_ENVIRONMENT_MAPPING.get(self.environment.lower(), STATSIG_DEVELOPMENT_TIER) + rule_name = f'{self.environment.lower()}-rule' + + # Build conditions for custom attributes if auto_enable is True + conditions = [] + if custom_attributes: + conditions = self._build_conditions_from_attributes(custom_attributes) + + # Build the feature gate payload + gate_payload = { + 'name': flag_name, + 'description': f'Feature gate managed by CDK for {flag_name} feature', + 'isEnabled': True, + 'rules': [ + { + 'name': rule_name, + 'conditions': conditions, + 'environments': [statsig_tier], + 'passPercentage': 100 if auto_enable else 0, + } + ], + } + # API spec https://docs.statsig.com/console-api/all-endpoints-generated#post-/console/v1/gates + response = self._make_console_api_request('POST', '/gates', gate_payload) + + if response.status_code in [200, 201]: + return response.json() + + raise FeatureFlagException(f'Failed to create feature gate: {response.status_code} - {response.text[:200]}') + + def _find_environment_rule(self, gate_data: dict[str, Any], rule_name: str) -> dict[str, Any] | None: + """ + Find an environment-specific rule in the gate data. + + :param gate_data: Gate configuration + :param rule_name: Name of the rule to find (e.g., 'test-rule', 'beta-rule', 'prod-rule') + :return: Rule data if found, None otherwise + """ + for rule in gate_data.get('rules', []): + if rule.get('name') == rule_name: + return rule + return None + + def _build_conditions_from_attributes(self, custom_attributes: dict[str, Any]) -> list[dict[str, Any]]: + """ + Build StatSig conditions from custom attributes. + + :param custom_attributes: Dictionary of custom attributes + :return: List of condition dictionaries + :raises FeatureFlagException: If attribute value is not a string or list + """ + conditions = [] + for key, value in custom_attributes.items(): + # Convert strings to lists, keep lists as-is, reject other types + if isinstance(value, str): + value = [value] + elif not isinstance(value, list): + raise FeatureFlagException(f'Custom attribute value must be a string or list: {value}') + + conditions.append({'type': 'custom_field', 'targetValue': value, 'field': key, 'operator': 'any'}) + return conditions + + def _add_environment_rule( + self, + gate_id: str, + gate_data: dict[str, Any], + auto_enable: bool, + custom_attributes: dict[str, Any] | None = None, + ) -> None: + """ + Add an environment-specific rule to an existing gate. + + :param gate_data: Original gate configuration + :param auto_enable: If True, passPercentage=100; if False, passPercentage=0 + :param custom_attributes: Optional custom attributes for targeting (only applied if auto_enable=True) + :return: Updated gate configuration + """ + updated_gate = gate_data.copy() + statsig_tier = STATSIG_ENVIRONMENT_MAPPING.get(self.environment.lower(), STATSIG_DEVELOPMENT_TIER) + rule_name = f'{self.environment.lower()}-rule' + + conditions = [] + # Build conditions if custom attributes were passed in + if custom_attributes: + conditions = self._build_conditions_from_attributes(custom_attributes) + + # Add new environment rule + new_rule = { + 'name': rule_name, + 'conditions': conditions, + 'environments': [statsig_tier], + 'passPercentage': 100 if auto_enable else 0, + } + + # Ensure rules list exists and add the new rule + if 'rules' not in updated_gate: + updated_gate['rules'] = [] + updated_gate['rules'].append(new_rule) + + self._update_gate(gate_id, updated_gate) + + def _update_gate(self, gate_id: str, gate_data: dict[str, Any]) -> bool: + """ + Update a feature gate using the PATCH endpoint. + + :param gate_id: ID of the feature gate to update + :param gate_data: Updated gate configuration + :return: True if successful + :raises FeatureFlagException: If update fails + """ + response = self._make_console_api_request('PATCH', f'/gates/{gate_id}', gate_data) + + if response.status_code in [200, 204]: + return True + + raise FeatureFlagException(f'Failed to update feature gate: {response.status_code} - {response.text[:200]}') + + def get_flag(self, flag_name: str) -> dict[str, Any] | None: + """ + Retrieve a feature gate by name. + + :param flag_name: Name of the feature gate to retrieve + :return: Gate data dictionary, or None if not found + :raises FeatureFlagException: If retrieval fails + """ + response = self._make_console_api_request('GET', '/gates') + + if response.status_code == 200: + gates_data = response.json() + + # API spec https://docs.statsig.com/console-api/all-endpoints-generated#get-/console/v1/gates + for gate in gates_data.get('data', []): + if gate.get('name') == flag_name: + return gate + return None + + raise FeatureFlagException(f'Failed to fetch gates: {response.status_code} - {response.text[:200]}') + + def delete_flag(self, flag_name: str) -> bool | None: + """ + Delete a feature gate or remove current environment rule from it. + + If the gate has only the current environment's rule, the entire gate is deleted. + If the gate has multiple environment rules, only the current environment's rule is removed. + + :param flag_name: Name of the feature flag to delete + :return: True if flag was fully deleted, False if only environment rule was removed, None if flag doesn't exist + :raises FeatureFlagException: If operation fails + """ + # Get the flag data first + flag_data = self.get_flag(flag_name) + if not flag_data: + return None # Flag doesn't exist + + flag_id = flag_data.get('id') + if not flag_id: + raise FeatureFlagException(f'Flag data missing ID field: {flag_name}') + + rule_name = f'{self.environment.lower()}-rule' + + # Check if current environment rule exists + environment_rule = self._find_environment_rule(flag_data, rule_name) + if not environment_rule: + # Environment rule doesn't exist, nothing to delete + return False + + # Count total number of rules in the gate + total_rules = len(flag_data.get('rules', [])) + + # If this is the only rule, delete the entire gate + if total_rules == 1: + response = self._make_console_api_request('DELETE', f'/gates/{flag_id}') + + if response.status_code in [200, 204]: + return True # Flag fully deleted + + raise FeatureFlagException(f'Failed to delete feature gate: {response.status_code} - {response.text[:200]}') + + # Remove only the current environment's rule + self._remove_environment_rule_from_flag(flag_id, flag_data, rule_name) + return False # Environment rule removed, not full deletion + + def _remove_environment_rule_from_flag(self, flag_id: str, flag_data: dict[str, Any], rule_name: str) -> bool: + """ + Remove an environment-specific rule from a feature gate. + + :param flag_id: ID of the feature gate + :param flag_data: Current flag configuration + :param rule_name: Name of the rule to remove (e.g., 'test-rule', 'beta-rule', 'prod-rule') + :return: True if rule was removed, False if it wasn't present + :raises FeatureFlagException: If operation fails + """ + # Prepare updated gate with the environment rule removed + updated_gate = flag_data.copy() + updated_rules = [rule for rule in updated_gate.get('rules', []) if rule.get('name') != rule_name] + + # If no rules were removed, the rule wasn't present + if len(updated_rules) == len(updated_gate.get('rules', [])): + return False + + updated_gate['rules'] = updated_rules + + # Update the gate + self._update_gate(flag_id, updated_gate) + return True + + def _shutdown(self): + """ + Shutdown the StatSig client to flush event logs to statsig. + """ + if self._is_initialized: + self.statsig_client.shutdown().wait() + self._is_initialized = False + + def __del__(self): + """ + Shutdown the StatSig client when the object is destroyed. + + This should be called to flush event logs to statsig when the lambda container shuts down. + """ + self._shutdown() + + +def create_feature_flag_client(environment: str) -> FeatureFlagClient: + """ + Factory function to create a FeatureFlagClient instance. + + This allows easy swapping of implementations based on configuration. + Currently only supports StatSig, but can be extended for other providers. + + :param environment: The CompactConnect environment the system is running in ('test', 'beta', 'prod') + :return: FeatureFlagClient instance + :raises FeatureFlagException: If client creation fails + """ + return StatSigFeatureFlagClient(environment=environment) diff --git a/backend/social-work-app/lambdas/python/feature-flag/handlers/__init__.py b/backend/social-work-app/lambdas/python/feature-flag/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/feature-flag/handlers/check_feature_flag.py b/backend/social-work-app/lambdas/python/feature-flag/handlers/check_feature_flag.py new file mode 100644 index 0000000000..8521443c94 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/handlers/check_feature_flag.py @@ -0,0 +1,57 @@ +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.exceptions import CCInternalException, CCInvalidRequestException +from cc_common.utils import api_handler +from feature_flag_client import FeatureFlagRequest, FeatureFlagValidationException, create_feature_flag_client + +# Initialize feature flag client outside of handler for caching +feature_flag_client = create_feature_flag_client(environment=config.environment_name) + + +@api_handler +def check_feature_flag(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Public endpoint for checking feature flags. + + This endpoint is designed to be called by other parts of the system for feature flag evaluation. + It abstracts away the underlying feature flag provider and provides a consistent interface for + checking feature flags. + """ + try: + # Extract flagId from path parameters + path_parameters = event.get('pathParameters') or {} + flag_id = path_parameters.get('flagId') + + if not flag_id: + raise CCInvalidRequestException('flagId is required in the URL path') + + # Parse and validate request body using client's validation + try: + body = json.loads(event['body'] or '{}') + validated_body = feature_flag_client.validate_request(body) + except json.JSONDecodeError as e: + logger.info('Request body is invalid json', error=str(e)) + raise CCInvalidRequestException(str(e)) from e + except FeatureFlagValidationException as e: + logger.info('Feature flag validation failed', error=str(e)) + raise CCInvalidRequestException(str(e)) from e + + # Create request object for flag evaluation with flagId from path + flag_request = FeatureFlagRequest(flagName=flag_id, context=validated_body.get('context', {})) + + # Check the feature flag + result = feature_flag_client.check_flag(flag_request) + + logger.debug('Feature flag checked', flag_name=flag_id, enabled=result.enabled) + + # Return simple response with just the enabled status + return {'enabled': result.enabled} + + except CCInvalidRequestException: + # Re-raise CC exceptions as-is + raise + except Exception as e: + logger.error(f'Unexpected error checking feature flag: {e}') + raise CCInternalException('Feature flag check failed') from e diff --git a/backend/social-work-app/lambdas/python/feature-flag/handlers/manage_feature_flag.py b/backend/social-work-app/lambdas/python/feature-flag/handlers/manage_feature_flag.py new file mode 100644 index 0000000000..f729d1f2e2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/handlers/manage_feature_flag.py @@ -0,0 +1,114 @@ +""" +Custom resource handler for managing StatSig feature flags. + +This handler manages feature flag lifecycle through CloudFormation custom resources, +automatically creating and configuring flags across different environments. +""" + +import os + +from cc_common.config import logger +from custom_resource_handler import CustomResourceHandler, CustomResourceResponse +from feature_flag_client import FeatureFlagException, StatSigFeatureFlagClient + + +class ManageFeatureFlagHandler(CustomResourceHandler): + """Handler for managing StatSig feature flags as custom resources""" + + def __init__(self): + super().__init__('ManageFeatureFlag') + self.environment = os.environ['ENVIRONMENT_NAME'] + # Create a StatSig client with console API access + self.client = StatSigFeatureFlagClient(environment=self.environment) + + def on_create(self, properties: dict) -> CustomResourceResponse | None: + """ + Handle Create events for feature flags. + + Creates or updates the feature flag based on environment and autoEnable setting. + + :param properties: ResourceProperties containing flagName, autoEnable, customAttributes + :return: CustomResourceResponse with PhysicalResourceId + """ + flag_name = properties.get('flagName') + auto_enable = properties.get('autoEnable', False) + custom_attributes = properties.get('customAttributes') + + if not flag_name: + raise ValueError('flagName is required in ResourceProperties') + + logger.info( + 'Creating feature flag resource', + flag_name=flag_name, + environment=self.environment, + auto_enable=auto_enable, + ) + + # Create or update the flag - client handles all environment-specific logic + flag_data = self.client.upsert_flag(flag_name, auto_enable, custom_attributes) + + # Handle the case where no action was taken (beta/prod with autoEnable=False and no existing flag) + if not flag_data: + logger.warning('Feature flag not created (autoEnable=False in beta/prod)', flag_name=flag_name) + return None + + # Extract gate ID from response + gate_id = flag_data.get('data', {}).get('id') + + logger.info('Feature flag resource created/updated successfully', flag_name=flag_name, gate_id=gate_id) + + # Return the gate ID as the PhysicalResourceId for tracking + return {'PhysicalResourceId': f'feature-flag-{flag_name}-{self.environment}', 'Data': {'gateId': gate_id}} + + def on_update(self, properties: dict) -> CustomResourceResponse | None: # noqa: ARG002 + """ + Flags are not updated once created in an environment. + + :param properties: ResourceProperties containing updated values + :return: None (no-op) + """ + return None + + def on_delete(self, properties: dict) -> CustomResourceResponse | None: + """ + Handle Delete events for feature flags. + + Removes the environment rule from the feature gate. If it's the last environment, + deletes the gate entirely. + + :param properties: ResourceProperties containing flagName + :return: Optional response data + """ + flag_name = properties.get('flagName') + + if not flag_name: + raise ValueError('flagName is required in ResourceProperties') + + logger.info('Deleting feature flag resource', flag_name=flag_name, environment=self.environment) + + try: + # Delete flag or remove current environment + # The delete_flag method handles all logic internally (fetching, checking environments, etc.) + result = self.client.delete_flag(flag_name) + except FeatureFlagException: + # log the error and return so we don't fail deployment + logger.error('Failed to delete feature flag', flag_name=flag_name) + return None + + if result is None: + logger.info('Feature gate does not exist, nothing to delete', flag_name=flag_name) + elif result is True: + logger.info('Feature gate fully deleted (was last environment)', flag_name=flag_name) + else: + logger.info('Removed current environment from feature gate', flag_name=flag_name) + + return None + + +# Lambda handler +handler = ManageFeatureFlagHandler() + + +def on_event(event: dict, context) -> dict | None: + """Lambda handler function for CloudFormation custom resource events""" + return handler(event, context) diff --git a/backend/social-work-app/lambdas/python/feature-flag/requirements-dev.in b/backend/social-work-app/lambdas/python/feature-flag/requirements-dev.in new file mode 100644 index 0000000000..e0c3124af6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb]>=5.0.12, <6 diff --git a/backend/social-work-app/lambdas/python/feature-flag/requirements-dev.txt b/backend/social-work-app/lambdas/python/feature-flag/requirements-dev.txt new file mode 100644 index 0000000000..49a7ffd2c7 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/requirements-dev.txt @@ -0,0 +1,68 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --cert=None --client-cert=None --index-url=None --no-emit-index-url --pip-args=None compact-connect/lambdas/python/feature-flag/requirements-dev.in +# +boto3==1.42.89 + # via moto +botocore==1.42.92 + # via + # boto3 + # moto + # s3transfer +certifi==2025.8.3 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.3 + # via requests +cryptography==46.0.7 + # via moto +docker==7.1.0 + # via moto +idna==3.10 + # via requests +jinja2==3.1.6 + # via moto +jmespath==1.0.1 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via + # jinja2 + # werkzeug +moto[dynamodb]==5.1.22 + # via -r requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==2.23 + # via cffi +python-dateutil==2.9.0.post0 + # via + # botocore + # moto +pyyaml==6.0.3 + # via responses +requests==2.33.1 + # via + # docker + # moto + # responses +responses==0.25.8 + # via moto +s3transfer==0.16.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.3 + # via moto +xmltodict==1.0.2 + # via moto diff --git a/backend/social-work-app/lambdas/python/feature-flag/requirements.in b/backend/social-work-app/lambdas/python/feature-flag/requirements.in new file mode 100644 index 0000000000..4aee6f2359 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/requirements.in @@ -0,0 +1,2 @@ +# common requirements are managed in the common requirements.in file +statsig-python-core>=0.9.3, <1.0.0 diff --git a/backend/social-work-app/lambdas/python/feature-flag/requirements.txt b/backend/social-work-app/lambdas/python/feature-flag/requirements.txt new file mode 100644 index 0000000000..1b8a3684e4 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/requirements.txt @@ -0,0 +1,20 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --cert=None --client-cert=None --index-url=None --no-emit-index-url --pip-args=None compact-connect/lambdas/python/feature-flag/requirements.in +# +certifi==2025.8.3 + # via requests +charset-normalizer==3.4.3 + # via requests +idna==3.10 + # via requests +requests==2.33.1 + # via statsig-python-core +statsig-python-core==0.19.1 + # via -r requirements.in +typing-extensions==4.15.0 + # via statsig-python-core +urllib3==2.7.0 + # via requests diff --git a/backend/social-work-app/lambdas/python/feature-flag/tests/__init__.py b/backend/social-work-app/lambdas/python/feature-flag/tests/__init__.py new file mode 100644 index 0000000000..4c65f50b42 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/tests/__init__.py @@ -0,0 +1,85 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'true', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'ENVIRONMENT_NAME': 'test', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': json.dumps( + [ + 'al', + 'ak', + 'az', + 'ar', + 'ca', + 'co', + 'ct', + 'de', + 'dc', + 'fl', + 'ga', + 'hi', + 'id', + 'il', + 'in', + 'ia', + 'ks', + 'ky', + 'la', + 'me', + 'md', + 'ma', + 'mi', + 'mn', + 'ms', + 'mo', + 'mt', + 'ne', + 'nv', + 'nh', + 'nj', + 'nm', + 'ny', + 'nc', + 'nd', + 'oh', + 'ok', + 'or', + 'pa', + 'pr', + 'ri', + 'sc', + 'sd', + 'tn', + 'tx', + 'ut', + 'vt', + 'va', + 'vi', + 'wa', + 'wv', + 'wi', + 'wy', + ] + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/feature-flag/tests/function/__init__.py b/backend/social-work-app/lambdas/python/feature-flag/tests/function/__init__.py new file mode 100644 index 0000000000..5ca33773f6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/tests/function/__init__.py @@ -0,0 +1,23 @@ +import logging +import os + +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + # This must be imported within the tests, since they import modules which require + # environment variables that are not set until the TstLambdas class is initialized + from common_test.test_data_generator import TestDataGenerator + + self.test_data_generator = TestDataGenerator diff --git a/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_check_feature_flag.py b/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_check_feature_flag.py new file mode 100644 index 0000000000..263a443739 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_check_feature_flag.py @@ -0,0 +1,166 @@ +import json +from unittest.mock import MagicMock, patch + +import boto3 +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestCheckFeatureFlag(TstFunction): + """Test suite for feature flag endpoint.""" + + def setUp(self): + super().setUp() + + # Set up environment variables for testing + import os + + os.environ['ENVIRONMENT_NAME'] = 'test' + + # Set up mock secrets manager with StatSig credentials + secrets_client = boto3.client('secretsmanager', region_name='us-east-1') + secrets_client.create_secret( + Name='compact-connect/env/test/statsig/credentials', + SecretString=json.dumps({'serverKey': 'test-server-key-123', 'consoleKey': 'test-console-key-456'}), + ) + + def tearDown(self): + """Clean up between tests to ensure test isolation""" + super().tearDown() + + # Reset the module-level feature_flag_client to force recreation in next test + # without this the client gets cached and cannot be modified + import sys + + if 'handlers.check_feature_flag' in sys.modules: + del sys.modules['handlers.check_feature_flag'] + + def _generate_test_api_gateway_event(self, body: dict, flag_id: str = 'test-flag') -> dict: + """Generate a test API Gateway event with flagId in path parameters""" + event = self.test_data_generator.generate_test_api_event() + event['body'] = json.dumps(body) + event['pathParameters'] = {'flagId': flag_id} + + return event + + def _setup_mock_statsig(self, mock_statsig, mock_flag_enabled_return: bool = True): + # Create a mock client instance + mock_client = MagicMock() + mock_client.initialize.return_value = MagicMock() + mock_client.check_gate.return_value = mock_flag_enabled_return + mock_client.shutdown.return_value = MagicMock() + + # Make the Statsig constructor return our mock client + mock_statsig.return_value = mock_client + + return mock_client + + @patch('feature_flag_client.Statsig') + def test_feature_flag_enabled_returns_true(self, mock_statsig): + """Test that when StatSig returns True, our handler returns enabled: true""" + self._setup_mock_statsig(mock_statsig, mock_flag_enabled_return=True) + from handlers.check_feature_flag import check_feature_flag + + # Create test event + test_body = { + 'context': {'userId': 'test-user-123', 'customAttributes': {'region': 'us-east-1'}}, + } + event = self._generate_test_api_gateway_event(test_body, flag_id='test-feature-flag') + + # Call the handler + result = check_feature_flag(event, self.mock_context) + + # Verify the API Gateway response format + self.assertEqual(result['statusCode'], 200) + + # Parse and verify the JSON body + response_body = json.loads(result['body']) + self.assertEqual({'enabled': True}, response_body) + + @patch('feature_flag_client.Statsig') + def test_feature_flag_disabled_returns_false(self, mock_statsig): + """Test that when StatSig returns False, our handler returns enabled: false""" + # Mock StatSig to return False for flag check + self._setup_mock_statsig(mock_statsig, mock_flag_enabled_return=False) + + from handlers.check_feature_flag import check_feature_flag + + # Create test event + test_body = {'context': {'userId': 'test-user-456'}} + event = self._generate_test_api_gateway_event(test_body, flag_id='disabled-feature-flag') + + # Call the handler + result = check_feature_flag(event, self.mock_context) + + # Verify the API Gateway response format + self.assertEqual(result['statusCode'], 200) + + # Parse and verify the JSON body + response_body = json.loads(result['body']) + self.assertEqual({'enabled': False}, response_body) + + @patch('feature_flag_client.Statsig') + def test_feature_flag_with_minimal_context(self, mock_statsig): + """Test feature flag check with minimal context (no userId or customAttributes)""" + # Mock StatSig to return True for flag check + self._setup_mock_statsig(mock_statsig, mock_flag_enabled_return=True) + + from handlers.check_feature_flag import check_feature_flag + + # Create test event with minimal context + test_body = {'context': {}} + event = self._generate_test_api_gateway_event(test_body, flag_id='minimal-test-flag') + + # Call the handler + result = check_feature_flag(event, self.mock_context) + + # Verify the API Gateway response format + self.assertEqual(result['statusCode'], 200) + + # Parse and verify the JSON body + response_body = json.loads(result['body']) + self.assertEqual({'enabled': True}, response_body) + + @patch('feature_flag_client.Statsig') + def test_missing_flag_id_returns_400(self, mock_statsig): + """Test that missing flagId in path parameters returns 400 error""" + self._setup_mock_statsig(mock_statsig, mock_flag_enabled_return=True) + from handlers.check_feature_flag import check_feature_flag + + # Create test event without flagId in path parameters + test_body = {'context': {'userId': 'test-user-123'}} + event = self._generate_test_api_gateway_event(test_body) + # Remove pathParameters to simulate missing flagId + event['pathParameters'] = None + + # Call the handler + result = check_feature_flag(event, self.mock_context) + + # Verify the API Gateway response format + self.assertEqual(result['statusCode'], 400) + + # Parse and verify the JSON body contains error message + response_body = json.loads(result['body']) + self.assertIn('flagId is required in the URL path', response_body['message']) + + @patch('feature_flag_client.Statsig') + def test_invalid_json_request_body_returns_400(self, mock_statsig): + """Test that an invalid JSON request body returns a 400 error""" + self._setup_mock_statsig(mock_statsig, mock_flag_enabled_return=True) + from handlers.check_feature_flag import check_feature_flag + + event = self._generate_test_api_gateway_event(body={}, flag_id='test-flag') + # Create test event with invalid json + event['body'] = 'invalid' + + # Call the handler + result = check_feature_flag(event, self.mock_context) + + # Verify the API Gateway response format + self.assertEqual(result['statusCode'], 400) + + # Parse and verify the JSON body contains error message + response_body = json.loads(result['body']) + self.assertIn('Expecting value: line 1 column 1 (char 0)', response_body['message']) diff --git a/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_manage_feature_flag.py b/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_manage_feature_flag.py new file mode 100644 index 0000000000..f71b8e961f --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_manage_feature_flag.py @@ -0,0 +1,104 @@ +import json +from unittest.mock import MagicMock, patch + +from moto import mock_aws + +from . import TstFunction + +MOCK_SERVER_KEY = 'test-server-key-123' +MOCK_CONSOLE_KEY = 'test-console-key-456' + + +@mock_aws +class TestManageFeatureFlagHandler(TstFunction): + """Test suite for ManageFeatureFlagHandler custom resource.""" + + def setUp(self): + super().setUp() + + # Set up mock secrets manager with StatSig credentials + secrets_client = self.create_mock_secrets_manager() + secrets_client.create_secret( + Name='compact-connect/env/test/statsig/credentials', + SecretString=json.dumps({'serverKey': MOCK_SERVER_KEY, 'consoleKey': MOCK_CONSOLE_KEY}), + ) + + def create_mock_secrets_manager(self): + """Create a mock secrets manager client""" + import boto3 + + return boto3.client('secretsmanager', region_name='us-east-1') + + @patch('handlers.manage_feature_flag.StatSigFeatureFlagClient') + def test_on_create_calls_upsert_flag_with_correct_params(self, mock_client_class): + """Test that on_create calls upsert_flag with the correct parameters""" + from handlers.manage_feature_flag import ManageFeatureFlagHandler + + # Set up mock client instance + mock_client = MagicMock() + # API spec https://docs.statsig.com/console-api/all-endpoints-generated#post-/console/v1/gates + mock_client.upsert_flag.return_value = {'data': {'id': 'test-flag', 'name': 'test-flag'}} + mock_client_class.return_value = mock_client + + handler = ManageFeatureFlagHandler() + properties = { + 'flagName': 'test-flag', + 'autoEnable': True, + 'customAttributes': {'region': 'us-east-1', 'feature': 'new'}, + } + + result = handler.on_create(properties) + + # Verify upsert_flag was called with correct parameters + mock_client.upsert_flag.assert_called_once_with('test-flag', True, {'region': 'us-east-1', 'feature': 'new'}) + + # Verify response + self.assertEqual(result['PhysicalResourceId'], 'feature-flag-test-flag-test') + self.assertEqual(result['Data']['gateId'], 'test-flag') + + @patch('handlers.manage_feature_flag.StatSigFeatureFlagClient') + def test_on_create_with_minimal_properties(self, mock_client_class): + """Test on_create with minimal required properties""" + from handlers.manage_feature_flag import ManageFeatureFlagHandler + + # Set up mock client instance + mock_client = MagicMock() + # API spec https://docs.statsig.com/console-api/all-endpoints-generated#post-/console/v1/gates + mock_client.upsert_flag.return_value = {'data': {'id': 'minimal-flag', 'name': 'minimal-flag'}} + mock_client_class.return_value = mock_client + + handler = ManageFeatureFlagHandler() + properties = {'flagName': 'minimal-flag'} + + result = handler.on_create(properties) + + # Verify upsert_flag was called with defaults + mock_client.upsert_flag.assert_called_once_with('minimal-flag', False, None) + + # Verify response + self.assertEqual(result['PhysicalResourceId'], 'feature-flag-minimal-flag-test') + self.assertEqual(result['Data']['gateId'], 'minimal-flag') + + @patch('handlers.manage_feature_flag.StatSigFeatureFlagClient') + def test_on_delete_calls_delete_flag_with_correct_params(self, mock_client_class): + """Test that on_delete calls delete_flag with the correct parameters""" + from handlers.manage_feature_flag import ManageFeatureFlagHandler + + # Set up mock client instance + mock_client = MagicMock() + mock_client.delete_flag.return_value = True # Flag fully deleted + mock_client_class.return_value = mock_client + + handler = ManageFeatureFlagHandler() + properties = {'flagName': 'delete-flag'} + + result = handler.on_delete(properties) + + # Verify client was initialized with correct environment + mock_client_class.assert_called_once_with(environment='test') + + # Verify delete_flag was called with correct parameters + mock_client.delete_flag.assert_called_once_with('delete-flag') + + # Should return None (successful deletion) + self.assertIsNone(result) diff --git a/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_statsig_client.py b/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_statsig_client.py new file mode 100644 index 0000000000..9a4a24ce00 --- /dev/null +++ b/backend/social-work-app/lambdas/python/feature-flag/tests/function/test_statsig_client.py @@ -0,0 +1,1122 @@ +import json +from unittest.mock import MagicMock, patch + +from feature_flag_client import ( + FeatureFlagException, + FeatureFlagRequest, + FeatureFlagValidationException, + StatSigFeatureFlagClient, +) +from moto import mock_aws +from statsig_python_core import StatsigOptions + +from . import TstFunction + +MOCK_SERVER_KEY = 'test-server-key-123' +MOCK_CONSOLE_KEY = 'test-console-key-456' + +STATSIG_API_BASE_URL = 'https://statsigapi.net/console/v1' +STATSIG_API_VERSION = '20240601' + + +@mock_aws +class TestStatSigClient(TstFunction): + """Test suite for StatSig feature flag client.""" + + def setUp(self): + super().setUp() + + # Set up mock secrets manager with StatSig credentials + secrets_client = self.create_mock_secrets_manager() + for env in ['sandbox', 'test', 'beta', 'prod']: + secrets_client.create_secret( + Name=f'compact-connect/env/{env}/statsig/credentials', + SecretString=json.dumps({'serverKey': MOCK_SERVER_KEY, 'consoleKey': MOCK_CONSOLE_KEY}), + ) + + def create_mock_secrets_manager(self): + """Create a mock secrets manager client""" + import boto3 + + return boto3.client('secretsmanager', region_name='us-east-1') + + def _setup_mock_statsig(self, mock_statsig, mock_flag_enabled_return: bool = True): + # Create a mock client instance + mock_client = MagicMock() + mock_client.initialize.return_value = MagicMock() + mock_client.check_gate.return_value = mock_flag_enabled_return + mock_client.shutdown.return_value = MagicMock() + + # Make the Statsig constructor return our mock client + mock_statsig.return_value = mock_client + + return mock_client + + def test_client_initialization_missing_secret(self): + """Test that client initialization fails when secret is missing""" + with self.assertRaises(FeatureFlagException) as context: + StatSigFeatureFlagClient(environment='nonexistent') + + self.assertIn( + "Failed to retrieve secret 'compact-connect/env/nonexistent/statsig/credentials'", str(context.exception) + ) + + @patch('feature_flag_client.Statsig') + def test_validate_request_success(self, mock_statsig): + """Test request validation with valid data""" + self._setup_mock_statsig(mock_statsig) + + client = StatSigFeatureFlagClient(environment='test') + + # Valid request data + request_data = { + 'context': {'userId': 'user123', 'customAttributes': {'region': 'us-east-1'}}, + } + + # Should validate successfully + client.validate_request(request_data) + + @patch('feature_flag_client.Statsig') + def test_validate_request_minimal_data(self, mock_statsig): + """Test request validation with minimal valid data""" + self._setup_mock_statsig(mock_statsig) + mock_statsig.initialize.return_value = MagicMock() + + client = StatSigFeatureFlagClient(environment='test') + + # Minimal valid request data + request_data = {} + + # Should validate successfully with defaults + validated = client.validate_request(request_data) + + self.assertEqual(validated['context'], {}) # Default empty context + + @patch('feature_flag_client.Statsig') + def test_validate_request_invalid_flag_name(self, mock_statsig): + """Test request validation fails when flagName is empty""" + self._setup_mock_statsig(mock_statsig) + + client = StatSigFeatureFlagClient(environment='test') + + # Invalid request data - empty flagName + request_data = {'flagName': '', 'context': {}} + + with self.assertRaises(FeatureFlagValidationException): + client.validate_request(request_data) + + @patch('feature_flag_client.Statsig') + def test_check_flag_enabled(self, mock_statsig): + """Test check_flag returns enabled=True when StatSig returns True""" + mock_statsig_client = self._setup_mock_statsig(mock_statsig) + + client = StatSigFeatureFlagClient(environment='test') + + # Create request + request = FeatureFlagRequest(flagName='enabled-flag', context={'userId': 'user123'}) + + # Check flag + result = client.check_flag(request) + + # Verify result + self.assertTrue(result.enabled) + self.assertEqual(result.flag_name, 'enabled-flag') + + # Verify StatSig was called correctly + mock_statsig_client.check_gate.assert_called_once() + call_args = mock_statsig_client.check_gate.call_args + statsig_user = call_args[0][0] + flag_name = call_args[0][1] + + self.assertEqual(statsig_user.user_id, 'user123') + self.assertEqual(flag_name, 'enabled-flag') + + @patch('feature_flag_client.Statsig') + def test_check_flag_disabled(self, mock_statsig): + """Test check_flag returns enabled=False when StatSig returns False""" + self._setup_mock_statsig(mock_statsig, mock_flag_enabled_return=False) + + client = StatSigFeatureFlagClient(environment='test') + + # Create request + request = FeatureFlagRequest(flagName='disabled-flag', context={'userId': 'user456'}) + + # Check flag + result = client.check_flag(request) + + # Verify result + self.assertFalse(result.enabled) + self.assertEqual(result.flag_name, 'disabled-flag') + + @patch('feature_flag_client.Statsig') + def test_check_flag_with_custom_attributes(self, mock_statsig): + """Test check_flag properly handles custom attributes""" + mock_statsig_client = self._setup_mock_statsig(mock_statsig) + + client = StatSigFeatureFlagClient(environment='test') + + # Create request with custom attributes + request = FeatureFlagRequest( + flagName='custom-flag', + context={ + 'userId': 'user789', + 'customAttributes': {'foo': 'bar'}, + }, + ) + + # Check flag + result = client.check_flag(request) + + # Verify result + self.assertTrue(result.enabled) + + # Verify StatSig user was created with custom attributes + call_args = mock_statsig_client.check_gate.call_args + statsig_user = call_args[0][0] + flag_name = call_args[0][1] + + self.assertEqual('user789', statsig_user.user_id) + self.assertEqual({'foo': 'bar'}, statsig_user.custom) + self.assertEqual('custom-flag', flag_name) + + @patch('feature_flag_client.Statsig') + def test_check_flag_default_user(self, mock_statsig): + """Test check_flag uses default user when no userId provided""" + mock_statsig_client = self._setup_mock_statsig(mock_statsig) + + client = StatSigFeatureFlagClient(environment='test') + + # Create request without userId + request = FeatureFlagRequest(flagName='default-user-flag', context={}) + + # Check flag + result = client.check_flag(request) + + # Verify result + self.assertTrue(result.enabled) + + # Verify default user was used + call_args = mock_statsig_client.check_gate.call_args + statsig_user = call_args[0][0] + + self.assertEqual(statsig_user.user_id, 'default_cc_user') + + @patch('feature_flag_client.Statsig') + def test_environment_tier_mapping(self, mock_statsig): + """Test that different environments map to correct StatSig tiers""" + self._setup_mock_statsig(mock_statsig) + + # Test different environments + test_cases = [ + ('test', 'development'), + ('beta', 'staging'), + ('prod', 'production'), + ('sandbox', 'development'), # Unknown environments default to development + ] + + for cc_env, expected_tier in test_cases: + # Create client + StatSigFeatureFlagClient(environment=cc_env) + + # Verify StatSig was called correctly + mock_statsig.assert_called_once() + call_args = mock_statsig.call_args + server_key = call_args[0][0] + options: StatsigOptions = call_args.kwargs['options'] + + self.assertEqual(MOCK_SERVER_KEY, server_key) + self.assertEqual(expected_tier, options.environment) + + mock_statsig.reset_mock() + + def _create_mock_response(self, status_code: int, json_data: dict = None): + """Create a mock requests response""" + mock_response = MagicMock() + mock_response.status_code = status_code + mock_response.json.return_value = json_data or {} + mock_response.text = json.dumps(json_data) if json_data else '' + return mock_response + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_create_new_in_test_environment(self, mock_requests, mock_statsig): + """Test creating a new flag in test environment with auto_enable=true (passPercentage=100 for dev)""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + # Mock POST request (create flag) + created_flag = {'id': 'gate-123', 'name': 'new-test-flag', 'data': {'id': 'gate-123'}} + mock_requests.post.return_value = self._create_mock_response(201, created_flag) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.upsert_flag('new-test-flag', auto_enable=True, custom_attributes={'region': 'us-east-1'}) + + # Verify result + self.assertEqual(result['id'], 'gate-123') + self.assertEqual(result['name'], 'new-test-flag') + + # Verify API calls + mock_requests.get.assert_called_once() + # Verify POST payload + mock_requests.post.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'name': 'new-test-flag', + 'description': 'Feature gate managed by CDK for new-test-flag feature', + 'isEnabled': True, + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [ + { + 'type': 'custom_field', + 'targetValue': ['us-east-1'], + 'field': 'region', + 'operator': 'any', + } + ], + 'environments': ['development'], + 'passPercentage': 100, # Always 100 if auto_enable is true + } + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_create_new_in_test_environment_no_attributes(self, mock_requests, mock_statsig): + """Test creating a new flag in test environment without custom attributes""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + # Mock POST request (create flag) + created_flag = {'id': 'gate-456', 'name': 'simple-flag', 'data': {'id': 'gate-456'}} + mock_requests.post.return_value = self._create_mock_response(201, created_flag) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.upsert_flag('simple-flag') + + # Verify result + self.assertEqual(result['id'], 'gate-456') + + # Verify API calls + mock_requests.get.assert_called_once() + mock_requests.post.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'name': 'simple-flag', + 'description': 'Feature gate managed by CDK for simple-flag feature', + 'isEnabled': True, + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 0, + } + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_does_not_update_existing_rule(self, mock_requests, mock_statsig): + """Test updating an existing flag in test environment (test-rule already exists, no modifications in test)""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-789', + 'name': 'existing-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [{'field': 'old_attr', 'targetValue': ['old_value']}], + 'environments': ['development'], + 'passPercentage': 100, + } + ], + } + + # Mock GET request (flag exists with test-rule) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + # Mock PATCH request (update test-rule) + mock_requests.patch.return_value = self._create_mock_response(200) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.upsert_flag('existing-flag', auto_enable=False, custom_attributes={'new_attr': 'new_value'}) + + # Verify result - no modification happens in test when rule already exists + self.assertEqual(result['id'], 'gate-789') + + # Verify API calls - no PATCH since test environment doesn't modify existing rules + self.assertEqual(1, mock_requests.get.call_count) + mock_requests.patch.assert_not_called() + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_prod_environment_auto_enable_false_no_existing_flag(self, mock_requests, mock_statsig): + """Test upsert in prod environment with autoEnable=False and no existing flag - creates with passPercentage=0""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + # Mock POST request (create flag with passPercentage=0) + created_flag = {'id': 'gate-prod-disabled', 'name': 'prod-flag', 'data': {'id': 'gate-prod-disabled'}} + mock_requests.post.return_value = self._create_mock_response(201, created_flag) + + client = StatSigFeatureFlagClient(environment='prod') + + result = client.upsert_flag('prod-flag', auto_enable=False) + + # Verify result + self.assertEqual(result['id'], 'gate-prod-disabled') + + # Verify API calls + mock_requests.get.assert_called_once() + mock_requests.post.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'name': 'prod-flag', + 'description': 'Feature gate managed by CDK for prod-flag feature', + 'isEnabled': True, + 'rules': [ + { + 'name': 'prod-rule', + 'conditions': [], + 'environments': ['production'], + 'passPercentage': 0, # Disabled in prod when auto_enable=False + } + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_prod_environment_auto_enable_true_no_existing_flag(self, mock_requests, mock_statsig): + """Test upsert in prod environment with autoEnable=True and no existing flag""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + # Mock POST request (create flag) + created_flag = {'id': 'gate-prod', 'name': 'prod-flag', 'data': {'id': 'gate-prod'}} + mock_requests.post.return_value = self._create_mock_response(201, created_flag) + + client = StatSigFeatureFlagClient(environment='prod') + + result = client.upsert_flag('prod-flag', auto_enable=True) + + # Verify result + self.assertEqual(result['id'], 'gate-prod') + + # Verify API calls + mock_requests.get.assert_called_once() + mock_requests.post.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'name': 'prod-flag', + 'description': 'Feature gate managed by CDK for prod-flag feature', + 'isEnabled': True, + 'rules': [ + { + 'name': 'prod-rule', + 'conditions': [], + 'environments': ['production'], + 'passPercentage': 100, + } + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_beta_environment_auto_enable_false_no_existing_rule_create_rule( + self, mock_requests, mock_statsig + ): + """Test upsert in beta environment with autoEnable=false and no existing flag creates beta rule""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'existing-flag', + 'name': 'existing-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [{'field': 'old_attr', 'targetValue': ['old_value']}], + 'environments': ['development'], + 'passPercentage': 100, + } + ], + } + + # Mock GET request (flag exists with test-rule) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + mock_requests.patch.return_value = self._create_mock_response(200) + + client = StatSigFeatureFlagClient(environment='beta') + + result = client.upsert_flag('existing-flag', auto_enable=False) + + # Verify result + self.assertEqual(result['id'], 'existing-flag') + + # Verify API calls + mock_requests.get.assert_called_once() + mock_requests.patch.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates/existing-flag', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'id': 'existing-flag', + 'name': 'existing-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [{'field': 'old_attr', 'targetValue': ['old_value']}], + 'environments': ['development'], + 'passPercentage': 100, + }, + { + 'name': 'beta-rule', + 'conditions': [], + 'environments': ['staging'], + 'passPercentage': 0, + }, + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_prod_environment_existing_flag_auto_enable_true(self, mock_requests, mock_statsig): + """Test upsert in prod environment with existing flag (no prod-rule yet) and autoEnable=True - adds prod-rule""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-existing-prod', + 'name': 'existing-prod-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 100, + } + ], + } + + # Mock GET requests (flag exists, then return updated flag) + mock_requests.get.side_effect = [ + self._create_mock_response(200, {'data': [existing_flag]}), # First call to check existence + ] + + # Mock PATCH request (add prod-rule) + mock_requests.patch.return_value = self._create_mock_response(200) + + client = StatSigFeatureFlagClient(environment='prod') + + result = client.upsert_flag('existing-prod-flag', auto_enable=True, custom_attributes={'example': 'value'}) + + # Verify result + self.assertEqual(result['id'], 'gate-existing-prod') + + # Verify API calls - adds prod-rule to existing flag + self.assertEqual(1, mock_requests.get.call_count) + mock_requests.patch.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates/gate-existing-prod', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'id': 'gate-existing-prod', + 'name': 'existing-prod-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 100, + }, + { + 'name': 'prod-rule', + 'conditions': [ + { + 'type': 'custom_field', + 'targetValue': ['value'], + 'field': 'example', + 'operator': 'any', + } + ], + 'environments': ['production'], + 'passPercentage': 100, + }, + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_prod_environment_existing_flag_auto_enable_false_should_not_update_flag( + self, mock_requests, mock_statsig + ): + """Test upsert in prod environment with existing prod-rule and autoEnable=False - no modification""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-existing-prod-2', + 'name': 'existing-prod-flag-2', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 100, + }, + { + 'name': 'prod-rule', + 'conditions': [{'field': 'old', 'targetValue': ['value']}], + 'environments': ['production'], + 'passPercentage': 0, + }, + ], + } + + # Mock GET request (flag exists with prod-rule) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + client = StatSigFeatureFlagClient(environment='prod') + + result = client.upsert_flag('existing-prod-flag-2', auto_enable=False, custom_attributes={'new': 'attr'}) + + # Verify result - no modification when auto_enable=False and rule exists + self.assertEqual(result['id'], 'gate-existing-prod-2') + + # Verify API calls - no PATCH since auto_enable=False + self.assertEqual(mock_requests.get.call_count, 1) + mock_requests.patch.assert_not_called() + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_api_error_handling(self, mock_requests, mock_statsig): + """Test error handling when StatSig API returns errors""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request failure + mock_requests.get.return_value = self._create_mock_response(500, {'error': 'Internal server error'}) + + client = StatSigFeatureFlagClient(environment='test') + + with self.assertRaises(FeatureFlagException) as context: + client.upsert_flag('error-flag') + + self.assertIn('Failed to fetch gates', str(context.exception)) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_create_api_error_raises_exception(self, mock_requests, mock_statsig): + """Test error handling when flag creation fails""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + # Mock POST request failure + mock_requests.post.return_value = self._create_mock_response(400, {'error': 'Bad request'}) + + client = StatSigFeatureFlagClient(environment='test') + + with self.assertRaises(FeatureFlagException) as context: + client.upsert_flag('create-error-flag') + + self.assertIn('Failed to create feature gate', str(context.exception)) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_update_api_error_raises_exception(self, mock_requests, mock_statsig): + """Test error handling when flag update fails""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-update-error', + 'name': 'update-error-flag', + 'rules': [{'name': 'environment_toggle', 'conditions': [], 'environments': ['development']}], + } + + # Mock GET request (flag exists) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + # Mock PATCH request failure + mock_requests.patch.return_value = self._create_mock_response(403, {'error': 'Forbidden'}) + + client = StatSigFeatureFlagClient(environment='test') + + with self.assertRaises(FeatureFlagException) as context: + client.upsert_flag('update-error-flag', custom_attributes={'test': 'value'}) + + self.assertIn('Failed to update feature gate', str(context.exception)) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_delete_flag_not_found(self, mock_requests, mock_statsig): + """Test delete_flag when flag doesn't exist""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.delete_flag('nonexistent-flag') + + # Should return None (flag doesn't exist) + self.assertIsNone(result) + + # Should only call GET, not DELETE or PATCH + mock_requests.get.assert_called_once() + mock_requests.delete.assert_not_called() + mock_requests.patch.assert_not_called() + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_delete_flag_last_rule_deletes_entire_flag(self, mock_requests, mock_statsig): + """Test delete_flag when test-rule is the only rule - should delete entire flag""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-delete-last', + 'name': 'delete-last-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 100, + } + ], + } + + # Mock GET request (flag exists with only test-rule) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + # Mock DELETE request (delete entire flag) + mock_requests.delete.return_value = self._create_mock_response(200) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.delete_flag('delete-last-flag') + + # Should return True (flag fully deleted) + self.assertTrue(result) + + # Verify API calls + mock_requests.get.assert_called_once() + mock_requests.delete.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates/gate-delete-last', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + timeout=30, + ) + mock_requests.patch.assert_not_called() + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_delete_flag_multiple_rules_removes_current_rule_only(self, mock_requests, mock_statsig): + """Test delete_flag when flag has multiple rules - should only remove test-rule""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-delete-multi', + 'name': 'delete-multi-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 100, + }, + { + 'name': 'beta-rule', + 'conditions': [], + 'environments': ['staging'], + 'passPercentage': 100, + }, + { + 'name': 'prod-rule', + 'conditions': [], + 'environments': ['production'], + 'passPercentage': 100, + }, + ], + } + + # Mock GET request (flag exists with multiple rules) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + # Mock PATCH request (remove test-rule) + mock_requests.patch.return_value = self._create_mock_response(200) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.delete_flag('delete-multi-flag') + + # Should return False (rule removed, not full deletion) + self.assertFalse(result) + + # Verify API calls + mock_requests.get.assert_called_once() + mock_requests.patch.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates/gate-delete-multi', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'id': 'gate-delete-multi', + 'name': 'delete-multi-flag', + 'rules': [ + { + 'name': 'beta-rule', + 'conditions': [], + 'environments': ['staging'], + 'passPercentage': 100, + }, + { + 'name': 'prod-rule', + 'conditions': [], + 'environments': ['production'], + 'passPercentage': 100, + }, + ], + }, + timeout=30, + ) + mock_requests.delete.assert_not_called() + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_delete_flag_prod_environment_last_rule(self, mock_requests, mock_statsig): + """Test delete_flag in prod environment when prod-rule is the only rule""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-delete-prod', + 'name': 'delete-prod-flag', + 'rules': [ + { + 'name': 'prod-rule', + 'conditions': [], + 'environments': ['production'], + 'passPercentage': 100, + } + ], + } + + # Mock GET request (flag exists with only prod-rule) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + # Mock DELETE request (delete entire flag) + mock_requests.delete.return_value = self._create_mock_response(200) + + client = StatSigFeatureFlagClient(environment='prod') + + result = client.delete_flag('delete-prod-flag') + + # Should return True (flag fully deleted) + self.assertTrue(result) + + # Verify API calls + mock_requests.get.assert_called_once() + mock_requests.delete.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates/gate-delete-prod', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_delete_flag_current_rule_not_present(self, mock_requests, mock_statsig): + """Test delete_flag when current environment rule is not in the flag""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-delete-not-present', + 'name': 'delete-not-present-flag', + 'rules': [ + { + 'name': 'beta-rule', + 'conditions': [], + 'environments': ['staging'], + 'passPercentage': 100, + }, + { + 'name': 'prod-rule', + 'conditions': [], + 'environments': ['production'], + 'passPercentage': 100, + }, + ], + } + + # Mock GET request (flag exists but test-rule not present) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.delete_flag('delete-not-present-flag') + + # Should return False (no rule removed since it wasn't there) + self.assertFalse(result) + + # Should not call PATCH or DELETE since rule wasn't present + mock_requests.get.assert_called_once() + mock_requests.patch.assert_not_called() + mock_requests.delete.assert_not_called() + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_delete_flag_api_error_on_delete_raises_exception(self, mock_requests, mock_statsig): + """Test delete_flag error handling when DELETE request fails""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-delete-error', + 'name': 'delete-error-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 100, + } + ], + } + + # Mock GET request (flag exists) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + # Mock DELETE request failure + mock_requests.delete.return_value = self._create_mock_response(403, {'error': 'Forbidden'}) + + client = StatSigFeatureFlagClient(environment='test') + + with self.assertRaises(FeatureFlagException) as context: + client.delete_flag('delete-error-flag') + + self.assertIn('Failed to delete feature gate', str(context.exception)) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_delete_flag_api_error_on_patch_raises_exception(self, mock_requests, mock_statsig): + """Test delete_flag error handling when PATCH request fails""" + self._setup_mock_statsig(mock_statsig) + + existing_flag = { + 'id': 'gate-patch-error', + 'name': 'patch-error-flag', + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [], + 'environments': ['development'], + 'passPercentage': 100, + }, + { + 'name': 'beta-rule', + 'conditions': [], + 'environments': ['staging'], + 'passPercentage': 100, + }, + ], + } + + # Mock GET request (flag exists) + mock_requests.get.return_value = self._create_mock_response(200, {'data': [existing_flag]}) + + # Mock PATCH request failure + mock_requests.patch.return_value = self._create_mock_response(400, {'error': 'Bad request'}) + + client = StatSigFeatureFlagClient(environment='test') + + with self.assertRaises(FeatureFlagException) as context: + client.delete_flag('patch-error-flag') + + self.assertIn('Failed to update feature gate', str(context.exception)) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_custom_attributes_as_string(self, mock_requests, mock_statsig): + """Test upsert_flag with custom attributes as string values""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + # Mock POST request (create flag) + created_flag = {'id': 'gate-string-attrs', 'name': 'string-attrs-flag', 'data': {'id': 'gate-string-attrs'}} + mock_requests.post.return_value = self._create_mock_response(201, created_flag) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.upsert_flag( + 'string-attrs-flag', auto_enable=False, custom_attributes={'region': 'us-east-1', 'feature': 'new'} + ) + + # Verify result + self.assertEqual(result['id'], 'gate-string-attrs') + + # Verify API calls - string values should be converted to lists; conditions included even when auto_enable=False + mock_requests.post.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'name': 'string-attrs-flag', + 'description': 'Feature gate managed by CDK for string-attrs-flag feature', + 'isEnabled': True, + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [ + { + 'type': 'custom_field', + 'targetValue': ['us-east-1'], + 'field': 'region', + 'operator': 'any', + }, + {'type': 'custom_field', 'targetValue': ['new'], 'field': 'feature', 'operator': 'any'}, + ], + 'environments': ['development'], + 'passPercentage': 0, # 0 since auto_enable is false + } + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_custom_attributes_as_list(self, mock_requests, mock_statsig): + """Test upsert_flag with custom attributes as list values""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + # Mock POST request (create flag) + created_flag = {'id': 'gate-list-attrs', 'name': 'list-attrs-flag', 'data': {'id': 'gate-list-attrs'}} + mock_requests.post.return_value = self._create_mock_response(201, created_flag) + + client = StatSigFeatureFlagClient(environment='test') + + result = client.upsert_flag( + 'list-attrs-flag', auto_enable=False, custom_attributes={'licenseType': ['cos', 'esth']} + ) + + # Verify result + self.assertEqual(result['id'], 'gate-list-attrs') + + # Verify API calls - list values preserved; conditions included even when auto_enable=False + mock_requests.post.assert_called_once_with( + f'{STATSIG_API_BASE_URL}/gates', + headers={ + 'STATSIG-API-KEY': MOCK_CONSOLE_KEY, + 'STATSIG-API-VERSION': STATSIG_API_VERSION, + 'Content-Type': 'application/json', + }, + json={ + 'name': 'list-attrs-flag', + 'description': 'Feature gate managed by CDK for list-attrs-flag feature', + 'isEnabled': True, + 'rules': [ + { + 'name': 'test-rule', + 'conditions': [ + { + 'type': 'custom_field', + 'targetValue': ['cos', 'esth'], + 'field': 'licenseType', + 'operator': 'any', + } + ], + 'environments': ['development'], + 'passPercentage': 0, + } + ], + }, + timeout=30, + ) + + @patch('feature_flag_client.Statsig') + @patch('feature_flag_client.requests') + def test_upsert_flag_custom_attributes_invalid_type_raises_exception(self, mock_requests, mock_statsig): + """Test upsert_flag with custom attributes as invalid type (dict) raises exception""" + self._setup_mock_statsig(mock_statsig) + + # Mock GET request (flag doesn't exist) + mock_requests.get.return_value = self._create_mock_response(200, {'data': []}) + + client = StatSigFeatureFlagClient(environment='prod') + + # Try to create flag with invalid custom attribute type (dict) when auto_enable=True + with self.assertRaises(FeatureFlagException) as context: + client.upsert_flag( + 'invalid-attrs-flag', + auto_enable=True, + custom_attributes={ + 'invalid_attr': {'nested': 'dict'} # This should raise an exception + }, + ) + + self.assertIn('Custom attribute value must be a string or list', str(context.exception)) diff --git a/backend/social-work-app/lambdas/python/migration/custom_resource_handler.py b/backend/social-work-app/lambdas/python/migration/custom_resource_handler.py new file mode 100644 index 0000000000..29ee5a56d9 --- /dev/null +++ b/backend/social-work-app/lambdas/python/migration/custom_resource_handler.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +from abc import ABC, abstractmethod +from typing import TypedDict + +from aws_lambda_powertools.logging.lambda_context import build_lambda_context_model +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import logger + + +class CustomResourceResponse(TypedDict, total=False): + """Return body for the custom resource handler.""" + + PhysicalResourceId: str + Data: dict + NoEcho: bool + + +class CustomResourceHandler(ABC): + """Base class for custom resource migrations. + + This class provides a framework for implementing temporary data migrations as custom resources. + It handles the routing of CloudFormation events to appropriate methods and provides a consistent + logging pattern. + + Subclasses must implement the on_create, on_update, and on_delete methods. + + Instances of this class are callable and can be used directly as Lambda handlers. + """ + + def __init__(self, handler_name: str): + """Initialize the custom resource handler. + + :param migration_name: A descriptive name for the handler, used in logging + :type handler_name: str + """ + self.handler_name = handler_name + + def __call__(self, event: dict, _context: LambdaContext) -> CustomResourceResponse | None: + return self._on_event(event, _context) + + def _on_event(self, event: dict, _context: LambdaContext) -> CustomResourceResponse | None: + """CloudFormation event handler using the CDK provider framework. + See: https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.custom_resources/README.html + + This method routes the event to the appropriate handler method based on the request type. + + :param event: The lambda event with properties in ResourceProperties + :type event: dict + :param context: Lambda context + :type context: LambdaContext + :return: Optional result from the handler method + :rtype: Optional[CustomResourceResponse] + :raises ValueError: If the request type is not supported + """ + + # @logger.inject_lambda_context doesn't work on instance methods, so we'll build the context manually + lambda_context = build_lambda_context_model(_context) + logger.structure_logs(**lambda_context.__dict__) + + logger.info(f'{self.handler_name} handler started') + + properties = event.get('ResourceProperties', {}) + request_type = event['RequestType'] + + match request_type: + case 'Create': + try: + resp = self.on_create(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} creation', exc_info=e) + raise + case 'Update': + try: + resp = self.on_update(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} update', exc_info=e) + raise + case 'Delete': + try: + resp = self.on_delete(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} delete', exc_info=e) + raise + case _: + raise ValueError(f'Unexpected request type: {request_type}') + + logger.info(f'{self.handler_name} handler complete') + return resp + + @abstractmethod + def on_create(self, properties: dict) -> CustomResourceResponse | None: + """Handle Create events. + + This method should be implemented by subclasses to perform the migration when a resource is being created. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ + + @abstractmethod + def on_update(self, properties: dict) -> CustomResourceResponse | None: + """Handle Update events. + + This method should be implemented by subclasses to perform the migration when a resource is being updated. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ + + @abstractmethod + def on_delete(self, properties: dict) -> CustomResourceResponse | None: + """Handle Delete events. + + This method should be implemented by subclasses to handle deletion of the migration. In many cases, this can + be a no-op as the migration is temporary and deletion should have no effect. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ diff --git a/backend/social-work-app/lambdas/python/migration/dummy_migration/main.py b/backend/social-work-app/lambdas/python/migration/dummy_migration/main.py new file mode 100644 index 0000000000..bfdd7a4fca --- /dev/null +++ b/backend/social-work-app/lambdas/python/migration/dummy_migration/main.py @@ -0,0 +1,7 @@ +""" +Dummy file to test the data migration construct. +""" + + +def on_event(_event, _context): + return diff --git a/backend/social-work-app/lambdas/python/migration/tests/__init__.py b/backend/social-work-app/lambdas/python/migration/tests/__init__.py new file mode 100644 index 0000000000..bd8c6d62bb --- /dev/null +++ b/backend/social-work-app/lambdas/python/migration/tests/__init__.py @@ -0,0 +1,103 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'true', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'EVENT_BUS_NAME': 'license-data-events', + 'PROVIDER_TABLE_NAME': 'provider-table', + 'RATE_LIMITING_TABLE_NAME': 'rate-limiting-table', + 'SSN_TABLE_NAME': 'ssn-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'ENVIRONMENT_NAME': 'test', + 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', + 'FAM_GIV_INDEX_NAME': 'famGiv', + 'LICENSE_GSI_NAME': 'licenseGSI', + 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'SSN_INDEX_NAME': 'ssnIndex', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': json.dumps( + [ + 'al', + 'ak', + 'az', + 'ar', + 'ca', + 'co', + 'ct', + 'de', + 'dc', + 'fl', + 'ga', + 'hi', + 'id', + 'il', + 'in', + 'ia', + 'ks', + 'ky', + 'la', + 'me', + 'md', + 'ma', + 'mi', + 'mn', + 'ms', + 'mo', + 'mt', + 'ne', + 'nv', + 'nh', + 'nj', + 'nm', + 'ny', + 'nc', + 'nd', + 'oh', + 'ok', + 'or', + 'pa', + 'pr', + 'ri', + 'sc', + 'sd', + 'tn', + 'tx', + 'ut', + 'vt', + 'va', + 'vi', + 'wa', + 'wv', + 'wi', + 'wy', + ] + ), + 'LICENSE_TYPES': json.dumps( + { + 'socw': [ + {'name': 'cosmetologist', 'abbreviation': 'cos'}, + {'name': 'esthetician', 'abbreviation': 'esth'}, + ], + }, + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/migration/tests/function/__init__.py b/backend/social-work-app/lambdas/python/migration/tests/function/__init__.py new file mode 100644 index 0000000000..bebb625a7a --- /dev/null +++ b/backend/social-work-app/lambdas/python/migration/tests/function/__init__.py @@ -0,0 +1,95 @@ +import logging +import os + +import boto3 +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + self.build_resources() + + # these must be imported within the tests, since they import modules which require + # environment variables that are not set until the TstLambdas class is initialized + import cc_common.config + from common_test.test_data_generator import TestDataGenerator + + cc_common.config.config = cc_common.config._Config() # noqa: SLF001 protected-access + self.config = cc_common.config.config + self.test_data_generator = TestDataGenerator + + self.addCleanup(self.delete_resources) + + def build_resources(self): + # in the case of DR, the lambda sync solution should be table agnostic, since we are performing the same + # cleanup and restoration process regardless of the table that is being recovered + self.create_provider_table() + + def create_provider_table(self): + self._provider_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerFamGivMid', 'AttributeType': 'S'}, + {'AttributeName': 'providerDateOfUpdate', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSISK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSISK', 'AttributeType': 'S'}, + ], + TableName=os.environ['PROVIDER_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['PROV_FAM_GIV_MID_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerFamGivMid', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerDateOfUpdate', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['LICENSE_GSI_NAME'], + 'KeySchema': [ + {'AttributeName': 'licenseGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': 'licenseUploadDateGSI', + 'KeySchema': [ + {'AttributeName': 'licenseUploadDateGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseUploadDateGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': { + 'ProjectionType': 'INCLUDE', + 'NonKeyAttributes': [ + 'providerId', + ], + }, + }, + ], + ) + + def delete_resources(self): + self._provider_table.delete() diff --git a/backend/social-work-app/lambdas/python/migration/tests/unit/__init__.py b/backend/social-work-app/lambdas/python/migration/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/migration/tests/unit/test_custom_resource.py b/backend/social-work-app/lambdas/python/migration/tests/unit/test_custom_resource.py new file mode 100644 index 0000000000..2d17209a58 --- /dev/null +++ b/backend/social-work-app/lambdas/python/migration/tests/unit/test_custom_resource.py @@ -0,0 +1,160 @@ +from custom_resource_handler import CustomResourceHandler, CustomResourceResponse + +from tests import TstLambdas + + +class TestMigration(CustomResourceHandler): + """Test implementation of CustomResourceMigration.""" + + def __init__(self, migration_name: str): + super().__init__(migration_name) + self.create_called = False + self.update_called = False + self.delete_called = False + self.properties_received = None + + def on_create(self, properties: dict) -> CustomResourceResponse | None: # noqa: ARG002 + self.create_called = True + self.properties_received = properties + return {'PhysicalResourceId': 'test-migration', 'Data': {'test': 'value'}} + + def on_update(self, properties: dict) -> CustomResourceResponse | None: # noqa: ARG002 + self.update_called = True + self.properties_received = properties + return {'PhysicalResourceId': 'test-migration', 'Data': {'test': 'updated'}} + + def on_delete(self, properties: dict) -> CustomResourceResponse | None: # noqa: ARG002 + self.delete_called = True + self.properties_received = properties + return {'PhysicalResourceId': 'test-migration', 'Data': {'test': 'delete'}} + + +class TestCustomResourceMigration(TstLambdas): + """Tests for the CustomResourceMigration base class.""" + + def setUp(self): + self.migration = TestMigration('test-migration') + + def test_on_event_create(self): + """Test that Create events are routed to on_create.""" + event = {'RequestType': 'Create', 'ResourceProperties': {'test': 'value'}} + + result = self.migration(event, self.mock_context) + + self.assertTrue(self.migration.create_called) + self.assertFalse(self.migration.update_called) + self.assertFalse(self.migration.delete_called) + self.assertEqual(self.migration.properties_received, {'test': 'value'}) + self.assertEqual(result, {'PhysicalResourceId': 'test-migration', 'Data': {'test': 'value'}}) + + def test_on_event_update(self): + """Test that Update events are routed to on_update.""" + event = {'RequestType': 'Update', 'ResourceProperties': {'test': 'updated'}} + + result = self.migration(event, self.mock_context) + + self.assertFalse(self.migration.create_called) + self.assertTrue(self.migration.update_called) + self.assertFalse(self.migration.delete_called) + self.assertEqual(self.migration.properties_received, {'test': 'updated'}) + self.assertEqual(result, {'PhysicalResourceId': 'test-migration', 'Data': {'test': 'updated'}}) + + def test_on_event_delete(self): + """Test that Delete events are routed to on_delete.""" + event = {'RequestType': 'Delete', 'ResourceProperties': {'test': 'delete'}} + + result = self.migration(event, self.mock_context) + + self.assertFalse(self.migration.create_called) + self.assertFalse(self.migration.update_called) + self.assertTrue(self.migration.delete_called) + self.assertEqual(self.migration.properties_received, {'test': 'delete'}) + self.assertEqual(result, {'PhysicalResourceId': 'test-migration', 'Data': {'test': 'delete'}}) + + def test_on_event_invalid_request_type(self): + """Test that invalid request types raise ValueError.""" + event = {'RequestType': 'InvalidType', 'ResourceProperties': {}} + + with self.assertRaises(ValueError) as context: + self.migration(event, self.mock_context) + + self.assertEqual(str(context.exception), 'Unexpected request type: InvalidType') + + def test_on_event_create_exception(self): + """Test that exceptions in on_create are logged and re-raised.""" + event = {'RequestType': 'Create', 'ResourceProperties': {}} + + # Create a migration that raises an exception + class ExceptionMigration(CustomResourceHandler): + def on_create(self, _properties: dict): + raise ValueError('Test exception') + + def on_update(self, _properties: dict): + return None + + def on_delete(self, _properties: dict): + return None + + migration = ExceptionMigration('exception-migration') + + with self.assertRaises(ValueError) as context: + migration(event, self.mock_context) + + self.assertEqual(str(context.exception), 'Test exception') + + def test_on_event_update_exception(self): + """Test that exceptions in on_update are logged and re-raised.""" + event = {'RequestType': 'Update', 'ResourceProperties': {}} + + # Create a migration that raises an exception + class ExceptionMigration(CustomResourceHandler): + def on_create(self, _properties: dict): + return None + + def on_update(self, _properties: dict): + raise ValueError('Test update exception') + + def on_delete(self, _properties: dict): + return None + + migration = ExceptionMigration('exception-migration') + + with self.assertRaises(ValueError) as context: + migration(event, self.mock_context) + + self.assertEqual(str(context.exception), 'Test update exception') + + def test_on_event_delete_exception(self): + """Test that exceptions in on_delete are logged and re-raised.""" + event = {'RequestType': 'Delete', 'ResourceProperties': {}} + + # Create a migration that raises an exception + class ExceptionMigration(CustomResourceHandler): + def on_create(self, _properties: dict): + return None + + def on_update(self, _properties: dict): + return None + + def on_delete(self, _properties: dict): + raise ValueError('Test delete exception') + + migration = ExceptionMigration('exception-migration') + + with self.assertRaises(ValueError) as context: + migration(event, self.mock_context) + + self.assertEqual(str(context.exception), 'Test delete exception') + + def test_empty_resource_properties(self): + """Test handling of events with no ResourceProperties.""" + event = { + 'RequestType': 'Create' + # No ResourceProperties + } + + result = self.migration(event, self.mock_context) + + self.assertTrue(self.migration.create_called) + self.assertEqual(self.migration.properties_received, {}) + self.assertEqual(result, {'PhysicalResourceId': 'test-migration', 'Data': {'test': 'value'}}) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/__init__.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/__init__.py new file mode 100644 index 0000000000..43e1ee52df --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/__init__.py @@ -0,0 +1,23 @@ +from cc_common.config import config, logger +from cc_common.data_model.update_tier_enum import UpdateTierEnum +from cc_common.utils import logger_inject_kwargs + + +@logger_inject_kwargs(logger, 'compact', 'provider_id') +def get_provider_information(compact: str, provider_id: str, is_public_response: bool = False) -> dict: + """Common method to get provider information by compact and provider id. + + Currently, this is used by staff-users to get information for a specific provider + and the public lookup api to get a filtered response. + + :param compact: Compact the provider belongs to. + :param provider_id: The provider's unique identifier. + :param is_public_response: If True, licenses that are not the most recent license for a type will + not be included in the response. + :return: Provider profile information. + """ + # Collect all main provider records and privilege update records, which are included in tier one. + provider_user_records = config.data_client.get_provider_user_records( + compact=compact, provider_id=provider_id, include_update_tier=UpdateTierEnum.TIER_ONE + ) + return provider_user_records.generate_api_response_object(is_public_response=is_public_response) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/bulk_upload.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/bulk_upload.py new file mode 100644 index 0000000000..f9beebf651 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/bulk_upload.py @@ -0,0 +1,274 @@ +import json +from datetime import datetime +from io import TextIOWrapper +from uuid import uuid4 + +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError +from botocore.response import StreamingBody +from cc_common.config import config, logger +from cc_common.data_model.schema.license.api import ( + LicensePostRequestSchema, + LicenseReportResponseSchema, +) +from cc_common.event_batch_writer import EventBatchWriter +from cc_common.exceptions import CCInternalException + +# initialize flag outside of handler so the flag is cached for the lifecycle of the lambda execution environment +from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled # noqa: E402 +from cc_common.utils import ( + ResponseEncoder, + api_handler, + authorize_compact_jurisdiction, + send_licenses_to_preprocessing_queue, +) +from license_csv_reader import LicenseCSVReader +from marshmallow import ValidationError +from marshmallow.exceptions import SCHEMA + +duplicate_ssn_check_flag_enabled = is_feature_enabled( + FeatureFlagEnum.DUPLICATE_SSN_UPLOAD_CHECK_FLAG, fail_default=True +) + + +@api_handler +@authorize_compact_jurisdiction(action='write') +def bulk_upload_url_handler(event: dict, context: LambdaContext): + """Generate a pre-signed POST to the bulk-upload s3 bucket + + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param LambdaContext context: + """ + return _bulk_upload_url_handler(event, context) + + +def _bulk_upload_url_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + compact = event['pathParameters']['compact'].lower() + jurisdiction = event['pathParameters']['jurisdiction'].lower() + + logger.debug('Creating pre-signed POST', compact=compact, jurisdiction=jurisdiction) + + upload = config.s3_client.generate_presigned_post( + Bucket=config.bulk_bucket_name, + Key=f'{compact}/{jurisdiction}/{uuid4().hex}', + ExpiresIn=config.presigned_post_ttl_seconds, + # Limit content length to ~30MB, ~200k licenses + Conditions=[ + ['content-length-range', 1, 30_000_000], + # Enforce that only CSV files can be uploaded + ['eq', '$Content-Type', 'text/csv'], + ], + ) + logger.info('Created pre-signed POST', url=upload['url']) + return {'upload': upload} + + +@logger.inject_lambda_context +def parse_bulk_upload_file(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Receive an S3 put event, and parse/validate the new s3 file before deleting it + :param event: Standard S3 ObjectCreated event + :param LambdaContext context: + """ + logger.info('Received event', event=event) + try: + for record in event['Records']: + event_time = datetime.fromisoformat(record['eventTime']) + bucket_name = record['s3']['bucket']['name'] + key = record['s3']['object']['key'] + size = record['s3']['object']['size'] + logger.info('Object', s3_url=f's3://{bucket_name}/{key}', size=size) + + # Extract the compact and jurisdiction from the object upload path + compact, jurisdiction = (i.lower() for i in key.split('/')[:2]) + + body: StreamingBody = config.s3_client.get_object(Bucket=bucket_name, Key=key)['Body'] + try: + process_bulk_upload_file( + event_time=event_time, + body=body, + object_key=key, + compact=compact, + jurisdiction=jurisdiction, + ) + except (ClientError, CCInternalException): + raise + except Exception as e: # noqa: BLE001 broad-exception-caught + # Most of the rest of the exception sources here will crop up with decoding + # of CSV data. We'll call that an ingest failure due to bad data and still + # proceed with deletion + logger.info('Failed to parse CSV file!', exc_info=e) + resp = config.events_client.put_events( + Entries=[ + { + 'Source': f'org.compactconnect.bulk-ingest.{key}', + 'DetailType': 'license.ingest-failure', + 'Detail': json.dumps( + { + 'eventTime': event_time.isoformat(), + 'compact': compact, + 'jurisdiction': jurisdiction, + 'errors': [str(e)], + } + ), + 'EventBusName': config.event_bus_name, + } + ] + ) + if resp.get('FailedEntryCount', 0) > 0: + logger.error('Failed to put failure event!') + logger.info(f"Processing 's3://{bucket_name}/{key}' complete") + config.s3_client.delete_object(Bucket=bucket_name, Key=key) + except Exception as e: + logger.error('Failed to process s3 event!', exc_info=e) + raise + + +def process_bulk_upload_file( + *, + event_time: datetime, + body: StreamingBody, + object_key: str, + compact: str, + jurisdiction: str, +): + """ + Stream each line of the new CSV file, validating it then publishing an ingest event for each line. + Process licenses in batches to avoid loading the entire file into memory. + """ + report_schema = LicenseReportResponseSchema() + schema = LicensePostRequestSchema() + reader = LicenseCSVReader() + + # We need to use utf-8-sig to handle potential BOM characters at the beginning of the file + stream = TextIOWrapper(body, encoding='utf-8-sig') + + # Define batch size for processing to limit memory footprint + batch_size = 100 + current_batch = [] + total_processed = 0 + failed_validation_count = 0 + # track which ssns were included in this file to detect duplicates, + # which are not allowed within the same file upload + # We track by (ssn, licenseType) tuple to allow same SSN for different license types + ssns_in_file_upload = {} + + with EventBatchWriter(config.events_client) as event_writer: + for i, raw_license in enumerate(reader.licenses(stream)): + logger.debug('Processing line %s', i + 1) + try: + try: + # dict() here, because it prevents `compact` and `jurisdiction` from being allowed in the + # raw_license + validated_license = schema.load(dict(compact=compact, jurisdiction=jurisdiction, **raw_license)) + # verify that this ssn/licenseType combination has not been used previously in the same batch + ssn_key = (validated_license['ssn'], validated_license['licenseType']) + if duplicate_ssn_check_flag_enabled: + matched_ssn_index = ssns_in_file_upload.get(ssn_key) + if matched_ssn_index: + # format the validation error as dict so it can be processed by email handler downstream + raise ValidationError( + { + SCHEMA: [ + f'Duplicate License SSN detected for license type ' + f'{validated_license["licenseType"]}. SSN matches with record ' + f'{matched_ssn_index}. Every record must have a unique SSN per license type ' + f'within the same file.' + ] + } + ) + ssns_in_file_upload.update({ssn_key: i + 1}) + except TypeError as e: + # This will be raised, if `raw_license` includes compact and/or jurisdiction fields + logger.error('License contains unsupported fields', fields=list(raw_license.keys()), exc_info=e) + raise ValidationError('License contains unsupported fields') from e + current_batch.append(schema.dump(validated_license)) + + # When batch is full, send to preprocessing queue + if len(current_batch) >= batch_size: + _process_license_batch(current_batch, event_time, compact, jurisdiction) + total_processed += len(current_batch) + current_batch = [] # Reset batch + + except ValidationError as e: + failed_validation_count += 1 + # This CSV line has failed validation. We will carefully collect what information we can + # and publish it as a failure event. Because this data may eventually be sent back over + # an email, we will only include the generally available values that we can still validate. + try: + report_license_data = report_schema.load(raw_license) + except ValidationError as exc_second_try: + report_license_data = exc_second_try.valid_data + logger.info( + 'Invalid license in line %s uploaded: %s', + i + 1, + str(e), + valid_data=report_license_data, + exc_info=e, + ) + event_writer.put_event( + Entry={ + 'Source': f'org.compactconnect.bulk-ingest.{object_key}', + 'DetailType': 'license.validation-error', + 'Detail': json.dumps( + { + 'eventTime': event_time.isoformat(), + 'compact': compact, + 'jurisdiction': jurisdiction, + 'recordNumber': i + 1, + 'validData': report_license_data, + 'errors': e.messages, + }, + cls=ResponseEncoder, + ), + 'EventBusName': config.event_bus_name, + } + ) + continue + + # Process any remaining licenses in the final batch + if current_batch: + _process_license_batch(current_batch, event_time, compact, jurisdiction) + total_processed += len(current_batch) + + logger.info( + 'Bulk upload processing complete', + total_processed=total_processed, + failed_validation_count=failed_validation_count, + compact=compact, + jurisdiction=jurisdiction, + ) + + if event_writer.failed_entry_count > 0: + logger.error('Failed to publish %s ingest failure events!', event_writer.failed_entry_count) + for failure in event_writer.failed_entries: + logger.debug('Failed event entry', entry=failure) + + raise CCInternalException('Failed to process object!') + + +def _process_license_batch(licenses_batch: list[dict], event_time: datetime, compact: str, jurisdiction: str): + """ + Process a batch of licenses by sending them to the preprocessing queue. + + :param licenses_batch: List of validated licenses to process + :param event_time: The event time + :param compact: The compact identifier + :param jurisdiction: The jurisdiction identifier + :raises CCInternalException: If any licenses fail to be sent to the queue + """ + if not licenses_batch: + return + + failed_license_numbers = send_licenses_to_preprocessing_queue( + licenses_data=licenses_batch, + event_time=event_time.isoformat(), + ) + + if failed_license_numbers: + logger.error( + 'Failed to send license messages to preprocessing queue!', + failed_license_numbers=failed_license_numbers, + compact=compact, + jurisdiction=jurisdiction, + ) + raise CCInternalException('Failed to process object!') diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/encumbrance.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/encumbrance.py new file mode 100644 index 0000000000..6f0d53abd2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/encumbrance.py @@ -0,0 +1,333 @@ +import json +from uuid import UUID, uuid4 + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.adverse_action import AdverseActionData +from cc_common.data_model.schema.adverse_action.api import ( + AdverseActionPatchRequestSchema, + AdverseActionPostRequestSchema, +) +from cc_common.data_model.schema.common import ( + AdverseActionAgainstEnum, + CCPermissionsAction, + EncumbranceType, +) +from cc_common.exceptions import CCInvalidRequestException +from cc_common.license_util import LicenseUtility +from cc_common.utils import api_handler, authorize_state_level_only_action, to_uuid +from marshmallow import ValidationError + +PRIVILEGE_ENCUMBRANCE_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance' +) +LICENSE_ENCUMBRANCE_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance' +) +PRIVILEGE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance/{encumbranceId}' +) +LICENSE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance/{encumbranceId}' +) + + +def _ensure_jurisdiction_live(compact: str, jurisdiction: str) -> None: + live_jurisdictions = config.live_compact_jurisdictions.get(compact, []) + normalized = jurisdiction.lower() + if normalized not in {j.lower() for j in live_jurisdictions}: + raise CCInvalidRequestException('Jurisdiction is not live in this compact') + + +@api_handler +@authorize_state_level_only_action(action=CCPermissionsAction.ADMIN) +def encumbrance_handler(event: dict, context: LambdaContext) -> dict: + """Encumbrance handler""" + with logger.append_context_keys(aws_request=context.aws_request_id): + if event['httpMethod'] == 'POST' and event['resource'] == PRIVILEGE_ENCUMBRANCE_ENDPOINT_RESOURCE: + return handle_privilege_encumbrance(event) + if event['httpMethod'] == 'POST' and event['resource'] == LICENSE_ENCUMBRANCE_ENDPOINT_RESOURCE: + return handle_license_encumbrance(event) + if event['httpMethod'] == 'PATCH' and event['resource'] == PRIVILEGE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE: + return handle_privilege_encumbrance_lifting(event) + if event['httpMethod'] == 'PATCH' and event['resource'] == LICENSE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE: + return handle_license_encumbrance_lifting(event) + + raise CCInvalidRequestException('Invalid endpoint requested') + + +def _load_adverse_action_post_body(event: dict) -> dict: + try: + schema = AdverseActionPostRequestSchema() + return schema.loads(event['body']) + except ValidationError as e: + raise CCInvalidRequestException(f'Invalid request body: {e.messages}') from e + + +def _get_submitting_user_id(event: dict) -> str: + return event['requestContext']['authorizer']['claims']['sub'] + + +def _generate_adverse_action_for_record_type( + compact: str, + provider_id: UUID, + jurisdiction: str, + license_type_abbr: str, + submitting_user: str, + adverse_action_post_body: dict, + adverse_action_against_record_type: AdverseActionAgainstEnum, +) -> AdverseActionData: + current_date = config.expiration_resolution_date + encumbrance_effective_date = adverse_action_post_body['encumbranceEffectiveDate'] + + if encumbrance_effective_date > current_date: + raise CCInvalidRequestException('The encumbrance date must not be a future date') + + # populate the adverse action data to be stored in the database + adverse_action = AdverseActionData.create_new() + adverse_action.compact = compact + adverse_action.providerId = provider_id + adverse_action.jurisdiction = jurisdiction + + license_type = LicenseUtility.get_license_type_by_abbreviation(compact=compact, abbreviation=license_type_abbr) + + if not license_type: + raise CCInvalidRequestException( + f'Could not find license type information based on provided parameters ' + f"compact: '{compact}' licenseType: '{license_type_abbr}'" + ) + + adverse_action.licenseTypeAbbreviation = license_type.abbreviation + adverse_action.licenseType = license_type.name + adverse_action.actionAgainst = adverse_action_against_record_type + adverse_action.encumbranceType = EncumbranceType(adverse_action_post_body['encumbranceType']) + adverse_action.clinicalPrivilegeActionCategories = adverse_action_post_body['clinicalPrivilegeActionCategories'] + adverse_action.effectiveStartDate = encumbrance_effective_date + adverse_action.submittingUser = submitting_user + adverse_action.creationDate = config.current_standard_datetime + adverse_action.adverseActionId = uuid4() + + return adverse_action + + +def _create_privilege_encumbrance_internal( + compact: str, + jurisdiction: str, + provider_id: UUID, + license_type_abbr: str, + submitting_user: str, + adverse_action_post_body: dict, +) -> UUID: + """Internal handler for creating privilege encumbrances that returns the adverse action ID""" + logger.info('Processing adverse action updates for privilege record') + adverse_action = _generate_adverse_action_for_record_type( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + adverse_action_post_body=adverse_action_post_body, + adverse_action_against_record_type=AdverseActionAgainstEnum.PRIVILEGE, + submitting_user=submitting_user, + ) + config.data_client.encumber_privilege(adverse_action) + + # Publish privilege encumbrance event + config.event_bus_client.publish_privilege_encumbrance_event( + source='org.compactconnect.provider-data', + compact=adverse_action.compact, + provider_id=adverse_action.providerId, + jurisdiction=adverse_action.jurisdiction, + license_type_abbreviation=adverse_action.licenseTypeAbbreviation, + effective_date=adverse_action.effectiveStartDate, + ) + + return adverse_action.adverseActionId + + +def handle_privilege_encumbrance(event: dict) -> dict: + """Public API handler for creating privilege encumbrances""" + # Parse event parameters + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + license_type_abbr = event['pathParameters']['licenseType'].lower() + submitting_user = _get_submitting_user_id(event) + adverse_action_post_body = _load_adverse_action_post_body(event) + + _create_privilege_encumbrance_internal( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + submitting_user=submitting_user, + adverse_action_post_body=adverse_action_post_body, + ) + return {'message': 'OK'} + + +def _create_license_encumbrance_internal( + compact: str, + jurisdiction: str, + provider_id: UUID, + license_type_abbr: str, + submitting_user: str, + adverse_action_post_body: dict, +) -> UUID: + """Internal handler for creating license encumbrances that returns the adverse action ID""" + logger.info('Processing adverse action updates for license record') + adverse_action = _generate_adverse_action_for_record_type( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + adverse_action_post_body=adverse_action_post_body, + adverse_action_against_record_type=AdverseActionAgainstEnum.LICENSE, + submitting_user=submitting_user, + ) + config.data_client.encumber_license(adverse_action) + + # Publish license encumbrance event + config.event_bus_client.publish_license_encumbrance_event( + source='org.compactconnect.provider-data', + compact=adverse_action.compact, + provider_id=adverse_action.providerId, + adverse_action_id=adverse_action.adverseActionId, + jurisdiction=adverse_action.jurisdiction, + license_type_abbreviation=adverse_action.licenseTypeAbbreviation, + effective_date=adverse_action.effectiveStartDate, + ) + + return adverse_action.adverseActionId + + +def handle_license_encumbrance(event: dict) -> dict: + """Public API handler for creating license encumbrances""" + # Parse event parameters + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + license_type_abbr = event['pathParameters']['licenseType'].lower() + submitting_user = _get_submitting_user_id(event) + adverse_action_post_body = _load_adverse_action_post_body(event) + + _create_license_encumbrance_internal( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + submitting_user=submitting_user, + adverse_action_post_body=adverse_action_post_body, + ) + return {'message': 'OK'} + + +def handle_privilege_encumbrance_lifting(event: dict) -> dict: + """Handle lifting encumbrance from a privilege record""" + # Get the cognito sub of the caller for tracing + cognito_sub = _get_submitting_user_id(event) + + with logger.append_context_keys(cognito_sub=cognito_sub): + logger.info('Processing privilege encumbrance lifting') + + # Extract path parameters + compact = event['pathParameters']['compact'] + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) + license_type_abbreviation = event['pathParameters']['licenseType'].lower() + encumbrance_id = to_uuid(event['pathParameters']['encumbranceId'], 'Invalid encumbranceId provided') + + # Parse and validate request body + body = json.loads(event['body']) + try: + validated_body = AdverseActionPatchRequestSchema().load(body) + lift_date = validated_body['effectiveLiftDate'] + except ValidationError as e: + raise CCInvalidRequestException(f'Invalid request body: {e.messages}') from e + + current_date = config.expiration_resolution_date + + if lift_date > current_date: + raise CCInvalidRequestException('The lift date must not be a future date') + + # Call the data client method to lift the privilege encumbrance + config.data_client.lift_privilege_encumbrance( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + adverse_action_id=encumbrance_id, + effective_lift_date=lift_date, + lifting_user=cognito_sub, + ) + + # Publish privilege encumbrance lifting event + config.event_bus_client.publish_privilege_encumbrance_lifting_event( + source='org.compactconnect.provider-data', + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + effective_date=lift_date, + ) + + return {'message': 'OK'} + + +def handle_license_encumbrance_lifting(event: dict) -> dict: + """Handle lifting encumbrance from a license record""" + # Get the cognito sub of the caller for tracing + cognito_sub = _get_submitting_user_id(event) + + with logger.append_context_keys(cognito_sub=cognito_sub): + logger.info('Processing license encumbrance lifting') + + # Extract path parameters + compact = event['pathParameters']['compact'] + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + jurisdiction = event['pathParameters']['jurisdiction'] + _ensure_jurisdiction_live(compact, jurisdiction) + license_type_abbreviation = event['pathParameters']['licenseType'].lower() + encumbrance_id = to_uuid(event['pathParameters']['encumbranceId'], 'Invalid encumbranceId provided') + + # Parse and validate request body + body = json.loads(event['body']) + try: + validated_body = AdverseActionPatchRequestSchema().load(body) + lift_date = validated_body['effectiveLiftDate'] + except ValidationError as e: + raise CCInvalidRequestException(f'Invalid request body: {e.messages}') from e + + current_date = config.expiration_resolution_date + + if lift_date > current_date: + raise CCInvalidRequestException('The lift date must not be a future date') + + # Call the data client method to lift the license encumbrance + config.data_client.lift_license_encumbrance( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + adverse_action_id=encumbrance_id, + effective_lift_date=lift_date, + lifting_user=cognito_sub, + ) + + # Publish license encumbrance lifting event + config.event_bus_client.publish_license_encumbrance_lifting_event( + source='org.compactconnect.provider-data', + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbreviation, + effective_date=lift_date, + ) + + return {'message': 'OK'} diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/ingest.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/ingest.py new file mode 100644 index 0000000000..58e449f4c9 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/ingest.py @@ -0,0 +1,349 @@ +import json +from copy import deepcopy + +from boto3.dynamodb.types import TypeSerializer +from cc_common.config import config, logger +from cc_common.data_model.provider_record_util import ProviderRecordType, ProviderRecordUtility +from cc_common.data_model.schema import LicenseRecordSchema +from cc_common.data_model.schema.common import ActiveInactiveStatus, UpdateCategory +from cc_common.data_model.schema.license import LicenseData +from cc_common.data_model.schema.license.ingest import LicenseIngestSchema +from cc_common.data_model.schema.license.record import LicenseUpdateRecordSchema +from cc_common.data_model.schema.provider import ProviderData +from cc_common.event_batch_writer import EventBatchWriter +from cc_common.exceptions import CCNotFoundException +from cc_common.utils import sqs_handler + +license_schema = LicenseIngestSchema() +license_update_schema = LicenseUpdateRecordSchema() + + +@sqs_handler +def preprocess_license_ingest(message: dict): + """ + Preprocess license data to remove SSN before sending to the event bus. + This reduces the attack surface by ensuring full SSNs don't reach the event bus. + + For each message: + 1. Extract the SSN + 2. Get or create the provider ID using the SSN + 3. Replace the full SSN with just the last 4 digits + 4. Send the modified message to the event bus + """ + + # Extract necessary fields + compact = message['compact'] + jurisdiction = message['jurisdiction'] + ssn = message.pop('ssn') # Remove SSN from the detail + + with logger.append_context_keys(compact=compact, jurisdiction=jurisdiction): + try: + # Get or create provider ID using the SSN and add it to the message_body + provider_id = config.data_client.get_or_create_provider_id(compact=compact, ssn=ssn) + message['providerId'] = provider_id + + # Add the last 4 digits of SSN to the detail + message['ssnLastFour'] = ssn[-4:] + # delete the ssn value from memory so it can be cleaned up as soon as we are done with it + del ssn + + # Send the sanitized license data to the event bus + with logger.append_context_keys(provider_id=provider_id): + logger.info('Sending preprocessed license data to event bus') + + config.events_client.put_events( + Entries=[ + { + 'Source': 'org.compactconnect.provider-data', + 'DetailType': 'license.ingest', + 'Detail': json.dumps(message), + 'EventBusName': config.event_bus_name, + } + ] + ) + except Exception as e: # noqa: BLE001 broad-exception-caught + logger.error(f'Error preprocessing license data: {str(e)}', exc_info=True) + # Send an ingest failure event + config.events_client.put_events( + Entries=[ + { + 'Source': 'org.compactconnect.provider-data', + 'DetailType': 'license.ingest-failure', + 'Detail': json.dumps( + { + 'eventTime': message.get('eventTime', config.current_standard_datetime.isoformat()), + 'compact': compact, + 'jurisdiction': jurisdiction, + 'errors': [f'Error preprocessing license data: {str(e)}'], + } + ), + 'EventBusName': config.event_bus_name, + } + ] + ) + # raise the exception so SQS will retry the message again + raise e + + +@sqs_handler +def ingest_license_message(message: dict): + """For each message, validate the license data and persist it in the database""" + # We're not using the event time here, currently, so we'll discard it + message['detail'].pop('eventTime') + + # This schema load will transform the 'licenseStatus' and 'compactEligibility' fields to + # 'jurisdictionUploadedLicenseStatus' and 'jurisdictionUploadedCompactEligibility' for internal references, and + # will also validate the data. + license_ingest_message = license_schema.load(message['detail']) + + compact = license_ingest_message['compact'] + jurisdiction = license_ingest_message['jurisdiction'] + provider_id = license_ingest_message['providerId'] + + with logger.append_context_keys(compact=compact, jurisdiction=jurisdiction): + with logger.append_context_keys(provider_id=provider_id): + logger.info('Ingesting license data') + + # Start preparing our db transactions + data_events = [] + + license_record_schema = LicenseRecordSchema() + dumped_license = license_record_schema.dumps(license_ingest_message) + + del license_ingest_message + + # We fully JSON serialize then load again so that we have a completely independent copy of the data + posted_license_record = license_record_schema.load(json.loads(dumped_license)) + + dynamo_transactions = [] + + try: + provider_data = config.data_client.get_provider( + compact=compact, + provider_id=provider_id, + detail=True, + consistent_read=True, + ) + provider_records = provider_data['items'] + + license_records = ProviderRecordUtility.get_records_of_type( + provider_records, + ProviderRecordType.LICENSE, + ) + licenses_organized = {} + for record in license_records: + licenses_organized.setdefault(record['jurisdiction'], {}) + licenses_organized[record['jurisdiction']][record['licenseType']] = record + + # Parse the top level provider record into a data class instance + current_provider_record = ProviderData.create_new( + ProviderRecordUtility.get_provider_record(provider_records) + ) + + except CCNotFoundException: + licenses_organized = {} + current_provider_record = None + + # Set (or replace) the posted license for its jurisdiction + existing_license = licenses_organized.get(posted_license_record['jurisdiction'], {}).get( + posted_license_record['licenseType'] + ) + if existing_license is not None: + _process_license_update( + existing_license=existing_license, + new_license=posted_license_record, + dynamo_transactions=dynamo_transactions, + data_events=data_events, + ) + # now grab the firstUploadDate from the existing record if available and put it in the posted_license + # for the license upload date GSI + if existing_license.get('firstUploadDate'): + posted_license_record['firstUploadDate'] = existing_license.get('firstUploadDate') + else: + # If this is the first time creating the license record, + # set the firstUploadDate to the current time for license upload date GSI tracking + posted_license_record['firstUploadDate'] = config.current_standard_datetime + + # write the record to the table to reflect the latest values from the upload + license_data = LicenseData.create_new(deepcopy(posted_license_record)) + dynamo_transactions.append( + { + 'Put': { + 'TableName': config.provider_table_name, + 'Item': TypeSerializer().serialize(license_data.serialize_to_database_record())['M'], + } + } + ) + + licenses_organized.setdefault(posted_license_record['jurisdiction'], {}) + licenses_organized[posted_license_record['jurisdiction']][posted_license_record['licenseType']] = ( + posted_license_record + ) + licenses_flattened = [ + license_record + for jurisdiction_licenses in licenses_organized.values() + for license_record in jurisdiction_licenses.values() + ] + + best_license = ProviderRecordUtility.find_most_recently_issued_or_renewed_license( + license_records=licenses_flattened, + ) + + if best_license is posted_license_record: + logger.info('Updating provider data') + + # If this posted license is the most recent issued/renewed license for the provider, + # and it's from a different jurisdiction, send a home jurisdiction change notification event + # to notify the former home jurisdiction. + if ( + current_provider_record + and current_provider_record.licenseJurisdiction != best_license['jurisdiction'] + ): + logger.info( + 'New home state license detected. Sending home state change notification.', + previous_home_jurisdiction=current_provider_record.licenseJurisdiction, + new_home_jurisdiction=jurisdiction, + ) + home_jurisdiction_change_event = config.event_bus_client.generate_home_jurisdiction_change_event( + source='org.compactconnect.provider-data', + compact=compact, + jurisdiction=jurisdiction, + provider_id=current_provider_record.providerId, + license_type=posted_license_record['licenseType'], + former_home_jurisdiction=current_provider_record.licenseJurisdiction, + ) + data_events.append(home_jurisdiction_change_event) + + # Update our top level provider record with data from the posted license + provider_record = ProviderRecordUtility.populate_provider_record( + current_provider_record=current_provider_record, license_record=posted_license_record + ) + + dynamo_transactions.append( + { + 'Put': { + 'TableName': config.provider_table_name, + 'Item': TypeSerializer().serialize(provider_record.serialize_to_database_record())['M'], + } + } + ) + + # Write the records together as a transaction that succeeds or fails as one, to ensure consistency + config.dynamodb_client.transact_write_items(TransactItems=dynamo_transactions) + + # We'll save our events until after the transaction is written, to ensure consistency + with EventBatchWriter(config.events_client) as event_writer: + for event in data_events: + event_writer.put_event(Entry=event) + + +def _process_license_update(*, existing_license: dict, new_license: dict, dynamo_transactions: list, data_events: list): + """ + Examine the differences between existing_license and new_license, categorize the change, and add + a licenseUpdate record to the transaction if appropriate. + :param dict existing_license: The existing license record + :param dict new_license: The newly-uploaded license record + :param list dynamo_transactions: The dynamodb transaction array to append records to + """ + # Remove fields that are calculated at runtime, not stored in the database + # uploadDate is metadata tracking when the license was first uploaded, not part of the license data + dynamic_keys = {'dateOfUpdate', 'status', 'uploadDate'} + updated_values = { + key: value + for key, value in new_license.items() + if key not in dynamic_keys and (key not in existing_license.keys() or value != existing_license[key]) + } + # If any fields are missing from the new license, we'll consider them removed + removed_values = existing_license.keys() - new_license.keys() + if not updated_values and not removed_values: + logger.info('No changes detected for this license.') + return + + # Categorize the update + update_record = _populate_update_record( + existing_license=existing_license, updated_values=updated_values, removed_values=removed_values + ) + # We'll fire off events for updates of particular importance + if update_record['updateType'] == UpdateCategory.DEACTIVATION: + # Only publish license deactivation event if the license is not expired. + # Expired licenses are handled separately, and we want to distinguish between + # jurisdiction deactivation vs natural expiration + is_expired = new_license['dateOfExpiration'] < config.expiration_resolution_date + + if not is_expired: + logger.info( + 'License is not expired, but is set to inactive. Publishing license deactivation event.', + date_of_expiration=new_license['dateOfExpiration'], + ) + # Use EventBusClient to generate the event + license_deactivation_event = config.event_bus_client.generate_license_deactivation_event( + source='org.compactconnect.provider-data', + compact=existing_license['compact'], + jurisdiction=existing_license['jurisdiction'], + provider_id=existing_license['providerId'], + license_type=existing_license['licenseType'], + ) + data_events.append(license_deactivation_event) + else: + logger.info( + 'License is expired, skipping license deactivation event.', + date_of_expiration=new_license['dateOfExpiration'], + ) + + dynamo_transactions.append( + {'Put': {'TableName': config.provider_table_name, 'Item': TypeSerializer().serialize(update_record)['M']}} + ) + + +def _populate_update_record(*, existing_license: dict, updated_values: dict, removed_values: dict) -> dict: + """ + Categorize the update between existing and new license records. + :param dict existing_license: The existing license record + :param dict updated_values: Values that have been updated as part of the new upload + :param dict removed_values: Values that have been removed as part of the new upload + :return: The license update record to be stored to track changes. + """ + logger.info( + 'Processing license update', + provider_id=existing_license['providerId'], + compact=existing_license['compact'], + jurisdiction=existing_license['jurisdiction'], + ) + update_type = None + # if expiration date moves forward, it's a renewal + # previously we checked for both dateOfExpiration and dateOfRenewal, but the dateOfRenewal was made optional + # for states, so we now only check for dateOfExpiration to see if the date has been extended + if ( + 'dateOfExpiration' in updated_values + and updated_values['dateOfExpiration'] > existing_license['dateOfExpiration'] + ): + update_type = UpdateCategory.RENEWAL + logger.info('License renewal detected - expiration date extended') + # if the license status is set to inactive, it's a deactivation, and this status is higher priority to + # store than a renewal + if updated_values.get('jurisdictionUploadedLicenseStatus') == ActiveInactiveStatus.INACTIVE: + update_type = UpdateCategory.DEACTIVATION + logger.info('License deactivation detected') + if update_type is None: + update_type = UpdateCategory.LICENSE_UPLOAD_UPDATE_OTHER + logger.info('License update detected') + + now = config.current_standard_datetime + + return license_update_schema.dump( + { + 'type': ProviderRecordType.LICENSE_UPDATE, + 'updateType': update_type, + 'providerId': existing_license['providerId'], + 'compact': existing_license['compact'], + 'jurisdiction': existing_license['jurisdiction'], + 'licenseType': existing_license['licenseType'], + 'createDate': now, + 'effectiveDate': now, + 'uploadDate': now, # Track when this update was created during upload + 'previous': existing_license, + 'updatedValues': updated_values, + # We'll only include the removed values field if there are some + **({'removedValues': sorted(removed_values)} if removed_values else {}), + } + ) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/investigation.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/investigation.py new file mode 100644 index 0000000000..3edb98a68e --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/investigation.py @@ -0,0 +1,281 @@ +import json +from uuid import UUID, uuid4 + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.common import ( + CCPermissionsAction, + InvestigationAgainstEnum, +) +from cc_common.data_model.schema.investigation import InvestigationData +from cc_common.data_model.schema.investigation.api import ( + InvestigationPatchRequestSchema, +) +from cc_common.exceptions import CCInvalidRequestException +from cc_common.license_util import LicenseUtility +from cc_common.utils import api_handler, authorize_state_level_only_action, to_uuid +from marshmallow import ValidationError + +from .encumbrance import _create_license_encumbrance_internal, _create_privilege_encumbrance_internal + +PRIVILEGE_INVESTIGATION_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation' +) +LICENSE_INVESTIGATION_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation' +) +PRIVILEGE_INVESTIGATION_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation/{investigationId}' +) +LICENSE_INVESTIGATION_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation/{investigationId}' +) + + +@api_handler +@authorize_state_level_only_action(action=CCPermissionsAction.ADMIN) +def investigation_handler(event: dict, context: LambdaContext) -> dict: + """Investigation handler""" + # Get the cognito sub of the caller for tracing + cognito_sub = event['requestContext']['authorizer']['claims']['sub'] + + with logger.append_context_keys(aws_request=context.aws_request_id, cognito_sub=cognito_sub): + if event['httpMethod'] == 'POST' and event['resource'] == PRIVILEGE_INVESTIGATION_ENDPOINT_RESOURCE: + return handle_privilege_investigation(event) + if event['httpMethod'] == 'POST' and event['resource'] == LICENSE_INVESTIGATION_ENDPOINT_RESOURCE: + return handle_license_investigation(event) + if event['httpMethod'] == 'PATCH' and event['resource'] == PRIVILEGE_INVESTIGATION_ID_ENDPOINT_RESOURCE: + return handle_privilege_investigation_close(event) + if event['httpMethod'] == 'PATCH' and event['resource'] == LICENSE_INVESTIGATION_ID_ENDPOINT_RESOURCE: + return handle_license_investigation_close(event) + + raise CCInvalidRequestException('Invalid endpoint requested') + + +def _load_investigation_patch_body(event: dict) -> dict: + # Parse and validate request body + body = json.loads(event['body']) + try: + return InvestigationPatchRequestSchema().load(body) + except ValidationError as e: + raise CCInvalidRequestException(f'Invalid request body: {e.messages}') from e + + +def _generate_investigation_for_record_type( + compact: str, + jurisdiction: str, + provider_id: UUID, + license_type_abbr: str, + investigation_against_record_type: InvestigationAgainstEnum, + cognito_sub: str, +) -> InvestigationData: + license_type = LicenseUtility.get_license_type_by_abbreviation(compact=compact, abbreviation=license_type_abbr) + + if not license_type: + raise CCInvalidRequestException( + f'Could not find license type information based on provided parameters ' + f"compact: '{compact}' licenseType: '{license_type_abbr}'" + ) + + # populate the investigation data to be stored in the database + return InvestigationData.create_new( + { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'providerId': provider_id, + 'investigationId': uuid4(), + 'licenseType': license_type.name, + 'investigationAgainst': investigation_against_record_type, + 'submittingUser': cognito_sub, + 'creationDate': config.current_standard_datetime, + } + ) + + +def handle_privilege_investigation(event: dict) -> dict: + """Public API handler for creating privilege investigations""" + # Parse event parameters + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + license_type_abbr = event['pathParameters']['licenseType'].lower() + cognito_sub = event['requestContext']['authorizer']['claims']['sub'] + + investigation = _generate_investigation_for_record_type( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + investigation_against_record_type=InvestigationAgainstEnum.PRIVILEGE, + cognito_sub=cognito_sub, + ) + logger.info('Processing privilege investigation') + config.data_client.create_investigation(investigation) + + # Publish privilege investigation event + config.event_bus_client.publish_investigation_event( + source='org.compactconnect.provider-data', + compact=investigation.compact, + provider_id=investigation.providerId, + jurisdiction=investigation.jurisdiction, + create_date=investigation.creationDate, + license_type_abbreviation=investigation.licenseTypeAbbreviation, + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + investigation_id=investigation.investigationId, + ) + + return {'message': 'OK'} + + +def handle_license_investigation(event: dict) -> dict: + """Public API handler for creating license investigations""" + # Parse event parameters + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + license_type_abbr = event['pathParameters']['licenseType'].lower() + cognito_sub = event['requestContext']['authorizer']['claims']['sub'] + + investigation = _generate_investigation_for_record_type( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + investigation_against_record_type=InvestigationAgainstEnum.LICENSE, + cognito_sub=cognito_sub, + ) + logger.info('Processing investigation updates for license record') + config.data_client.create_investigation(investigation) + + # Publish license investigation event + config.event_bus_client.publish_investigation_event( + source='org.compactconnect.provider-data', + compact=investigation.compact, + provider_id=investigation.providerId, + jurisdiction=investigation.jurisdiction, + create_date=investigation.creationDate, + license_type_abbreviation=investigation.licenseTypeAbbreviation, + investigation_against=InvestigationAgainstEnum.LICENSE, + investigation_id=investigation.investigationId, + ) + + return {'message': 'OK'} + + +def handle_privilege_investigation_close(event: dict) -> dict: + """Handle closing a privilege investigation.""" + # Parse event parameters + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + license_type_abbr = event['pathParameters']['licenseType'].lower() + investigation_id = to_uuid(event['pathParameters']['investigationId'], 'Invalid investigationId provided') + cognito_sub = event['requestContext']['authorizer']['claims']['sub'] + investigation_patch_body = _load_investigation_patch_body(event) + + logger.info('Processing privilege investigation closure') + now = config.current_standard_datetime + + # Create encumbrance if provided + resulting_encumbrance_id = None + encumbrance_data = investigation_patch_body.get('encumbrance') + if encumbrance_data: + # Create the encumbrance the same way we do directly via the encumbrance endpoint + resulting_encumbrance_id = _create_privilege_encumbrance_internal( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + submitting_user=cognito_sub, + adverse_action_post_body=encumbrance_data, + ) + + # Call the data client method to close the investigation + config.data_client.close_investigation( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbr, + investigation_id=investigation_id, + closing_user=cognito_sub, + close_date=now, + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + resulting_encumbrance_id=resulting_encumbrance_id, + ) + + # Publish privilege investigation closure event + config.event_bus_client.publish_investigation_closed_event( + source='org.compactconnect.provider-data', + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbr, + close_date=now, + investigation_against=InvestigationAgainstEnum.PRIVILEGE, + investigation_id=investigation_id, + adverse_action_id=resulting_encumbrance_id, + ) + + return {'message': 'OK'} + + +def handle_license_investigation_close(event: dict) -> dict: + """Handle closing investigation for a license record""" + # Parse event parameters + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + provider_id = to_uuid(event['pathParameters']['providerId'], 'Invalid providerId provided') + license_type_abbr = event['pathParameters']['licenseType'].lower() + investigation_id = to_uuid(event['pathParameters']['investigationId'], 'Invalid investigationId provided') + cognito_sub = event['requestContext']['authorizer']['claims']['sub'] + investigation_patch_body = _load_investigation_patch_body(event) + + logger.info('Processing license investigation closure') + + now = config.current_standard_datetime + + # Create encumbrance if provided + resulting_encumbrance_id = None + encumbrance_data = investigation_patch_body.get('encumbrance') + if encumbrance_data: + # Create the encumbrance the same way we do directly via the encumbrance endpoint + resulting_encumbrance_id = _create_license_encumbrance_internal( + compact=compact, + jurisdiction=jurisdiction, + provider_id=provider_id, + license_type_abbr=license_type_abbr, + submitting_user=cognito_sub, + adverse_action_post_body=encumbrance_data, + ) + + # Call the data client method to close the investigation + config.data_client.close_investigation( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbr, + investigation_id=investigation_id, + closing_user=cognito_sub, + close_date=now, + investigation_against=InvestigationAgainstEnum.LICENSE, + resulting_encumbrance_id=resulting_encumbrance_id, + ) + + # Publish license investigation closure event + config.event_bus_client.publish_investigation_closed_event( + source='org.compactconnect.provider-data', + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type_abbreviation=license_type_abbr, + close_date=now, + investigation_against=InvestigationAgainstEnum.LICENSE, + investigation_id=investigation_id, + adverse_action_id=resulting_encumbrance_id, + ) + + return {'message': 'OK'} diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/licenses.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/licenses.py new file mode 100644 index 0000000000..59d8bd64f7 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/licenses.py @@ -0,0 +1,107 @@ +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.license.api import LicensePostRequestSchema +from cc_common.exceptions import CCInternalException, CCInvalidRequestCustomResponseException, CCInvalidRequestException +from cc_common.signature_auth import optional_signature_auth +from cc_common.utils import api_handler, authorize_compact_jurisdiction, send_licenses_to_preprocessing_queue +from marshmallow import ValidationError + +schema = LicensePostRequestSchema() + +# initialize flag outside of handler so the flag is cached for the lifecycle of the execution environment +from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled # noqa: E402 + +# low risk flag, so we default to enabled if failure detected +duplicate_ssn_check_flag_enabled = is_feature_enabled( + FeatureFlagEnum.DUPLICATE_SSN_UPLOAD_CHECK_FLAG, fail_default=True +) + + +@api_handler +@optional_signature_auth +@authorize_compact_jurisdiction(action='write') +def post_licenses(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Synchronously validate and submit an array of licenses + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param LambdaContext context: + """ + compact = event['pathParameters']['compact'] + jurisdiction = event['pathParameters']['jurisdiction'] + + try: + license_records = json.loads(event['body']) + except json.JSONDecodeError as e: + logger.debug('Invalid JSON payload provided') + raise CCInvalidRequestException(f'Invalid JSON: {e}') from e + except TypeError as e: + raise CCInvalidRequestException('Invalid request body') from e + + # Validate that the payload is a list + if not isinstance(license_records, list): + logger.debug('Request body must be a list') + raise CCInvalidRequestException('Request body must be an array of license objects') + + # Validate that each item in the list is a dictionary and collect all errors + invalid_records = {} + licenses = [] + for i, license_record in enumerate(license_records): + if not isinstance(license_record, dict): + invalid_records.update({str(i): {'INVALID_JSON_OBJECT': ['Must be a JSON object.']}}) + # record is dictionary, add required fields and run schema validation against it + else: + license_entry = {**license_record, 'compact': compact, 'jurisdiction': jurisdiction} + try: + licenses.append(schema.load(license_entry)) + except ValidationError as e: + logger.debug( + 'invalid license record detected', + compact=compact, + jurisdiction=jurisdiction, + index=i, + error=e.messages_dict, + ) + invalid_records.update({str(i): e.messages_dict}) + + if invalid_records: + raise CCInvalidRequestCustomResponseException( + response_body={ + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': invalid_records, + } + ) + if duplicate_ssn_check_flag_enabled: + # verify that none of the SSN+LicenseType combinations are repeats within the same batch + license_keys = [(license_record['ssn'], license_record['licenseType']) for license_record in licenses] + if len(set(license_keys)) < len(license_keys): + logger.info('Duplicate SSNs detected in same request.', compact=compact, jurisdiction=jurisdiction) + raise CCInvalidRequestCustomResponseException( + response_body={ + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': { + 'SSN': 'Same SSN for the same license type detected on multiple rows. ' + 'Every record must have a unique SSN per license type within the same request.' + }, + } + ) + + event_time = config.current_standard_datetime + + logger.info('Sending license records to preprocessing queue', compact=compact, jurisdiction=jurisdiction) + # Use the utility function to send licenses to the preprocessing queue + failed_license_numbers = send_licenses_to_preprocessing_queue( + licenses_data=schema.dump(licenses, many=True), + event_time=event_time.isoformat(), + ) + + if failed_license_numbers: + logger.error( + 'Failed to send license messages to preprocessing queue!', + compact=compact, + jurisdiction=jurisdiction, + failed_license_numbers=failed_license_numbers, + ) + raise CCInternalException('Failed to process licenses!') + + return {'message': 'OK'} diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/providers.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/providers.py new file mode 100644 index 0000000000..35ec7b3f70 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/providers.py @@ -0,0 +1,126 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.common import CCPermissionsAction +from cc_common.data_model.schema.provider.api import ( + ProviderGeneralResponseSchema, + QueryProvidersRequestSchema, +) +from cc_common.exceptions import CCInvalidRequestException +from cc_common.utils import ( + api_handler, + authorize_compact, + get_event_scopes, + sanitize_provider_data_based_on_caller_scopes, +) +from marshmallow import ValidationError + +from . import get_provider_information + + +@api_handler +@authorize_compact(action=CCPermissionsAction.READ_GENERAL) +def query_providers(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Query providers data + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param LambdaContext context: + """ + compact = event['pathParameters']['compact'] + + # Parse and validate the request body using the schema to strip whitespace + try: + schema = QueryProvidersRequestSchema() + body = schema.loads(event['body']) + except ValidationError as e: + logger.warning('Invalid request body', errors=e.messages) + raise CCInvalidRequestException(f'Invalid request: {e.messages}') from e + + query = body.get('query', {}) + if 'providerId' in query.keys(): + provider_id = query['providerId'] + query = {'providerId': provider_id} + resp = config.data_client.get_provider( + compact=compact, + provider_id=provider_id, + pagination=body.get('pagination'), + detail=False, + ) + resp['query'] = query + + else: + if 'givenName' in query.keys() and 'familyName' not in query.keys(): + raise CCInvalidRequestException('familyName is required if givenName is provided') + provider_name = None + if 'familyName' in query.keys(): + provider_name = (query.get('familyName'), query.get('givenName')) + + jurisdiction = query.get('jurisdiction') + + sorting = body.get('sorting', {}) + sorting_key = sorting.get('key') + + sort_direction = sorting.get('direction', 'ascending') + scan_forward = sort_direction == 'ascending' + + match sorting_key: + case None | 'familyName': + resp = { + 'query': query, + 'sorting': {'key': 'familyName', 'direction': sort_direction}, + **config.data_client.get_providers_sorted_by_family_name( + compact=compact, + jurisdiction=jurisdiction, + provider_name=provider_name, + scan_forward=scan_forward, + pagination=body.get('pagination'), + ), + } + case 'dateOfUpdate': + if provider_name is not None: + raise CCInvalidRequestException( + 'givenName and familyName are not supported for sorting by dateOfUpdate', + ) + resp = { + 'query': query, + 'sorting': {'key': 'dateOfUpdate', 'direction': sort_direction}, + **config.data_client.get_providers_sorted_by_updated( + compact=compact, + jurisdiction=jurisdiction, + scan_forward=scan_forward, + pagination=body.get('pagination'), + ), + } + case _: + # This shouldn't happen unless our api validation gets misconfigured + raise CCInvalidRequestException(f"Invalid sort key: '{sorting_key}'") + # Convert generic field to more specific one for this API and sanitize data + unsanitized_providers = resp.pop('items', []) + # for the query endpoint, we only return generally available data, regardless of the caller's scopes + general_schema = ProviderGeneralResponseSchema() + sanitized_providers = [general_schema.load(provider) for provider in unsanitized_providers] + + resp['providers'] = sanitized_providers + + return resp + + +@api_handler +@authorize_compact(action=CCPermissionsAction.READ_GENERAL) +def get_provider(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Return one provider's data + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param LambdaContext context: + """ + try: + compact = event['pathParameters']['compact'] + provider_id = event['pathParameters']['providerId'] + except (KeyError, TypeError) as e: + # This shouldn't happen without miss-configuring the API, but we'll handle it, anyway + logger.error(f'Missing parameter: {e}') + raise CCInvalidRequestException('Missing required field') from e + + with logger.append_context_keys(compact=compact, provider_id=provider_id): + provider_information = get_provider_information(compact=compact, provider_id=provider_id) + + return sanitize_provider_data_based_on_caller_scopes( + compact=compact, provider=provider_information, scopes=get_event_scopes(event) + ) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/public_lookup.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/public_lookup.py new file mode 100644 index 0000000000..2d4706054a --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/public_lookup.py @@ -0,0 +1,30 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import logger +from cc_common.data_model.schema.provider.api import ProviderPublicResponseSchema +from cc_common.exceptions import CCInvalidRequestException +from cc_common.utils import api_handler + +from . import get_provider_information + + +@api_handler +def public_get_provider(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Return one provider's data + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param LambdaContext context: + """ + try: + compact = event['pathParameters']['compact'] + provider_id = event['pathParameters']['providerId'] + except (KeyError, TypeError) as e: + # This shouldn't happen without miss-configuring the API, but we'll handle it, anyway + logger.error(f'Missing parameter: {e}') + raise CCInvalidRequestException('Missing required field') from e + + with logger.append_context_keys(compact=compact, provider_id=provider_id): + provider_information = get_provider_information( + compact=compact, provider_id=provider_id, is_public_response=True + ) + + public_schema = ProviderPublicResponseSchema() + return public_schema.load(provider_information) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/handlers/state_api.py b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/state_api.py new file mode 100644 index 0000000000..cc752f5ff8 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/handlers/state_api.py @@ -0,0 +1,20 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.signature_auth import optional_signature_auth +from cc_common.utils import api_handler, authorize_compact_jurisdiction + +from handlers.bulk_upload import _bulk_upload_url_handler + + +@api_handler +@optional_signature_auth +@authorize_compact_jurisdiction(action='write') +def bulk_upload_url_handler(event: dict, context: LambdaContext): + """Generate a pre-signed POST to the bulk-upload s3 bucket + + Note: We need this distinct copy for the state api because our auth requirements + are different. + + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param LambdaContext context: + """ + return _bulk_upload_url_handler(event, context) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/license_csv_reader.py b/backend/social-work-app/lambdas/python/provider-data-v1/license_csv_reader.py new file mode 100644 index 0000000000..380c3e1785 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/license_csv_reader.py @@ -0,0 +1,20 @@ +from collections.abc import Generator +from csv import DictReader +from io import TextIOBase + +from cc_common.data_model.schema.license.api import LicensePostRequestSchema + + +class LicenseCSVReader: + def __init__(self): + self.schema = LicensePostRequestSchema() + + def licenses(self, stream: TextIOBase) -> Generator[dict, None, None]: + reader = DictReader(stream, restkey='invalid', dialect='excel', strict=True) + for license_row in reader: + # Drop fields that are blank + drop_fields = [k for k, v in license_row.items() if v == ''] + for k in drop_fields: + del license_row[k] + + yield license_row diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/requirements-dev.in b/backend/social-work-app/lambdas/python/provider-data-v1/requirements-dev.in new file mode 100644 index 0000000000..11ecd05d40 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/requirements-dev.in @@ -0,0 +1,2 @@ +moto[dynamodb, s3]>=5.0.12, <6 +Faker>=40, <41 diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/requirements-dev.txt b/backend/social-work-app/lambdas/python/provider-data-v1/requirements-dev.txt new file mode 100644 index 0000000000..4c65d1dc25 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/requirements-dev.txt @@ -0,0 +1,66 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/provider-data-v1/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via moto +docker==7.1.0 + # via moto +faker==40.15.0 + # via -r lambdas/python/provider-data-v1/requirements-dev.in +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/provider-data-v1/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/requirements.in b/backend/social-work-app/lambdas/python/provider-data-v1/requirements.in new file mode 100644 index 0000000000..3d293fbf73 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common-python requirements.in file diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/requirements.txt b/backend/social-work-app/lambdas/python/provider-data-v1/requirements.txt new file mode 100644 index 0000000000..f9665c63b1 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/provider-data-v1/requirements.in +# diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/__init__.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/__init__.py new file mode 100644 index 0000000000..f15fe6f55f --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/__init__.py @@ -0,0 +1,109 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'false', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'BULK_BUCKET_NAME': 'cc-license-data-bulk-bucket', + 'EVENT_BUS_NAME': 'license-data-events', + 'PROVIDER_TABLE_NAME': 'provider-table', + 'RATE_LIMITING_TABLE_NAME': 'rate-limiting-table', + 'SSN_TABLE_NAME': 'ssn-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'ENVIRONMENT_NAME': 'test', + 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', + 'FAM_GIV_INDEX_NAME': 'famGiv', + 'LICENSE_GSI_NAME': 'licenseGSI', + 'PROVIDER_USER_POOL_ID': 'us-east-1-12345', + 'USERS_TABLE_NAME': 'staff-users-table', + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': 'email-notification-service-lambda', + 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'SSN_INDEX_NAME': 'ssnIndex', + 'USER_POOL_ID': 'us-east-1-12345', + 'LICENSE_PREPROCESSING_QUEUE_URL': 'license-preprocessing-queue-url', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': json.dumps( + [ + 'al', + 'ak', + 'az', + 'ar', + 'ca', + 'co', + 'ct', + 'de', + 'dc', + 'fl', + 'ga', + 'hi', + 'id', + 'il', + 'in', + 'ia', + 'ks', + 'ky', + 'la', + 'me', + 'md', + 'ma', + 'mi', + 'mn', + 'ms', + 'mo', + 'mt', + 'ne', + 'nv', + 'nh', + 'nj', + 'nm', + 'ny', + 'nc', + 'nd', + 'oh', + 'ok', + 'or', + 'pa', + 'pr', + 'ri', + 'sc', + 'sd', + 'tn', + 'tx', + 'ut', + 'vt', + 'va', + 'vi', + 'wa', + 'wv', + 'wi', + 'wy', + ] + ), + 'LICENSE_TYPES': json.dumps( + { + 'socw': [ + {'name': 'cosmetologist', 'abbreviation': 'cos'}, + {'name': 'esthetician', 'abbreviation': 'esth'}, + ], + }, + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/__init__.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/__init__.py new file mode 100644 index 0000000000..87790f5626 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/__init__.py @@ -0,0 +1,389 @@ +import json +import logging +import os +from datetime import UTC, datetime, timedelta +from decimal import Decimal +from glob import glob +from random import randint +from unittest.mock import patch + +import boto3 +from faker import Faker +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def assertDictPartialMatch(self, expected: dict, actual: dict): # noqa: N802 emulating TestCase style here + for key, value in expected.items(): + try: + self.assertEqual(value, actual[key], f'Expected {key}: {value} but got {key}: {actual[key]}') + except KeyError: + self.fail(f'Missing expected key, {key}') + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + + # we want to see any diffs in failed tests, regardless of how large the object is + self.maxDiff = None + + self.build_resources() + + # these must be imported within the tests, since they import modules which require + # environment variables that are not set until the TstLambdas class is initialized + import cc_common.config + from common_test.test_data_generator import TestDataGenerator + + cc_common.config.config = cc_common.config._Config() # noqa: SLF001 protected-access + self.config = cc_common.config.config + self.test_data_generator = TestDataGenerator + + # Keep provider_record_util's module-level config binding in sync with the + # new singleton so that generate_privileges_for_provider sees test overrides. + import cc_common.data_model.provider_record_util + + cc_common.data_model.provider_record_util.config = self.config + + # Clear the live_compact_jurisdictions cached property so class-level patches or per-test overrides + # are used on first access instead of a value cached from the compact config table. + self.config.__dict__.pop('live_compact_jurisdictions', None) + + self.addCleanup(self.delete_resources) + + def build_resources(self): + self._bucket = boto3.resource('s3').create_bucket(Bucket=os.environ['BULK_BUCKET_NAME']) + self.create_provider_table() + self.create_staff_users_table() + self.create_ssn_table() + self.create_rate_limiting_table() + self.create_compact_configuration_table() + # By default, we'll set compact config so get_live_compact_jurisdictions returns an empty list so no privileges + # are generated by runtime logic. Tests that need to check privilege related logic can override this behavior + self._load_compact_configuration( + { + 'configuredStates': [], + } + ) + self.create_license_preprocessing_queue() + self.create_staff_user_pool() + + boto3.client('events').create_event_bus(Name=os.environ['EVENT_BUS_NAME']) + + def create_staff_user_pool(self): + # Create a new Cognito user pool + cognito_client = boto3.client('cognito-idp') + user_pool_name = 'TestUserPool' + user_pool_response = cognito_client.create_user_pool( + PoolName=user_pool_name, + AliasAttributes=['email'], + UsernameAttributes=['email'], + ) + os.environ['USER_POOL_ID'] = user_pool_response['UserPool']['Id'] + self._user_pool_id = user_pool_response['UserPool']['Id'] + + def create_staff_users_table(self): + self._staff_users_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'famGiv', 'AttributeType': 'S'}, + ], + TableName=self.config.users_table_name, + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['FAM_GIV_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'famGiv', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + + def create_provider_table(self): + self._provider_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerFamGivMid', 'AttributeType': 'S'}, + {'AttributeName': 'providerDateOfUpdate', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSISK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSISK', 'AttributeType': 'S'}, + ], + TableName=os.environ['PROVIDER_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['PROV_FAM_GIV_MID_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerFamGivMid', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerDateOfUpdate', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['LICENSE_GSI_NAME'], + 'KeySchema': [ + {'AttributeName': 'licenseGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': 'licenseUploadDateGSI', + 'KeySchema': [ + {'AttributeName': 'licenseUploadDateGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseUploadDateGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': { + 'ProjectionType': 'INCLUDE', + 'NonKeyAttributes': ['providerId'], + }, + }, + ], + ) + + def create_ssn_table(self): + self._ssn_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerIdGSIpk', 'AttributeType': 'S'}, + ], + TableName=os.environ['SSN_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['SSN_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'providerIdGSIpk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + + def create_rate_limiting_table(self): + self._rate_limiting_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['RATE_LIMITING_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def create_compact_configuration_table(self): + """Create the compact configuration table for testing.""" + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + ) + + def create_license_preprocessing_queue(self): + self._license_preprocessing_queue = boto3.resource('sqs').create_queue(QueueName='workflow-queue') + os.environ['LICENSE_PREPROCESSING_QUEUE_URL'] = self._license_preprocessing_queue.url + + def delete_resources(self): + self._bucket.objects.delete() + self._bucket.delete() + self._provider_table.delete() + self._staff_users_table.delete() + self._ssn_table.delete() + self._compact_configuration_table.delete() + self._rate_limiting_table.delete() + self._license_preprocessing_queue.delete() + boto3.client('events').delete_event_bus(Name=os.environ['EVENT_BUS_NAME']) + + def _load_compact_configuration(self, overrides: dict): + with open('../common/tests/resources/dynamo/compact.json') as f: + compact_data = json.load(f, parse_float=Decimal) + compact_data.update(overrides) + self._compact_configuration_table.put_item(Item=compact_data) + + def set_live_compact_jurisdictions_for_test(self, value: dict): + """ + Override live_compact_jurisdictions for this test without patching. + + Use when the test needs specific compacts -> list of jurisdiction codes (e.g. {'socw': ['ne', 'oh']}) + so that runtime privilege generation returns the expected privileges. Call at the start of the + test; the cache is cleared in setUp so this value will be used on first access. + """ + self.config.live_compact_jurisdictions = value + + def _load_jurisdiction_configuration(self, overrides: dict): + with open('../common/tests/resources/dynamo/jurisdiction.json') as f: + jurisdiction_data = json.load(f, parse_float=Decimal) + jurisdiction_data.update(overrides) + self._compact_configuration_table.put_item(Item=jurisdiction_data) + + def _load_provider_data(self): + """Use the canned test resources to load a basic provider to the DB""" + test_resources = glob('../common/tests/resources/dynamo/*.json') + + for resource in test_resources: + with open(resource) as f: + if resource.endswith('user.json'): + # skip the staff user test data, as it is not stored in the provider table + continue + record = json.load(f, parse_float=Decimal) + + logger.debug('Loading resource, %s: %s', resource, str(record)) + if record['type'] == 'provider-ssn': + self._ssn_table.put_item(Item=record) + else: + self._provider_table.put_item(Item=record) + + def _generate_providers( + self, + *, + home: str, + start_serial: int, + names: tuple[tuple[str, str]] = (), + date_of_update: datetime | None = None, + ): + """Generate 10 providers with one license. + :param home: The jurisdiction for the license + :param start_serial: Starting number for last portion of the provider's SSN + :param names: A list of tuples, each containing a family name and given name + :param date_of_update: Fixed date to use for provider updates, if None uses random dates + """ + from handlers.ingest import ingest_license_message, preprocess_license_ingest + + with open('../common/tests/resources/ingest/preprocessor-sqs-message.json') as f: + preprocessing_sqs_message = json.load(f) + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + ingest_message = json.load(f) + + name_faker = Faker(['en_US', 'ja_JP', 'es_MX']) + # Generate 10 providers, each with a license + for name_idx, ssn_serial in enumerate(range(start_serial, start_serial - 10, -1)): + # So we can mutate top-level fields without messing up subsequent iterations + preprocessing_sqs_message_copy = json.loads(json.dumps(preprocessing_sqs_message)) + ingest_message_copy = json.loads(json.dumps(ingest_message)) + + # Use a requested name, if provided + try: + family_name, given_name = names[name_idx] + except IndexError: + family_name = name_faker.unique.last_name() + given_name = name_faker.unique.first_name() + + # Update both message copies with the same data + ssn = f'{randint(100, 999)}-{randint(10, 99)}-{ssn_serial}' + + # Update preprocessing message with license data including SSN + preprocessing_sqs_message_copy.update( + { + 'compact': 'socw', + 'jurisdiction': home, + 'licenseNumber': f'TEST-{ssn_serial}', + 'licenseType': 'cosmetologist', + 'status': 'active', + 'dateOfIssuance': '2020-01-01', + 'dateOfExpiration': '2050-01-01', + 'familyName': family_name, + 'givenName': given_name, + 'middleName': name_faker.unique.first_name(), + 'ssn': ssn, + 'dateOfBirth': '1980-01-01', + 'homeAddressStreet1': '123 Test St', + 'homeAddressCity': 'Test City', + 'homeAddressState': 'TS', + 'homeAddressPostalCode': '12345', + } + ) + + # Update ingest message with the same data (minus SSN which will be handled by preprocessor) + ingest_message_copy['detail'].update( + { + 'familyName': family_name, + 'givenName': given_name, + 'middleName': name_faker.unique.first_name(), + 'compact': 'socw', + 'jurisdiction': home, + 'licenseNumber': f'TEST-{ssn_serial}', + 'licenseType': 'cosmetologist', + 'status': 'active', + 'dateOfIssuance': '2020-01-01', + 'dateOfExpiration': '2050-01-01', + 'dateOfBirth': '1980-01-01', + 'homeAddressStreet1': '123 Test St', + 'homeAddressCity': 'Test City', + 'homeAddressState': 'TS', + 'homeAddressPostalCode': '12345', + # Only include last 4 of SSN in the event bus message + 'ssnLastFour': ssn[-4:], + } + ) + + # Use provided date_of_update or generate random variation in dateOfUpdate values to sort by + patch_kwargs = {} + if date_of_update is not None: + # Use the provided fixed date + patch_kwargs['new_callable'] = lambda: date_of_update + else: + # Use random dates for variation + patch_kwargs['new_callable'] = lambda: ( + datetime.now(tz=UTC).replace(microsecond=0) - timedelta(days=randint(1, 365)) + ) + + with patch( + 'cc_common.config._Config.current_standard_datetime', + **patch_kwargs, + ): + # First call the preprocessor to handle the SSN data + preprocess_license_ingest( + {'Records': [{'messageId': '123', 'body': json.dumps(preprocessing_sqs_message_copy)}]}, + self.mock_context, + ) + # we need to get the provider id from the ssn table so it can be used in the ingest message + provider_id = self._ssn_table.get_item(Key={'pk': f'socw#SSN#{ssn}', 'sk': f'socw#SSN#{ssn}'})['Item'][ + 'providerId' + ] + + # update the ingest message with the provider id + ingest_message_copy['detail']['providerId'] = provider_id + + # Then call the ingest message handler to process the provider data + ingest_license_message( + {'Records': [{'messageId': '123', 'body': json.dumps(ingest_message_copy)}]}, + self.mock_context, + ) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/__init__.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py new file mode 100644 index 0000000000..c9f0ad2e8a --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py @@ -0,0 +1,169 @@ +from urllib.parse import quote + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestClient(TstFunction): + def test_get_providers_sorted_by_family_name(self): + from cc_common.data_model.data_client import DataClient + + self._generate_providers(home='oh', start_serial=9999) + self._generate_providers(home='ne', start_serial=9989) + self._generate_providers(home='ne', start_serial=9979) + client = DataClient(self.config) + + # We expect to see 10 providers with licenses in oh + resp = client.get_providers_sorted_by_family_name( + compact='socw', + jurisdiction='oh', + pagination={'pageSize': 5}, + ) + first_provider_ids = {item['providerId'] for item in resp['items']} + first_items = resp['items'] + self.assertEqual(5, len(resp['items'])) + self.assertIsInstance(resp['pagination']['lastKey'], str) + + last_key = resp['pagination']['lastKey'] + resp = client.get_providers_sorted_by_family_name( + compact='socw', + jurisdiction='oh', + pagination={'lastKey': last_key, 'pageSize': 100}, + ) + self.assertEqual(5, len(resp['items'])) + self.assertIsNone(resp['pagination']['lastKey']) + + second_provider_ids = {item['providerId'] for item in resp['items']} + # Verify that there are no repeat items between the two calls + self.assertFalse(first_provider_ids & second_provider_ids) + + # Verify sorting by family name (without getting into how duplicate family names are sorted) + all_items = [*first_items, *resp['items']] + family_names = [item['familyName'].lower() for item in all_items] + self.assertListEqual(sorted(family_names, key=quote), family_names) + + def test_get_providers_sorted_by_family_name_descending(self): + from cc_common.data_model.data_client import DataClient + + self._generate_providers(home='oh', start_serial=9999) + client = DataClient(self.config) + + resp = client.get_providers_sorted_by_family_name( + compact='socw', + jurisdiction='oh', + scan_forward=False, + ) + self.assertEqual(10, len(resp['items'])) + + # Verify sorting by family name (without getting into how duplicate family names are sorted) + family_names = [item['familyName'].lower() for item in resp['items']] + self.assertListEqual(sorted(family_names, key=quote, reverse=True), family_names) + + def test_get_providers_by_family_name(self): + from cc_common.data_model.data_client import DataClient + + # We'll provide names, so we know we'll have one record for our friends, Tess and Ted Testerly + self._generate_providers( + home='oh', + start_serial=9999, + names=( + ('Testerly', 'Tess'), + ('Testerly', 'Ted'), + ), + ) + client = DataClient(self.config) + + resp = client.get_providers_sorted_by_family_name( + compact='socw', + jurisdiction='oh', + provider_name=('Testerly', None), + scan_forward=False, + ) + + self.assertEqual(2, len(resp['items'])) + # Make sure both our providers have the expected familyName + for provider in resp['items']: + self.assertEqual('Testerly', provider['familyName']) + + def test_get_providers_by_family_and_given_name(self): + from cc_common.data_model.data_client import DataClient + + # We'll provide names, so we know we'll have one record for our friends, Tess and Ted Testerly + self._generate_providers( + home='oh', + start_serial=9999, + names=( + ('Testerly', 'Tess'), + ('Testerly', 'Ted'), + ), + ) + client = DataClient(self.config) + + resp = client.get_providers_sorted_by_family_name( + compact='socw', + jurisdiction='oh', + # By providing given and family name, we can expect only one provider returned + provider_name=('Testerly', 'Tess'), + scan_forward=False, + ) + self.assertEqual(1, len(resp['items'])) + + # Make sure we got the right provider + self.assertEqual('Tess', resp['items'][0]['givenName']) + self.assertEqual('Testerly', resp['items'][0]['familyName']) + + def test_get_providers_sorted_by_date_updated(self): + from cc_common.data_model.data_client import DataClient + + self._generate_providers(home='oh', start_serial=9999) + self._generate_providers(home='ne', start_serial=9989) + self._generate_providers(home='ne', start_serial=9979) + client = DataClient(self.config) + + # We expect to see 10 providers with licenses in oh + resp = client.get_providers_sorted_by_updated( + compact='socw', + jurisdiction='oh', + pagination={'pageSize': 5}, + ) + first_provider_ids = {item['providerId'] for item in resp['items']} + first_provider_items = resp['items'] + self.assertEqual(5, len(resp['items'])) + self.assertIsInstance(resp['pagination']['lastKey'], str) + + last_key = resp['pagination']['lastKey'] + resp = client.get_providers_sorted_by_updated( + compact='socw', + jurisdiction='oh', + pagination={'lastKey': last_key, 'pageSize': 10}, + ) + self.assertEqual(5, len(resp['items'])) + self.assertIsNone(resp['pagination']['lastKey']) + + second_provider_ids = {item['providerId'] for item in resp['items']} + # Verify that there are no repeat items between the two calls + self.assertFalse(first_provider_ids & second_provider_ids) + + all_items = [*first_provider_items, *resp['items']] + # Verify sorting by dateOfUpdate + dates_of_update = [item['dateOfUpdate'] for item in all_items] + self.assertListEqual(sorted(dates_of_update), dates_of_update) + + def test_get_providers_sorted_by_date_of_update_descending(self): + from cc_common.data_model.data_client import DataClient + + self._generate_providers(home='oh', start_serial=9999) + client = DataClient(self.config) + + resp = client.get_providers_sorted_by_updated( + compact='socw', + jurisdiction='oh', + scan_forward=False, + ) + self.assertEqual(10, len(resp['items'])) + + # Verify sorting by dateOfUpdate + dates_of_update = [item['dateOfUpdate'] for item in resp['items']] + self.assertListEqual(sorted(dates_of_update, reverse=True), dates_of_update) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py new file mode 100644 index 0000000000..054acd6661 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py @@ -0,0 +1,159 @@ +import json +from datetime import datetime +from unittest.mock import patch + +from cc_common.data_model.update_tier_enum import UpdateTierEnum +from moto import mock_aws + +from .. import TstFunction + +MOCK_CURRENT_DATETIME_STRING = '2024-11-08T23:59:59+00:00' + + +@mock_aws +class TestTransformations(TstFunction): + # Yes, this is an excessively long method. We're going with it for sake of a single illustrative test. + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_DATETIME_STRING)) + @patch('cc_common.config._Config.license_preprocessing_queue') + def test_transformations(self, mock_license_preprocessing_queue): + """Provider data undergoes several transformations from when a license is first posted, stored into the + database, then returned via the API. We will specifically test that chain, end to end, to make sure the + transformations all happen as expected. + """ + # Before we get started, we'll pre-set the SSN/providerId association we expect + with open('../common/tests/resources/dynamo/provider-ssn.json') as f: + provider_ssn = json.load(f) + + self._ssn_table.put_item(Item=provider_ssn) + expected_provider_id = provider_ssn['providerId'] + + # license data as it comes in from a board, in this case, as POSTed through the API + with open('../common/tests/resources/api/license-post.json') as f: + license_post = json.load(f) + license_ssn = license_post['ssn'] + + # The API Gateway event, as it is presented to the API lambda + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # Pack an array of one license into the request body + event['body'] = json.dumps([license_post]) + + # Compact and jurisdiction are provided via path parameters + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + # Authorize ourselves to write the license + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.write' + + from handlers.licenses import post_licenses + + # POST the license via the API + post_licenses(event, self.mock_context) + + # Capture the message sent to the preprocessing queue + preprocessing_message = json.loads( + mock_license_preprocessing_queue.send_messages.call_args.kwargs['Entries'][0]['MessageBody'] + ) + + # Now we need to simulate the preprocessing step + # Mock EventBatchWriter so we can intercept the EventBridge event + with patch('handlers.ingest.config.events_client', autospec=True) as mock_event_client: + from handlers.ingest import preprocess_license_ingest + + # Create an SQS event with our preprocessing message + preprocess_event = {'Records': [{'messageId': '123', 'body': json.dumps(preprocessing_message)}]} + + # Run the preprocessing step + preprocess_license_ingest(preprocess_event, self.mock_context) + + # Capture the event the preprocessor will produce for the event bus + event_bridge_event = json.loads(mock_event_client.put_events.call_args.kwargs['Entries'][0]['Detail']) + + # A sample SQS message from EventBridge + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # Pack our license.ingest event into the sample message + message['detail'] = event_bridge_event + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + from handlers.ingest import ingest_license_message + + # This should fully ingest the license, which will result in it being written to the DB + ingest_license_message(event, self.mock_context) + + # We'll fetch the provider id from the ssn table + provider_id = self._ssn_table.get_item(Key={'pk': f'socw#SSN#{license_ssn}', 'sk': f'socw#SSN#{license_ssn}'})[ + 'Item' + ]['providerId'] + self.assertEqual(expected_provider_id, provider_id) + + # Get the provider and all update records straight from the table, to inspect them + provider_user_records = self.config.data_client.get_provider_user_records( + compact='socw', provider_id=provider_id, include_update_tier=UpdateTierEnum.TIER_THREE + ) + + # One record for each of: provider and license (no privileges inSocial Workmodel) + self.assertEqual(2, len(provider_user_records.provider_records)) + records = {item['type']: item for item in provider_user_records.provider_records} + + # Expected representation of each record in the database + with open('../common/tests/resources/dynamo/provider.json') as f: + expected_provider = json.load(f) + expected_provider['licenseStatus'] = 'active' + expected_provider['compactEligibility'] = 'eligible' + + with open('../common/tests/resources/dynamo/license.json') as f: + expected_license = json.load(f) + # license should be active and compact eligible + expected_license['licenseStatus'] = 'active' + expected_license['compactEligibility'] = 'eligible' + expected_license['firstUploadDate'] = MOCK_CURRENT_DATETIME_STRING + expected_license['licenseUploadDateGSIPK'] = 'C#socw#J#oh#D#2024-11' + expected_license['licenseUploadDateGSISK'] = ( + 'TIME#1731110399#LT#cos#PID#89a6377e-c3a5-40e5-bca5-317ec854c570' + ) + + # each record has a dynamic dateOfUpdate field that we'll remove for comparison + for record in [expected_provider, expected_license, *records.values()]: + del record['dateOfUpdate'] + del expected_provider['providerDateOfUpdate'] + del records['provider']['providerDateOfUpdate'] + + # Make sure each is represented the way we expect, in the db + self.assertEqual(expected_provider, records['provider']) + self.assertEqual(expected_license, records['license']) + + from handlers.providers import get_provider + + # Get a fresh API Gateway event + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + event['pathParameters'] = {'compact': 'socw', 'providerId': provider_id} + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral socw/readPrivate' + + resp = get_provider(event, self.mock_context) + + # If we get a 200, our full ingest chain was successful + self.assertEqual(200, resp['statusCode']) + + provider_data = json.loads(resp['body']) + + # Expected representation of our provider coming _out_ via the API + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # Force the provider id to match + expected_provider['providerId'] = provider_id + # privileges tied to active states + expected_provider['privileges'] = [] + + # Drop dynamic fields from comparison + del provider_data['dateOfUpdate'] + del provider_data['licenses'][0]['dateOfUpdate'] + del expected_provider['dateOfUpdate'] + del expected_provider['licenses'][0]['dateOfUpdate'] + + # Phew! We've loaded the data all the way in via the ingest chain and back out via the API! + self.maxDiff = None + self.assertEqual(expected_provider, provider_data) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/__init__.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_bulk_upload.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_bulk_upload.py new file mode 100644 index 0000000000..09d6607b06 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_bulk_upload.py @@ -0,0 +1,466 @@ +import csv +import json +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from botocore.exceptions import ClientError +from moto import mock_aws + +from tests.function import TstFunction + +mock_flag_client = MagicMock() +mock_flag_client.return_value = True + + +@mock_aws +class TestBulkUpload(TstFunction): + def test_get_bulk_upload_url(self): + from handlers.bulk_upload import bulk_upload_url_handler + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + resp = bulk_upload_url_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + body = json.loads(resp['body']) + self.assertEqual({'url', 'fields'}, body['upload'].keys()) + + def test_get_bulk_upload_url_forbidden(self): + from handlers.bulk_upload import bulk_upload_url_handler + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + # User has permission in ne, not oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff ne/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + resp = bulk_upload_url_handler(event, self.mock_context) + + self.assertEqual(403, resp['statusCode']) + + +@mock_aws +@patch('cc_common.feature_flag_client.is_feature_enabled', mock_flag_client) +class TestProcessObjects(TstFunction): + def test_uploaded_csv(self): + from handlers.bulk_upload import parse_bulk_upload_file + + # Upload a bulk license csv file + object_key = f'socw/co/{uuid4().hex}' + self._bucket.upload_file('../common/tests/resources/licenses.csv', object_key) + + # Simulate the s3 bucket event + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + event['Records'][0]['s3']['bucket'] = { + 'name': self._bucket.name, + 'arn': f'arn:aws:s3:::{self._bucket.name}', + 'ownerIdentity': {'principalId': 'ASDFG123'}, + } + event['Records'][0]['s3']['object']['key'] = object_key + + parse_bulk_upload_file(event, self.mock_context) + + # The object should be gone, once parsing is complete + with self.assertRaises(ClientError): + self._bucket.Object(object_key).get() + + def test_bulk_upload_processor_puts_messages_on_preprocessing_queue(self): + from handlers.bulk_upload import parse_bulk_upload_file + + # Upload a bulk license csv file + object_key = f'socw/oh/{uuid4().hex}' + self._bucket.upload_file('../common/tests/resources/licenses.csv', object_key) + + # Simulate the s3 bucket event + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + event['Records'][0]['s3']['bucket'] = { + 'name': self._bucket.name, + 'arn': f'arn:aws:s3:::{self._bucket.name}', + 'ownerIdentity': {'principalId': 'ASDFG123'}, + } + event['Records'][0]['s3']['object']['key'] = object_key + + parse_bulk_upload_file(event, self.mock_context) + + # the test csv file has 5 valid licenses, so we should have 5 messages on the queue + messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(5, len(messages)) + + # load the csv test data into a dict object. Example row: + csv_licenses = {} + with open('../common/tests/resources/licenses.csv') as f: + reader = csv.DictReader(f) + for row in reader: + # add compact and jurisdiction to each row since this is injected into the sqs message + row['compact'] = 'socw' + row['jurisdiction'] = 'oh' + # the event time comes from the test put-event.json file + row['eventTime'] = '1970-01-01T00:00:00+00:00' + # some rows have an empty homeAddressStreet2, which we need to remove from the expected object + if not row['homeAddressStreet2']: + row.pop('homeAddressStreet2', None) + csv_licenses[row['licenseNumber']] = row + + for message in messages: + message_data = json.loads(message.body) + self.assertEqual(csv_licenses[message_data['licenseNumber']], message_data) + + def test_bulk_upload_strips_whitespace_from_string_fields(self): + """Test that whitespace is stripped from all string fields in CSV data.""" + from handlers.bulk_upload import parse_bulk_upload_file + + # Create CSV content with whitespace in string fields + csv_content = ( + 'ssn,licenseNumber,givenName,middleName,familyName,suffix,dateOfBirth,dateOfIssuance' + ',dateOfRenewal,dateOfExpiration,licenseStatus,compactEligibility,homeAddressStreet1' + ',homeAddressStreet2,homeAddressCity,homeAddressState,homeAddressPostalCode' + ',emailAddress,phoneNumber,licenseType,licenseStatusName\n' + '123-45-6789,' + ' LICENSE123 ,' + ' John ,' + ' Middle ,' + ' Doe ,' + ' Jr. ,' + '1990-01-01,' + '2020-01-01,' + '2021-01-01,' + '2023-01-01,' + ' active ,' + ' eligible ,' + ' 123 Main St ,' + ' Apt 1 ,' + ' Columbus ,' + ' OH ,' + ' 43215 ,' + ' test@example.com,' + '+15551234567,' + ' cosmetologist ,' + ' Active ' + ) + + # Upload the CSV content directly to the mock S3 bucket + object_key = f'socw/oh/{uuid4().hex}' + self._bucket.put_object(Key=object_key, Body=csv_content) + + # Simulate the s3 bucket event + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + event['Records'][0]['s3']['bucket'] = { + 'name': self._bucket.name, + 'arn': f'arn:aws:s3:::{self._bucket.name}', + 'ownerIdentity': {'principalId': 'ASDFG123'}, + } + event['Records'][0]['s3']['object']['key'] = object_key + + parse_bulk_upload_file(event, self.mock_context) + + # Verify that one message was sent to the preprocessing queue + messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(1, len(messages)) + + message_data = json.loads(messages[0].body) + + # Verify that whitespace was stripped from all string fields + self.assertEqual('LICENSE123', message_data['licenseNumber']) # Should be trimmed + self.assertEqual('John', message_data['givenName']) # Should be trimmed + self.assertEqual('Middle', message_data['middleName']) # Should be trimmed + self.assertEqual('Doe', message_data['familyName']) # Should be trimmed + self.assertEqual('Jr.', message_data['suffix']) # Should be trimmed + self.assertEqual('123 Main St', message_data['homeAddressStreet1']) # Should be trimmed + self.assertEqual('Apt 1', message_data['homeAddressStreet2']) # Should be trimmed + self.assertEqual('Columbus', message_data['homeAddressCity']) # Should be trimmed + self.assertEqual('OH', message_data['homeAddressState']) # Should be trimmed + self.assertEqual('43215', message_data['homeAddressPostalCode']) # Should be trimmed + self.assertEqual('test@example.com', message_data['emailAddress']) # Should be trimmed + self.assertEqual('cosmetologist', message_data['licenseType']) # Should be trimmed + self.assertEqual('Active', message_data['licenseStatusName']) # Should be trimmed + + # Verify that other fields remain unchanged + self.assertEqual('socw', message_data['compact']) + self.assertEqual('oh', message_data['jurisdiction']) + self.assertEqual('123-45-6789', message_data['ssn']) + self.assertEqual('active', message_data['licenseStatus']) + self.assertEqual('eligible', message_data['compactEligibility']) + + def test_bulk_upload_prevents_compact_jurisdiction_overwrites(self): + """Test that CSV compact/jurisdiction fields cannot overwrite URL path values.""" + from handlers.bulk_upload import parse_bulk_upload_file + + # Create CSV content that includes compact and jurisdiction fields + # These should NOT be allowed to overwrite the values from the URL path + csv_content = ( + 'ssn,licenseNumber,givenName,middleName,familyName,suffix,dateOfBirth,dateOfIssuance' + ',dateOfRenewal,dateOfExpiration,licenseStatus,compactEligibility,homeAddressStreet1' + ',homeAddressStreet2,homeAddressCity,homeAddressState,homeAddressPostalCode' + ',emailAddress,phoneNumber,licenseType,licenseStatusName,compact,jurisdiction\n' + '123-45-6789,LICENSE123,John,Middle,Doe,Jr.,1990-01-01,2020-01-01,2021-01-01,2023-01-01,active,' + 'eligible,123 Main St,Apt 1,Columbus,OH,43215,test@example.com,+15551234567,esthetician,Active,' + 'malicious_compact,malicious_jurisdiction' + ) + + # Upload the CSV content directly to the mock S3 bucket + # URL path indicates socw/oh, but CSV contains malicious_compact/malicious_jurisdiction + object_key = f'socw/oh/{uuid4().hex}' + self._bucket.put_object(Key=object_key, Body=csv_content) + + # Simulate the s3 bucket event + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + event['Records'][0]['s3']['bucket'] = { + 'name': self._bucket.name, + 'arn': f'arn:aws:s3:::{self._bucket.name}', + 'ownerIdentity': {'principalId': 'ASDFG123'}, + } + event['Records'][0]['s3']['object']['key'] = object_key + + # Mock EventBatchWriter to capture put_event calls + with patch('handlers.bulk_upload.EventBatchWriter') as mock_event_writer_class: + mock_event_writer = mock_event_writer_class.return_value.__enter__.return_value + # Mock the failed_entry_count attribute to return 0 + mock_event_writer.failed_entry_count = 0 + + # Process the file - should not raise an exception + parse_bulk_upload_file(event, self.mock_context) + + # Verify that put_event was called for the validation error + mock_event_writer.put_event.assert_called_once() + + # Get the call arguments to verify the event details + call_args = mock_event_writer.put_event.call_args[1]['Entry'] + + # Verify the complete event structure + expected_entry = { + 'Source': f'org.compactconnect.bulk-ingest.{object_key}', + 'DetailType': 'license.validation-error', + 'Detail': json.dumps( + { + 'eventTime': '1970-01-01T00:00:00+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'recordNumber': 1, + 'validData': { + 'licenseType': 'esthetician', + 'licenseStatusName': 'Active', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'licenseNumber': 'LICENSE123', + 'givenName': 'John', + 'middleName': 'Middle', + 'familyName': 'Doe', + 'suffix': 'Jr.', + 'dateOfIssuance': '2020-01-01', + 'dateOfRenewal': '2021-01-01', + 'dateOfExpiration': '2023-01-01', + }, + 'errors': ['License contains unsupported fields'], + } + ), + 'EventBusName': 'license-data-events', + } + + self.assertEqual(expected_entry, call_args) + + def test_bulk_upload_prevents_repeated_ssns_within_the_same_file_upload(self): + """Test that duplicate SSNs within a CSV upload are detected and rejected.""" + from handlers.bulk_upload import parse_bulk_upload_file + + # Create CSV content that includes duplicate SSNs + # Rows that duplicate the same SSN will be considered an error and not processed + csv_content = ( + 'ssn,licenseNumber,givenName,middleName,familyName,suffix,dateOfBirth,dateOfIssuance' + ',dateOfRenewal,dateOfExpiration,licenseStatus,compactEligibility,homeAddressStreet1' + ',homeAddressStreet2,homeAddressCity,homeAddressState,homeAddressPostalCode' + ',emailAddress,phoneNumber,licenseType,licenseStatusName\n' + '123-45-6789,LICENSE123,John,Middle,Doe,Jr.,1990-01-01,2020-01-01,2021-01-01,2023-01-01,active,' + 'eligible,123 Main St,Apt 1,Columbus,OH,43215,test@example.com,+15551234567,cosmetologist,Active\n' + '123-45-6789,LICENSE456,Jane,Middle,Smith,,1995-01-01,2023-01-01,2025-01-01,2026-01-01,active,' + 'eligible,123 Main St,Apt 1,Columbus,OH,43215,test@example.com,+15551234567,cosmetologist,Active' + ) + + # Upload the CSV content directly to the mock S3 bucket + object_key = f'socw/oh/{uuid4().hex}' + self._bucket.put_object(Key=object_key, Body=csv_content) + + # Simulate the s3 bucket event + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + event['Records'][0]['s3']['bucket'] = { + 'name': self._bucket.name, + 'arn': f'arn:aws:s3:::{self._bucket.name}', + 'ownerIdentity': {'principalId': 'ASDFG123'}, + } + event['Records'][0]['s3']['object']['key'] = object_key + + # Mock EventBatchWriter to capture put_event calls + with patch('handlers.bulk_upload.EventBatchWriter') as mock_event_writer_class: + mock_event_writer = mock_event_writer_class.return_value.__enter__.return_value + # Mock the failed_entry_count attribute to return 0 + mock_event_writer.failed_entry_count = 0 + + # Process the file - should not raise an exception + parse_bulk_upload_file(event, self.mock_context) + + # Verify that put_event was called for the validation error + mock_event_writer.put_event.assert_called_once() + + # Get the call arguments to verify the event details + call_args = mock_event_writer.put_event.call_args[1]['Entry'] + + # Verify the complete event structure + expected_entry = { + 'Source': f'org.compactconnect.bulk-ingest.{object_key}', + 'DetailType': 'license.validation-error', + 'Detail': json.dumps( + { + 'eventTime': '1970-01-01T00:00:00+00:00', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'recordNumber': 2, + 'validData': { + 'licenseType': 'cosmetologist', + 'licenseStatusName': 'Active', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'licenseNumber': 'LICENSE456', + 'givenName': 'Jane', + 'middleName': 'Middle', + 'familyName': 'Smith', + 'dateOfIssuance': '2023-01-01', + 'dateOfRenewal': '2025-01-01', + 'dateOfExpiration': '2026-01-01', + }, + 'errors': { + '_schema': [ + 'Duplicate License SSN detected for license type cosmetologist. ' + 'SSN matches with record 1. Every record must have a unique SSN per' + ' license type within the same file.' + ] + }, + } + ), + 'EventBusName': 'license-data-events', + } + + self.assertEqual(expected_entry, call_args) + + def test_bulk_upload_allows_repeated_ssns_for_different_license_types(self): + """Test that duplicate SSNs within a CSV upload are allowed if the license types are different.""" + from handlers.bulk_upload import parse_bulk_upload_file + + # Create CSV content that includes duplicate SSNs but different license types + csv_content = ( + 'ssn,licenseNumber,givenName,middleName,familyName,suffix,dateOfBirth,dateOfIssuance' + ',dateOfRenewal,dateOfExpiration,licenseStatus,compactEligibility,homeAddressStreet1' + ',homeAddressStreet2,homeAddressCity,homeAddressState,homeAddressPostalCode' + ',emailAddress,phoneNumber,licenseType,licenseStatusName\n' + '123-45-6789,LICENSE123,John,Middle,Doe,Jr.,1990-01-01,2020-01-01,2021-01-01,2023-01-01,active,' + 'eligible,123 Main St,Apt 1,Columbus,OH,43215,test@example.com,+15551234567,cosmetologist,Active\n' + '123-45-6789,LICENSE456,John,Middle,Doe,Jr.,1990-01-01,2023-01-01,2025-01-01,2026-01-01,active,' + 'eligible,123 Main St,Apt 1,Columbus,OH,43215,test@example.com,+15551234567,esthetician,' + 'Active' + ) + + # Upload the CSV content directly to the mock S3 bucket + object_key = f'socw/oh/{uuid4().hex}' + self._bucket.put_object(Key=object_key, Body=csv_content) + + # Simulate the s3 bucket event + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + event['Records'][0]['s3']['bucket'] = { + 'name': self._bucket.name, + 'arn': f'arn:aws:s3:::{self._bucket.name}', + 'ownerIdentity': {'principalId': 'ASDFG123'}, + } + event['Records'][0]['s3']['object']['key'] = object_key + + parse_bulk_upload_file(event, self.mock_context) + + # Verify that both messages were sent to the preprocessing queue + messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(2, len(messages)) + + message_data_1 = json.loads(messages[0].body) + message_data_2 = json.loads(messages[1].body) + + # Verify the license types are correct + # Messages might not be in order, so we check both + license_types = {message_data_1['licenseType'], message_data_2['licenseType']} + self.assertEqual({'esthetician', 'cosmetologist'}, license_types) + + # Verify SSNs are the same + self.assertEqual(message_data_1['ssn'], '123-45-6789') + self.assertEqual(message_data_2['ssn'], '123-45-6789') + + def test_bulk_upload_handles_bom_character(self): + """Test that CSV files with BOM characters are handled correctly.""" + from handlers.bulk_upload import parse_bulk_upload_file + + # Create CSV content without BOM in the string (BOM will be added during encoding) + csv_content = ( + 'dateOfIssuance,licenseNumber,dateOfBirth,licenseType,familyName,homeAddressCity,middleName,' + 'licenseStatus,licenseStatusName,compactEligibility,ssn,homeAddressStreet1,homeAddressStreet2,' + 'dateOfExpiration,homeAddressState,homeAddressPostalCode,givenName,dateOfRenewal\n' + '2024-06-30,BOM0608337260,2024-06-30,esthetician,TestFamily,Columbus,' + 'TestMiddle,active,ACTIVE,eligible,529-31-5413,123 BOM Test St.,Apt 1,2024-06-30,oh,43215,' + 'TestGiven,2024-06-30' + ) + + # Upload the CSV content with BOM added at byte level (simulates real BOM files) + object_key = f'socw/oh/{uuid4().hex}' + self._bucket.put_object(Key=object_key, Body=csv_content.encode('utf-8-sig')) + + # Simulate the s3 bucket event + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + event['Records'][0]['s3']['bucket'] = { + 'name': self._bucket.name, + 'arn': f'arn:aws:s3:::{self._bucket.name}', + 'ownerIdentity': {'principalId': 'ASDFG123'}, + } + event['Records'][0]['s3']['object']['key'] = object_key + + parse_bulk_upload_file(event, self.mock_context) + + # Verify that one message was sent to the preprocessing queue + messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(1, len(messages)) + + message_data = json.loads(messages[0].body) + + # Verify that the license was processed correctly despite the BOM character + self.assertEqual('BOM0608337260', message_data['licenseNumber']) + self.assertEqual('TestGiven', message_data['givenName']) + self.assertEqual('TestMiddle', message_data['middleName']) + self.assertEqual('TestFamily', message_data['familyName']) + self.assertEqual('Columbus', message_data['homeAddressCity']) + self.assertEqual('123 BOM Test St.', message_data['homeAddressStreet1']) + self.assertEqual('Apt 1', message_data['homeAddressStreet2']) + self.assertEqual('oh', message_data['homeAddressState']) + self.assertEqual('43215', message_data['homeAddressPostalCode']) + self.assertEqual('esthetician', message_data['licenseType']) + self.assertEqual('active', message_data['licenseStatus']) + self.assertEqual('ACTIVE', message_data['licenseStatusName']) + self.assertEqual('eligible', message_data['compactEligibility']) + self.assertEqual('529-31-5413', message_data['ssn']) + + # Verify injected fields + self.assertEqual('socw', message_data['compact']) + self.assertEqual('oh', message_data['jurisdiction']) + self.assertEqual('1970-01-01T00:00:00+00:00', message_data['eventTime']) + + # The object should be gone, once parsing is complete + with self.assertRaises(ClientError): + self._bucket.Object(object_key).get() diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py new file mode 100644 index 0000000000..f8e9b1b6f8 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py @@ -0,0 +1,1097 @@ +import json +from datetime import UTC, date, datetime, timedelta +from unittest.mock import patch +from uuid import uuid4 + +from boto3.dynamodb.conditions import Key +from cc_common.exceptions import CCInternalException +from common_test.test_constants import ( + DEFAULT_AA_SUBMITTING_USER_ID, + DEFAULT_COMPACT, + DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + DEFAULT_LICENSE_JURISDICTION, + DEFAULT_LICENSE_TYPE, + DEFAULT_LICENSE_TYPE_ABBREVIATION, + DEFAULT_PRIVILEGE_JURISDICTION, + DEFAULT_PROVIDER_ID, +) +from moto import mock_aws + +from .. import TstFunction + +PRIVILEGE_ENCUMBRANCE_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance' +) +LICENSE_ENCUMBRANCE_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance' +) +PRIVILEGE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance/{encumbranceId}' +) +LICENSE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/encumbrance/{encumbranceId}' +) + +TEST_ENCUMBRANCE_EFFECTIVE_DATE = '2023-01-15' + +# Noon UTC-4, designated encumbrance time to make date consistent across US timezones +TEST_ENCUMBRANCE_EFFECTIVE_DATETIME = '2023-01-15T12:00:00-04:00' + + +def _generate_test_body(): + from cc_common.data_model.schema.common import ClinicalPrivilegeActionCategory, EncumbranceType + + return { + 'encumbranceEffectiveDate': TEST_ENCUMBRANCE_EFFECTIVE_DATE, + # These Enums are expected to be `str` type, so we'll directly access their .value + 'encumbranceType': EncumbranceType.SUSPENSION.value, + 'clinicalPrivilegeActionCategories': [ClinicalPrivilegeActionCategory.FRAUD.value], + } + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPostPrivilegeEncumbrance(TstFunction): + """Test suite for privilege encumbrance endpoints.""" + + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'socw': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + + def _when_testing_privilege_encumbrance(self, body_overrides: dict | None = None): + self.test_data_generator.put_default_provider_record_in_provider_table() + self.test_data_generator.put_default_license_record_in_provider_table() + + body = _generate_test_body() + if body_overrides: + body.update(body_overrides) + + context = { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + } + test_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {context["jurisdiction"]}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': PRIVILEGE_ENCUMBRANCE_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': context['compact'], + 'providerId': context['providerId'], + 'jurisdiction': context['jurisdiction'], + 'licenseType': context['licenseTypeAbbreviation'], + }, + 'body': json.dumps(body), + }, + ) + return test_event, context + + def test_privilege_encumbrance_handler_returns_ok_message_with_valid_body(self): + from handlers.encumbrance import encumbrance_handler + + event = self._when_testing_privilege_encumbrance()[0] + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'OK'}, + response_body, + ) + + def test_privilege_encumbrance_handler_adds_adverse_action_record_in_provider_data_table(self): + from cc_common.data_model.schema.adverse_action import AdverseActionData + from handlers.encumbrance import encumbrance_handler + + event, context = self._when_testing_privilege_encumbrance() + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that the encumbrance record was added to the provider data table + pk = f'{context["compact"]}#PROVIDER#{context["providerId"]}' + sk_prefix = f'{context["compact"]}#PROVIDER#privilege/{context["jurisdiction"]}/cos#ADVERSE_ACTION' + adverse_action_encumbrances = self._provider_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq(pk) & Key('sk').begins_with(sk_prefix), + ) + self.assertEqual(1, len(adverse_action_encumbrances['Items'])) + item = adverse_action_encumbrances['Items'][0] + + default_adverse_action_encumbrance = self.test_data_generator.generate_default_adverse_action( + value_overrides={ + 'adverseActionId': item['adverseActionId'], + 'effectiveStartDate': date.fromisoformat(TEST_ENCUMBRANCE_EFFECTIVE_DATE), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + } + ) + loaded_adverse_action = AdverseActionData.from_database_record(item) + + self.assertEqual( + default_adverse_action_encumbrance.to_dict(), + loaded_adverse_action.to_dict(), + ) + + def test_privilege_encumbrance_handler_sets_provider_record_to_encumbered_in_provider_data_table(self): + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from cc_common.data_model.schema.provider import ProviderData + from handlers.encumbrance import encumbrance_handler + + event, context = self._when_testing_privilege_encumbrance() + test_provider_record = self.test_data_generator.generate_default_provider() + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that the encumbrance status was added to the provider record + provider_serialized_record = test_provider_record.serialize_to_database_record() + provider_records = self._provider_table.get_item( + Key={'pk': provider_serialized_record['pk'], 'sk': provider_serialized_record['sk']} + ) + item = provider_records['Item'] + + expected_provider_data = self.test_data_generator.generate_default_provider( + value_overrides={'dateOfUpdate': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, 'encumberedStatus': 'encumbered'} + ) + loaded_provider_data = ProviderData.from_database_record(item) + + self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + + self.assertEqual( + expected_provider_data.to_dict(), + loaded_provider_data.to_dict(), + ) + + def test_privilege_encumbrance_handler_returns_access_denied_if_compact_admin(self): + from handlers.encumbrance import encumbrance_handler + + event, context = self._when_testing_privilege_encumbrance() + + event['requestContext']['authorizer']['claims']['scope'] = f'openid email {context["compact"]}/admin' + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + def test_privilege_encumbrance_handler_returns_400_if_encumbrance_date_in_future(self): + """Verifying that encumbrance dates cannot be set in the future""" + from handlers.encumbrance import encumbrance_handler + + future_date = (datetime.now(tz=UTC) + timedelta(days=2)).strftime('%Y-%m-%d') + event, _ = self._when_testing_privilege_encumbrance(body_overrides={'encumbranceEffectiveDate': future_date}) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'The encumbrance date must not be a future date'}, + response_body, + ) + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_privilege_encumbrance_handler_publishes_event(self, mock_publish_event): + """Test that privilege encumbrance handler publishes the correct event.""" + from handlers.encumbrance import encumbrance_handler + + event, context = self._when_testing_privilege_encumbrance() + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify event was published with correct details + mock_publish_event.assert_called_once() + call_args = mock_publish_event.call_args[1] + self.assertEqual('org.compactconnect.provider-data', call_args['source']) + self.assertEqual('privilege.encumbrance', call_args['detail_type']) + self.assertEqual(context['compact'], call_args['detail']['compact']) + self.assertEqual(context['providerId'], call_args['detail']['providerId']) + self.assertEqual(context['jurisdiction'], call_args['detail']['jurisdiction']) + self.assertEqual(context['licenseTypeAbbreviation'], call_args['detail']['licenseTypeAbbreviation']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, call_args['detail']['eventTime']) + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_privilege_encumbrance_handler_handles_event_publishing_failure(self, mock_publish_event): + """Test that privilege encumbrance handler fails when event publishing fails.""" + from handlers.encumbrance import encumbrance_handler + + event, _ = self._when_testing_privilege_encumbrance() + mock_publish_event.side_effect = Exception('Event publishing failed') + + with self.assertRaises(Exception) as context: + encumbrance_handler(event, self.mock_context) + self.assertEqual('Event publishing failed', str(context.exception)) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPostLicenseEncumbrance(TstFunction): + """Test suite for license encumbrance endpoints.""" + + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'socw': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + + def _when_testing_valid_license_encumbrance(self, body_overrides: dict | None = None): + self.test_data_generator.put_default_provider_record_in_provider_table() + test_license_record = self.test_data_generator.put_default_license_record_in_provider_table() + + body = _generate_test_body() + if body_overrides: + body.update(body_overrides) + + test_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': LICENSE_ENCUMBRANCE_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': self.test_data_generator.get_license_type_abbr_for_license_type( + compact=test_license_record.compact, license_type=test_license_record.licenseType + ), + }, + 'body': json.dumps(body), + }, + ) + + # return both the event and test license record + return test_event, test_license_record + + def test_license_encumbrance_handler_returns_ok_message_with_valid_body(self): + from handlers.encumbrance import encumbrance_handler + + event = self._when_testing_valid_license_encumbrance()[0] + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'OK'}, + response_body, + ) + + def test_license_encumbrance_handler_adds_adverse_action_record_in_provider_data_table(self): + from cc_common.data_model.schema.adverse_action import AdverseActionData + from handlers.encumbrance import encumbrance_handler + + event, test_license_record = self._when_testing_valid_license_encumbrance() + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that the encumbrance record was added to the provider data table + # Perform a query to list all encumbrances for the provider using the starts_with key condition + adverse_action_encumbrances = self._provider_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq(test_license_record.serialize_to_database_record()['pk']) + & Key('sk').begins_with( + f'{test_license_record.compact}#PROVIDER#license/{test_license_record.jurisdiction}/cos#ADVERSE_ACTION' + ), + ) + self.assertEqual(1, len(adverse_action_encumbrances['Items'])) + item = adverse_action_encumbrances['Items'][0] + + expected_adverse_action_encumbrance = self.test_data_generator.generate_default_adverse_action( + value_overrides={ + 'actionAgainst': 'license', + 'adverseActionId': item['adverseActionId'], + 'effectiveStartDate': date.fromisoformat(TEST_ENCUMBRANCE_EFFECTIVE_DATE), + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + } + ) + loaded_adverse_action = AdverseActionData.from_database_record(item) + + self.assertEqual( + expected_adverse_action_encumbrance.to_dict(), + loaded_adverse_action.to_dict(), + ) + + def test_license_encumbrance_handler_adds_license_update_record_in_provider_data_table(self): + from handlers.encumbrance import encumbrance_handler + + event, test_license_record = self._when_testing_valid_license_encumbrance() + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that the update record was added for the license + license_update_records = self.test_data_generator.query_license_update_records_for_given_record_from_database( + test_license_record + ) + self.assertEqual(1, len(license_update_records)) + loaded_license_update_data = license_update_records[0] + + expected_license_update_data = self.test_data_generator.generate_default_license_update( + value_overrides={ + 'updateType': 'encumbrance', + 'updatedValues': {'encumberedStatus': 'encumbered'}, + 'createDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'effectiveDate': datetime.fromisoformat(TEST_ENCUMBRANCE_EFFECTIVE_DATETIME), + } + ) + + self.assertEqual( + expected_license_update_data.to_dict(), + loaded_license_update_data.to_dict(), + ) + + def test_license_encumbrance_handler_sets_license_record_to_encumbered_in_provider_data_table(self): + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from cc_common.data_model.schema.license import LicenseData + from handlers.encumbrance import encumbrance_handler + + event, test_license_record = self._when_testing_valid_license_encumbrance() + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that the encumbrance status was added to the license record + license_serialized_record = test_license_record.serialize_to_database_record() + license_records = self._provider_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq(license_serialized_record['pk']) + & Key('sk').eq(license_serialized_record['sk']), + ) + self.assertEqual(1, len(license_records['Items'])) + item = license_records['Items'][0] + + expected_license_data = self.test_data_generator.generate_default_license( + value_overrides={'dateOfUpdate': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, 'encumberedStatus': 'encumbered'} + ) + loaded_license_data = LicenseData.from_database_record(item) + + self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_license_data.encumberedStatus) + + self.assertEqual( + expected_license_data.to_dict(), + loaded_license_data.to_dict(), + ) + + def test_license_encumbrance_handler_sets_provider_record_to_encumbered_in_provider_data_table(self): + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from cc_common.data_model.schema.provider import ProviderData + from handlers.encumbrance import encumbrance_handler + + event, test_license_record = self._when_testing_valid_license_encumbrance() + test_provider_record = self.test_data_generator.generate_default_provider() + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that the encumbrance status was added to the provider record + provider_serialized_record = test_provider_record.serialize_to_database_record() + provider_records = self._provider_table.get_item( + Key={'pk': provider_serialized_record['pk'], 'sk': provider_serialized_record['sk']} + ) + item = provider_records['Item'] + + expected_provider_data = self.test_data_generator.generate_default_provider( + value_overrides={'dateOfUpdate': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, 'encumberedStatus': 'encumbered'} + ) + loaded_provider_data = ProviderData.from_database_record(item) + + self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + + self.assertEqual( + expected_provider_data.to_dict(), + loaded_provider_data.to_dict(), + ) + + def test_license_encumbrance_handler_returns_access_denied_if_compact_admin(self): + """Verifying that only state admins are allowed to encumber licenses""" + from handlers.encumbrance import encumbrance_handler + + event, test_license_record = self._when_testing_valid_license_encumbrance() + + event['requestContext']['authorizer']['claims']['scope'] = f'openid email {test_license_record.compact}/admin' + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + def test_license_encumbrance_handler_returns_400_if_encumbrance_date_in_future(self): + """Verifying that license encumbrances cannot have future effective dates""" + from handlers.encumbrance import encumbrance_handler + + future_date = (datetime.now(tz=UTC) + timedelta(days=2)).strftime('%Y-%m-%d') + + event, test_license_record = self._when_testing_valid_license_encumbrance( + body_overrides={'encumbranceEffectiveDate': future_date} + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'The encumbrance date must not be a future date'}, + response_body, + ) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPatchPrivilegeEncumbranceLifting(TstFunction): + """Test suite for privilege encumbrance lifting endpoints.""" + + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'socw': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + + def _setup_privilege_with_adverse_action( + self, + adverse_action_overrides=None, + license_overrides=None, + license_adverse_action_overrides=None, + include_license_adverse_action=True, + ): + """Helper method to set up provider + license + privilege adverse action for testing. + + :param license_adverse_action_overrides: Optional overrides for the license adverse action record only + (not shared with the privilege adverse action). + :param include_license_adverse_action: When True (default), also add a license adverse action + so the provider has another active encumbrance. Set False when testing "last encumbrance + lifted" so only the privilege adverse action exists and the provider can become unencumbered. + """ + context = { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseJurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'licenseTypeAbbreviation': DEFAULT_LICENSE_TYPE_ABBREVIATION, + } + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'encumberedStatus': 'encumbered'} + ) + test_adverse_action = self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'jurisdiction': context['jurisdiction'], + 'licenseType': context['licenseType'], + 'licenseTypeAbbreviation': context['licenseTypeAbbreviation'], + **(adverse_action_overrides or {}), + } + ) + + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'jurisdiction': context['licenseJurisdiction'], + 'licenseType': context['licenseType'], + **(license_overrides or {}), + } + ) + if include_license_adverse_action: + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'adverseActionId': uuid4(), + 'jurisdiction': context['licenseJurisdiction'], + 'licenseType': context['licenseType'], + **(license_adverse_action_overrides or {}), + } + ) + + return context, test_adverse_action + + def _generate_lift_encumbrance_event(self, context, adverse_action, body_overrides=None): + """Helper method to generate a test event for lifting privilege encumbrance.""" + body = { + 'effectiveLiftDate': '2024-01-15', + **(body_overrides or {}), + } + + return self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {context["jurisdiction"]}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': PRIVILEGE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': context['compact'], + 'providerId': context['providerId'], + 'jurisdiction': context['jurisdiction'], + 'licenseType': context['licenseTypeAbbreviation'], + 'encumbranceId': str(adverse_action.adverseActionId), + }, + 'body': json.dumps(body), + }, + ) + + def test_should_raise_cc_invalid_exception_if_lift_date_in_future(self): + from handlers.encumbrance import encumbrance_handler + + context, adverse_action = self._setup_privilege_with_adverse_action() + + # Set lift date to future + future_date = (datetime.now(UTC) + timedelta(days=2)).strftime('%Y-%m-%d') + event = self._generate_lift_encumbrance_event( + context, adverse_action, body_overrides={'effectiveLiftDate': future_date} + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('future date', response_body['message']) + + def test_should_raise_cc_invalid_exception_if_lift_date_is_invalid_date(self): + from handlers.encumbrance import encumbrance_handler + + context, adverse_action = self._setup_privilege_with_adverse_action() + + event = self._generate_lift_encumbrance_event( + context, adverse_action, body_overrides={'effectiveLiftDate': 'invalid-date'} + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertEqual("Invalid request body: {'effectiveLiftDate': ['Not a valid date.']}", response_body['message']) + + def test_should_raise_cc_not_found_exception_if_adverse_action_not_found(self): + from handlers.encumbrance import encumbrance_handler + + context, _ = self._setup_privilege_with_adverse_action() + + # Use a non-existent adverse action ID (valid UUID format that doesn't exist) + event = self._generate_lift_encumbrance_event( + context, type('MockAdverseAction', (), {'adverseActionId': str(uuid4())})() + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(404, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('Encumbrance record not found', response_body['message']) + + def test_should_raise_cc_invalid_exception_if_adverse_action_is_already_lifted(self): + from handlers.encumbrance import encumbrance_handler + + context, adverse_action = self._setup_privilege_with_adverse_action( + adverse_action_overrides={'effectiveLiftDate': date(2024, 1, 10)} + ) + + event = self._generate_lift_encumbrance_event(context, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('already been lifted', response_body['message']) + + def test_should_return_ok_message_if_successful(self): + from handlers.encumbrance import encumbrance_handler + + context, adverse_action = self._setup_privilege_with_adverse_action() + event = self._generate_lift_encumbrance_event(context, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + response_body = json.loads(response['body']) + self.assertEqual({'message': 'OK'}, response_body) + + def test_should_update_adverse_action_to_set_lifted_fields_when_privilege_encumbrance_is_lifted(self): + from handlers.encumbrance import encumbrance_handler + + context, adverse_action = self._setup_privilege_with_adverse_action() + event = self._generate_lift_encumbrance_event(context, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify adverse action has lift information + provider_records = self.config.data_client.get_provider_user_records( + compact=context['compact'], provider_id=context['providerId'] + ) + + adverse_actions = provider_records.get_adverse_action_records_for_privilege( + privilege_jurisdiction=context['jurisdiction'], + privilege_license_type_abbreviation=adverse_action.licenseTypeAbbreviation, + ) + + self.assertEqual(1, len(adverse_actions)) + lifted_adverse_action = adverse_actions[0] + self.assertEqual(date(2024, 1, 15), lifted_adverse_action.effectiveLiftDate) + self.assertEqual(DEFAULT_AA_SUBMITTING_USER_ID, str(lifted_adverse_action.liftingUser)) + + def test_should_update_provider_record_to_unencumbered_when_last_privilege_encumbrance_is_lifted(self): + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from handlers.encumbrance import encumbrance_handler + + # Only one adverse action (privilege) so lifting it makes provider unencumbered + context, adverse_action = self._setup_privilege_with_adverse_action( + include_license_adverse_action=False, + ) + event = self._generate_lift_encumbrance_event(context, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify provider record is now unencumbered + provider_records: ProviderUserRecords = self.config.data_client.get_provider_user_records( + compact=context['compact'], provider_id=context['providerId'] + ) + + loaded_provider_data = provider_records.get_provider_record() + self.assertEqual(LicenseEncumberedStatusEnum.UNENCUMBERED, loaded_provider_data.encumberedStatus) + + def test_should_not_update_provider_record_when_other_privilege_encumbrances_exist(self): + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from handlers.encumbrance import encumbrance_handler + + # Set up first privilege with adverse action + context, adverse_action = self._setup_privilege_with_adverse_action() + + # Add a second (still active) adverse action for another privilege so provider stays encumbered + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'adverseActionId': uuid4(), + 'compact': context['compact'], + 'jurisdiction': 'ky', # Different jurisdiction + 'licenseType': context['licenseType'], + 'licenseTypeAbbreviation': context['licenseTypeAbbreviation'], + 'providerId': context['providerId'], + } + ) + + event = self._generate_lift_encumbrance_event(context, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify provider record remains encumbered + provider_records: ProviderUserRecords = self.config.data_client.get_provider_user_records( + compact=context['compact'], provider_id=context['providerId'] + ) + + loaded_provider_data = provider_records.get_provider_record() + self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + + def test_should_not_update_provider_record_when_encumbered_license_exists(self): + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from handlers.encumbrance import encumbrance_handler + + # Set up privilege with adverse action; helper also adds a license adverse action (distinct ID) + # so lifting the privilege encumbrance leaves an active encumbrance and provider stays encumbered + context, adverse_action = self._setup_privilege_with_adverse_action() + + event = self._generate_lift_encumbrance_event(context, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify provider record remains encumbered + provider_records: ProviderUserRecords = self.config.data_client.get_provider_user_records( + compact=context['compact'], provider_id=context['providerId'] + ) + + loaded_provider_data = provider_records.get_provider_record() + self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + + def test_should_return_access_denied_if_compact_admin_attempts_to_lift_privilege_encumbrance(self): + """Verifying that only state admins are allowed to lift privilege encumbrances""" + from handlers.encumbrance import encumbrance_handler + + context, adverse_action = self._setup_privilege_with_adverse_action() + event = self._generate_lift_encumbrance_event(context, adverse_action) + + # Change scope to compact admin instead of state admin + event['requestContext']['authorizer']['claims']['scope'] = f'openid email {context["compact"]}/admin' + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode']) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_privilege_encumbrance_lifting_handler_publishes_event(self, mock_publish_event): + """Test that privilege encumbrance lifting handler publishes the correct event.""" + from handlers.encumbrance import encumbrance_handler + + ctx, adverse_action = self._setup_privilege_with_adverse_action() + event = self._generate_lift_encumbrance_event(ctx, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify event was published with correct details + mock_publish_event.assert_called_once() + call_args = mock_publish_event.call_args[1] + self.assertEqual('org.compactconnect.provider-data', call_args['source']) + self.assertEqual('privilege.encumbranceLifted', call_args['detail_type']) + self.assertEqual(ctx['compact'], call_args['detail']['compact']) + self.assertEqual(ctx['providerId'], call_args['detail']['providerId']) + self.assertEqual(ctx['jurisdiction'], call_args['detail']['jurisdiction']) + self.assertEqual(ctx['licenseTypeAbbreviation'], call_args['detail']['licenseTypeAbbreviation']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, call_args['detail']['eventTime']) + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_privilege_encumbrance_lifting_handler_handles_event_publishing_failure(self, mock_publish_event): + """Test that privilege encumbrance lifting handler fails when event publishing fails.""" + from handlers.encumbrance import encumbrance_handler + + ctx, adverse_action = self._setup_privilege_with_adverse_action() + event = self._generate_lift_encumbrance_event(ctx, adverse_action) + mock_publish_event.side_effect = Exception('Event publishing failed') + + with self.assertRaises(Exception) as exc_context: + encumbrance_handler(event, self.mock_context) + self.assertEqual('Event publishing failed', str(exc_context.exception)) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPatchLicenseEncumbranceLifting(TstFunction): + """Test suite for license encumbrance lifting endpoints.""" + + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'socw': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + + def _setup_license_with_adverse_action(self, adverse_action_overrides=None, license_overrides=None): + """Helper method to set up a license with an adverse action for testing.""" + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'encumberedStatus': 'encumbered'} + ) + test_license_record = self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides=license_overrides or {'encumberedStatus': 'encumbered'} + ) + test_adverse_action = self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'jurisdiction': test_license_record.jurisdiction, + **(adverse_action_overrides or {}), + } + ) + return test_license_record, test_adverse_action + + def _generate_lift_encumbrance_event(self, license_record, adverse_action, body_overrides=None): + """Helper method to generate a test event for lifting license encumbrance.""" + body = { + 'effectiveLiftDate': '2024-01-15', + **(body_overrides or {}), + } + + return self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': LICENSE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': license_record.compact, + 'providerId': str(license_record.providerId), + 'jurisdiction': license_record.jurisdiction, + 'licenseType': DEFAULT_LICENSE_TYPE_ABBREVIATION, + 'encumbranceId': str(adverse_action.adverseActionId), + }, + 'body': json.dumps(body), + }, + ) + + def test_should_raise_cc_invalid_exception_if_lift_date_in_future(self): + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action() + + # Set lift date to future + future_date = (datetime.now(tz=UTC) + timedelta(days=2)).strftime('%Y-%m-%d') + event = self._generate_lift_encumbrance_event( + license_record, adverse_action, body_overrides={'effectiveLiftDate': future_date} + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('future date', response_body['message']) + + def test_should_raise_cc_invalid_exception_if_lift_date_is_invalid_date(self): + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action() + + event = self._generate_lift_encumbrance_event( + license_record, adverse_action, body_overrides={'effectiveLiftDate': 'invalid-date'} + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertEqual("Invalid request body: {'effectiveLiftDate': ['Not a valid date.']}", response_body['message']) + + def test_should_raise_cc_not_found_exception_if_adverse_action_not_found(self): + from handlers.encumbrance import encumbrance_handler + + license_record, _ = self._setup_license_with_adverse_action() + + # Use a non-existent adverse action ID (valid UUID format that doesn't exist) + event = self._generate_lift_encumbrance_event( + license_record, type('MockAdverseAction', (), {'adverseActionId': str(uuid4())})() + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(404, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('Encumbrance record not found', response_body['message']) + + def test_should_raise_cc_invalid_exception_if_adverse_action_is_already_lifted(self): + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action( + adverse_action_overrides={'effectiveLiftDate': date(2024, 1, 10)} + ) + + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + response_body = json.loads(response['body']) + self.assertIn('already been lifted', response_body['message']) + + def test_should_return_ok_message_if_successful(self): + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action() + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + response_body = json.loads(response['body']) + self.assertEqual({'message': 'OK'}, response_body) + + def test_should_raise_cc_internal_exception_if_license_record_not_found(self): + from handlers.encumbrance import encumbrance_handler + + # Set up adverse action without corresponding license record + self.test_data_generator.put_default_provider_record_in_provider_table() + adverse_action = self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={'actionAgainst': 'license'} + ) + + event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {adverse_action.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': LICENSE_ENCUMBRANCE_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': adverse_action.compact, + 'providerId': str(adverse_action.providerId), + 'jurisdiction': adverse_action.jurisdiction, + 'licenseType': adverse_action.licenseTypeAbbreviation, + 'encumbranceId': str(adverse_action.adverseActionId), + }, + 'body': json.dumps( + { + 'effectiveLiftDate': '2024-01-15', + } + ), + }, + ) + + with self.assertRaises(CCInternalException) as context: + encumbrance_handler(event, self.mock_context) + + self.assertIn('License record not found', str(context.exception)) + + def test_should_update_encumbrance_status_on_license_record_if_last_encumbrance_lifted(self): + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action( + license_overrides={'encumberedStatus': 'encumbered'} + ) + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify license record is now unencumbered + provider_records = self.config.data_client.get_provider_user_records( + compact=license_record.compact, provider_id=str(license_record.providerId) + ) + + license_records = provider_records.get_license_records( + filter_condition=lambda record: ( + record.jurisdiction == license_record.jurisdiction and record.licenseType == license_record.licenseType + ) + ) + + self.assertEqual(1, len(license_records)) + self.assertEqual(LicenseEncumberedStatusEnum.UNENCUMBERED, license_records[0].encumberedStatus) + + def test_should_update_adverse_action_to_set_lifted_fields_when_license_encumbrance_is_lifted(self): + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action() + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify adverse action has lift information + provider_records = self.config.data_client.get_provider_user_records( + compact=license_record.compact, provider_id=str(license_record.providerId) + ) + + adverse_actions = provider_records.get_adverse_action_records_for_license( + license_jurisdiction=license_record.jurisdiction, + license_type_abbreviation=adverse_action.licenseTypeAbbreviation, + ) + + self.assertEqual(1, len(adverse_actions)) + lifted_adverse_action = adverse_actions[0] + self.assertEqual(date(2024, 1, 15), lifted_adverse_action.effectiveLiftDate) + self.assertEqual(DEFAULT_AA_SUBMITTING_USER_ID, str(lifted_adverse_action.liftingUser)) + + def test_should_update_provider_record_to_unencumbered_when_last_license_encumbrance_is_lifted(self): + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action( + license_overrides={'encumberedStatus': 'encumbered'} + ) + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify provider record is now unencumbered + provider_records: ProviderUserRecords = self.config.data_client.get_provider_user_records( + compact=license_record.compact, provider_id=str(license_record.providerId) + ) + + loaded_provider_data = provider_records.get_provider_record() + self.assertEqual(LicenseEncumberedStatusEnum.UNENCUMBERED, loaded_provider_data.encumberedStatus) + + def test_should_not_update_provider_record_when_other_license_encumbrances_exist(self): + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from handlers.encumbrance import encumbrance_handler + + # Set up first license with adverse action + license_record, adverse_action = self._setup_license_with_adverse_action() + + # Add a second (still active) adverse action for another license so provider stays encumbered + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'license', + 'adverseActionId': uuid4(), + 'compact': license_record.compact, + 'jurisdiction': 'ne', # Different jurisdiction + 'licenseType': license_record.licenseType, + 'licenseTypeAbbreviation': self.test_data_generator.get_license_type_abbr_for_license_type( + compact=license_record.compact, license_type=license_record.licenseType + ), + 'providerId': license_record.providerId, + } + ) + + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify provider record remains encumbered + provider_records: ProviderUserRecords = self.config.data_client.get_provider_user_records( + compact=license_record.compact, provider_id=str(license_record.providerId) + ) + + loaded_provider_data = provider_records.get_provider_record() + self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + + def test_should_not_update_provider_record_when_encumbered_privilege_exists(self): + from cc_common.data_model.provider_record_util import ProviderUserRecords + from cc_common.data_model.schema.common import LicenseEncumberedStatusEnum + from handlers.encumbrance import encumbrance_handler + + # Set up license with adverse action + license_record, adverse_action = self._setup_license_with_adverse_action() + + # Add a (still active) privilege adverse action so provider stays encumbered when lifting license + self.test_data_generator.put_default_adverse_action_record_in_provider_table( + value_overrides={ + 'actionAgainst': 'privilege', + 'adverseActionId': uuid4(), + 'compact': license_record.compact, + 'jurisdiction': 'ne', + 'licenseType': license_record.licenseType, + 'licenseTypeAbbreviation': self.test_data_generator.get_license_type_abbr_for_license_type( + compact=license_record.compact, license_type=license_record.licenseType + ), + 'providerId': license_record.providerId, + } + ) + + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify provider record remains encumbered + provider_records: ProviderUserRecords = self.config.data_client.get_provider_user_records( + compact=license_record.compact, provider_id=str(license_record.providerId) + ) + + loaded_provider_data = provider_records.get_provider_record() + self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + + def test_should_return_access_denied_if_compact_admin_attempts_to_lift_license_encumbrance(self): + """Verifying that only state admins are allowed to lift license encumbrances""" + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action() + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + # Change scope to compact admin instead of state admin + event['requestContext']['authorizer']['claims']['scope'] = f'openid email {license_record.compact}/admin' + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode']) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py new file mode 100644 index 0000000000..0d9444760c --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py @@ -0,0 +1,807 @@ +import json +from datetime import date, datetime +from unittest.mock import MagicMock, patch + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestIngest(TstFunction): + @staticmethod + def _set_provider_data_to_empty_values(expected_provider: dict) -> dict: + # The canned response resource assumes that the provider will be given + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privileges'] = [] + + return expected_provider + + def _with_ingested_license(self, omit_email: bool = False, omit_date_of_renewal: bool = False) -> str: + from handlers.ingest import ingest_license_message + + with open('../common/tests/resources/dynamo/provider-ssn.json') as f: + ssn_record = json.load(f) + + self._ssn_table.put_item(Item=ssn_record) + provider_id = ssn_record['providerId'] + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + if omit_email: + del message['detail']['emailAddress'] + if omit_date_of_renewal: + del message['detail']['dateOfRenewal'] + + # Upload a new license + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + return provider_id + + def _get_provider_via_api(self, provider_id: str) -> dict: + from handlers.providers import get_provider + + # To test full internal consistency, we'll also pull this new license record out + # via the API to make sure it shows up as expected. + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + event['pathParameters'] = {'compact': 'socw', 'providerId': provider_id} + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email stuff socw/readGeneral socw/readPrivate' + ) + resp = get_provider(event, self.mock_context) + self.assertEqual(resp['statusCode'], 200) + return json.loads(resp['body']) + + def test_new_provider_ingest(self): + from handlers.ingest import ingest_license_message + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = f.read() + + provider_id = json.loads(message)['detail']['providerId'] + + event = {'Records': [{'messageId': '123', 'body': message}]} + + resp = ingest_license_message(event, self.mock_context) + + self.assertEqual({'batchItemFailures': []}, resp) + + # Now get the full provider details + provider_data = self._get_provider_via_api(provider_id) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # Reset the expected data to match the canned response + expected_provider = self._set_provider_data_to_empty_values(expected_provider) + + # Removing/setting dynamic fields for comparison + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + expected_provider['providerId'] = provider_id + for license_data in expected_provider['licenses']: + del license_data['dateOfUpdate'] + license_data['providerId'] = provider_id + for license_data in provider_data['licenses']: + del license_data['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_old_inactive_license(self): + from handlers.ingest import ingest_license_message + + # So get_provider returns one privilege (ne) to match expected fixture + self.set_live_compact_jurisdictions_for_test({'socw': ['ne']}) + + # The test resource provider has a license in oh + self._load_provider_data() + with open('../common/tests/resources/dynamo/provider-ssn.json') as f: + provider_id = json.load(f)['providerId'] + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + # Imagine that this provider used to be licensed in ky. + # What happens if ky uploads that inactive license? + message['detail']['dateOfIssuance'] = '2006-01-01' + message['detail']['familyName'] = 'Oldname' + message['detail']['jurisdiction'] = 'ky' + message['detail']['licenseStatus'] = 'inactive' + message['detail']['compactEligibility'] = 'ineligible' + + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + resp = ingest_license_message(event, self.mock_context) + + self.assertEqual({'batchItemFailures': []}, resp) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + provider_data = self._get_provider_via_api(provider_id) + + # Removing dynamic fields from comparison + del expected_provider['providerId'] + del provider_data['providerId'] + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + + # We will look at the licenses separately + del expected_provider['licenses'] + licenses = provider_data.pop('licenses') + + # The original provider data is preferred over the posted license data in our test case + self.assertEqual(expected_provider, provider_data) + + # But the second license should now be listed + self.assertEqual(2, len(licenses)) + + @patch('handlers.ingest.EventBatchWriter', autospec=True) + def test_existing_provider_deactivation(self, mock_event_writer): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # What happens if their license goes inactive in a subsequent upload? + message['detail']['licenseStatus'] = 'inactive' + message['detail']['compactEligibility'] = 'ineligible' + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # The license status and provider should immediately be inactive + expected_provider['jurisdictionUploadedLicenseStatus'] = 'inactive' + expected_provider['jurisdictionUploadedCompactEligibility'] = 'ineligible' + expected_provider['licenses'][0]['jurisdictionUploadedLicenseStatus'] = 'inactive' + expected_provider['licenses'][0]['jurisdictionUploadedCompactEligibility'] = 'ineligible' + # these should be calculated as inactive at record load time + expected_provider['licenseStatus'] = 'inactive' + expected_provider['licenses'][0]['licenseStatus'] = 'inactive' + expected_provider['compactEligibility'] = 'ineligible' + expected_provider['licenses'][0]['compactEligibility'] = 'ineligible' + + provider_data = self._get_provider_via_api(provider_id) + + # Reset the expected data to match the canned response + expected_provider = self._set_provider_data_to_empty_values(expected_provider) + + # Removing/setting dynamic fields for comparison + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + expected_provider['providerId'] = provider_id + for license_data in expected_provider['licenses']: + del license_data['dateOfUpdate'] + license_data['providerId'] = provider_id + for license_data in provider_data['licenses']: + del license_data['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + # Assert that an event was sent for the deactivation + mock_event_writer.return_value.__enter__.return_value.put_event.assert_called_once() + call_kwargs = mock_event_writer.return_value.__enter__.return_value.put_event.call_args.kwargs + self.assertEqual( + { + 'Entry': { + 'Source': 'org.compactconnect.provider-data', + 'DetailType': 'license.deactivation', + 'Detail': json.dumps( + { + 'compact': 'socw', + 'jurisdiction': 'oh', + 'eventTime': '2024-11-08T23:59:59+00:00', + 'providerId': provider_id, + 'licenseType': 'cosmetologist', + } + ), + 'EventBusName': 'license-data-events', + } + }, + call_kwargs, + ) + + @patch('handlers.ingest.EventBatchWriter', autospec=True) + def test_expired_license_deactivation_does_not_send_event(self, mock_event_writer): + """Test that license deactivation event is NOT sent when the license is expired.""" + from common_test.test_constants import ( + DEFAULT_COMPACT, + DEFAULT_LICENSE_JURISDICTION, + DEFAULT_LICENSE_TYPE, + DEFAULT_PROVIDER_ID, + ) + from handlers.ingest import ingest_license_message + + # Set up test data with an expired license that gets deactivated + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Create a license that is expired (dateOfExpiration before current date) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'dateOfExpiration': date.fromisoformat( + '2024-11-05' + ), # expired compared to mock test date of 2024-11-08 + 'jurisdictionUploadedLicenseStatus': 'active', # Currently active, will be deactivated + 'jurisdictionUploadedCompactEligibility': 'eligible', + } + ) + + # Create the ingest message to deactivate the expired license + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + message['detail'].update( + { + 'compact': DEFAULT_COMPACT, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'providerId': DEFAULT_PROVIDER_ID, + 'dateOfExpiration': '2024-11-05', # expired compared to mock test date of 2024-11-08 + 'licenseStatus': 'inactive', # Being deactivated by jurisdiction + 'compactEligibility': 'ineligible', + } + ) + + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + # Execute the ingest + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + # Verify that NO license deactivation event was sent because the license is expired + mock_event_writer.return_value.__enter__.return_value.put_event.assert_not_called() + + def _when_test_existing_provider_renewal(self, message_detail: dict, omit_date_of_renewal: bool = False): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license(omit_date_of_renewal=omit_date_of_renewal) + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + message['detail'].update(message_detail) + if omit_date_of_renewal: + del message['detail']['dateOfRenewal'] + + # What happens if their license is renewed in a subsequent upload? + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # The license status and provider should immediately reflect the new dates + expected_provider['dateOfExpiration'] = '2030-03-03' + expected_provider['licenses'][0]['dateOfExpiration'] = '2030-03-03' + if omit_date_of_renewal: + del expected_provider['licenses'][0]['dateOfRenewal'] + else: + expected_provider['licenses'][0]['dateOfRenewal'] = '2025-03-03' + + provider_data = self._get_provider_via_api(provider_id) + + # Reset the expected data to match the canned response + expected_provider = self._set_provider_data_to_empty_values(expected_provider) + + # Removing/setting dynamic fields for comparison + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + expected_provider['providerId'] = provider_id + for license_data in expected_provider['licenses']: + del license_data['dateOfUpdate'] + license_data['providerId'] = provider_id + for license_data in provider_data['licenses']: + del license_data['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_existing_provider_renewal(self): + self._when_test_existing_provider_renewal( + message_detail={'dateOfRenewal': '2025-03-03', 'dateOfExpiration': '2030-03-03'}, omit_date_of_renewal=False + ) + + def test_existing_provider_renewal_without_date_of_renewal_field(self): + self._when_test_existing_provider_renewal( + message_detail={'dateOfExpiration': '2030-03-03'}, omit_date_of_renewal=True + ) + + def test_existing_provider_name_change(self): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + message['detail'].update({'familyName': 'VonSmitherton'}) + + # What happens if their name changes in a subsequent upload? + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # The license status and provider should immediately reflect the new name + expected_provider['familyName'] = 'VonSmitherton' + expected_provider['licenses'][0]['familyName'] = 'VonSmitherton' + + provider_data = self._get_provider_via_api(provider_id) + + # Reset the expected data to match the canned response + expected_provider = self._set_provider_data_to_empty_values(expected_provider) + + # Removing/setting dynamic fields for comparison + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + expected_provider['providerId'] = provider_id + for license_data in expected_provider['licenses']: + del license_data['dateOfUpdate'] + license_data['providerId'] = provider_id + for license_data in provider_data['licenses']: + del license_data['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_existing_provider_no_change(self): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # What happens if their license is uploaded again with no change? + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # The license status and provider should remain unchanged + provider_data = self._get_provider_via_api(provider_id) + + # Reset the expected data to match the canned response + expected_provider = self._set_provider_data_to_empty_values(expected_provider) + + # Removing/setting dynamic fields for comparison + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + expected_provider['providerId'] = provider_id + for license_data in expected_provider['licenses']: + del license_data['dateOfUpdate'] + license_data['providerId'] = provider_id + for license_data in provider_data['licenses']: + del license_data['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_existing_provider_removed_email(self): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + del message['detail']['emailAddress'] + + # What happens if their email is removed in a subsequent upload? + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # The license status and provider should immediately reflect the removal of the email + provider_data = self._get_provider_via_api(provider_id) + + # Reset the expected data to match the canned response + expected_provider = self._set_provider_data_to_empty_values(expected_provider) + + for license_data in expected_provider['licenses']: + # We uploaded a license with no email by just deleting emailAddress + # This should show up in the license history + del license_data['emailAddress'] + + # Removing/setting dynamic fields for comparison + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + expected_provider['providerId'] = provider_id + for license_data in expected_provider['licenses']: + del license_data['dateOfUpdate'] + license_data['providerId'] = provider_id + for license_data in provider_data['licenses']: + del license_data['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_existing_provider_added_email(self): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license(omit_email=True) + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # What happens if their email is added in a subsequent upload? + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + # The license status and provider should immediately reflect the new email + provider_data = self._get_provider_via_api(provider_id) + + # Reset the expected data to match the canned response + expected_provider = self._set_provider_data_to_empty_values(expected_provider) + + # Removing/setting dynamic fields for comparison + del expected_provider['dateOfUpdate'] + del provider_data['dateOfUpdate'] + expected_provider['providerId'] = provider_id + for license_data in expected_provider['licenses']: + del license_data['dateOfUpdate'] + license_data['providerId'] = provider_id + for license_data in provider_data['licenses']: + del license_data['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_preprocess_license_ingest_creates_ssn_provider_record(self): + from handlers.ingest import preprocess_license_ingest + + test_ssn = '123-12-1234' + + # Before running method under test, ensure the provider ssn record does not exist + provider = self._ssn_table.get_item(Key={'pk': f'socw#SSN#{test_ssn}', 'sk': f'socw#SSN#{test_ssn}'}) + self.assertNotIn('Item', provider) + + with open('../common/tests/resources/ingest/preprocessor-sqs-message.json') as f: + message = json.load(f) + # set fixed ssn here to ensure we are checking the expected value + message['ssn'] = test_ssn + + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + resp = preprocess_license_ingest(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + # Find the provider's id from their ssn + provider = self._ssn_table.get_item(Key={'pk': f'socw#SSN#{test_ssn}', 'sk': f'socw#SSN#{test_ssn}'})['Item'] + provider_id = provider['providerId'] + # the provider_id is randomly generated, so we cannot check an exact value, just to make sure it exists + self.assertIsNotNone(provider_id) + + def test_preprocess_license_returns_batch_item_failure_if_error_occurs(self): + from handlers.ingest import preprocess_license_ingest + + # adding an invalid ssn here to force an exception + test_ssn = False + with open('../common/tests/resources/ingest/preprocessor-sqs-message.json') as f: + message = json.load(f) + # set fixed ssn here to ensure we are checking the expected value + message['ssn'] = test_ssn + + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + resp = preprocess_license_ingest(event, self.mock_context) + self.assertEqual({'batchItemFailures': [{'itemIdentifier': '123'}]}, resp) + + def test_multiple_license_types_same_jurisdiction(self): + """ + Test that multiple license types in the same jurisdiction are handled correctly. + + This test: + 1. Ingests a first active license with licenseType: cosmetologist + 2. For the same provider, ingests a second active license with licenseType: esthetician and a newer + dateOfIssuance + 3. Verifies that both licenses are present and that the provider data was copied from the esthetician license + """ + from handlers.ingest import ingest_license_message + + # First, ingest a cosmetologist license + provider_id = self._with_ingested_license() + + # Get the provider data after the first license ingest + provider_data_after_first_license = self._get_provider_via_api(provider_id) + + # Verify the first license was ingested correctly + self.assertEqual(1, len(provider_data_after_first_license['licenses'])) + self.assertEqual('cosmetologist', provider_data_after_first_license['licenses'][0]['licenseType']) + self.assertEqual('oh', provider_data_after_first_license['licenseJurisdiction']) + self.assertEqual('Björk', provider_data_after_first_license['givenName']) + + # Now ingest a second license for the same provider but with a different license type + # and a newer issuance date + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # Update the message to be for an esthetician license with a newer issuance date + # and a different givenName to track which license is used for provider data + message['detail'].update( + { + 'licenseType': 'esthetician', + 'dateOfIssuance': '2020-06-06', # Newer than the first license (2010-06-06) + 'licenseNumber': 'B0608337260', # Different license number + 'givenName': 'Audrey', # Different name to track which license is used + } + ) + + # Ingest the second license + event = {'Records': [{'messageId': '456', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + # Get the updated provider data + provider_data = self._get_provider_via_api(provider_id) + + # Verify that both licenses are present + self.assertEqual(2, len(provider_data['licenses'])) + + # Find each license by type + cos_license = next((lic for lic in provider_data['licenses'] if lic['licenseType'] == 'cosmetologist'), None) + est_license = next((lic for lic in provider_data['licenses'] if lic['licenseType'] == 'esthetician'), None) + + # Verify both licenses exist + self.assertIsNotNone(cos_license, 'cosmetologist license not found') + self.assertIsNotNone(est_license, 'esthetician license not found') + + # Verify license details + self.assertEqual('A0608337260', cos_license['licenseNumber']) + self.assertEqual('2010-06-06', cos_license['dateOfIssuance']) + self.assertEqual('oh', cos_license['jurisdiction']) + self.assertEqual('Björk', cos_license['givenName']) + + self.assertEqual('B0608337260', est_license['licenseNumber']) + self.assertEqual('2020-06-06', est_license['dateOfIssuance']) + self.assertEqual('oh', est_license['jurisdiction']) + self.assertEqual('Audrey', est_license['givenName']) + + # Verify that the provider data was copied from the esthetician license (newer issuance date) + # by checking the givenName + self.assertEqual('oh', provider_data['licenseJurisdiction']) + self.assertEqual('Audrey', provider_data['givenName']) + self.assertEqual('Guðmundsdóttir', provider_data['familyName']) + + def test_multiple_license_types_different_jurisdictions(self): + """ + Test that multiple license types in different jurisdictions are handled correctly. + + This test: + 1. Ingests a first active license with licenseType: cosmetologist in 'oh' + 2. For the same provider, ingests a second active license with licenseType: esthetician in 'ky' + 3. Verifies that both licenses are present and the provider data is from the most recently issued license + """ + from handlers.ingest import ingest_license_message + + # First, ingest a cosmetologist license in 'oh' + provider_id = self._with_ingested_license() + + # Get the provider data after the first license ingest + provider_data_after_first_license = self._get_provider_via_api(provider_id) + + # Verify the first license was ingested correctly + self.assertEqual(1, len(provider_data_after_first_license['licenses'])) + self.assertEqual('cosmetologist', provider_data_after_first_license['licenses'][0]['licenseType']) + self.assertEqual('oh', provider_data_after_first_license['licenseJurisdiction']) + self.assertEqual('Björk', provider_data_after_first_license['givenName']) + + # Now ingest a second license for the same provider but with a different license type + # in a different jurisdiction and a newer issuance date + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # Update the message to be for an esthetician license in 'ky' with a newer issuance date + # and a different givenName to track which license is used for provider data + message['detail'].update( + { + 'licenseType': 'esthetician', + 'jurisdiction': 'ky', + 'dateOfIssuance': '2020-06-06', # Newer than the first license (2010-06-06) + 'licenseNumber': 'B0608337260', # Different license number + 'givenName': 'Audrey', # Different name to track which license is used + } + ) + + # Ingest the second license + event = {'Records': [{'messageId': '456', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + # Get the updated provider data + provider_data = self._get_provider_via_api(provider_id) + + # Verify that both licenses are present + self.assertEqual(2, len(provider_data['licenses'])) + + # Find each license by jurisdiction and type + oh_license = next((lic for lic in provider_data['licenses'] if lic['jurisdiction'] == 'oh'), None) + ky_license = next((lic for lic in provider_data['licenses'] if lic['jurisdiction'] == 'ky'), None) + + # Verify both licenses exist + self.assertIsNotNone(oh_license, 'Ohio license not found') + self.assertIsNotNone(ky_license, 'Kentucky license not found') + + # Verify license details + self.assertEqual('cosmetologist', oh_license['licenseType']) + self.assertEqual('A0608337260', oh_license['licenseNumber']) + self.assertEqual('2010-06-06', oh_license['dateOfIssuance']) + self.assertEqual('Björk', oh_license['givenName']) + + self.assertEqual('esthetician', ky_license['licenseType']) + self.assertEqual('B0608337260', ky_license['licenseNumber']) + self.assertEqual('2020-06-06', ky_license['dateOfIssuance']) + self.assertEqual('Audrey', ky_license['givenName']) + + # Verify that the provider data was copied from the esthetician license in 'ky' + # because it has a newer issuance date. We can verify this by checking the givenName. + self.assertEqual('ky', provider_data['licenseJurisdiction']) + self.assertEqual('Audrey', provider_data['givenName']) + self.assertEqual('Guðmundsdóttir', provider_data['familyName']) + + def test_same_license_types_different_jurisdictions_triggers_home_jurisdiction_change_event_bridge_notification( + self, + ): + """ + Same license type (cosmetologist) in two jurisdictions: a newer issuance from KY replaces OH as the best + cosmetologist license and ingest emits ``provider.homeStateChange`` with former OH and new KY. + """ + import handlers.ingest as ingest_handler + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + provider_data_after_first_license = self._get_provider_via_api(provider_id) + + # Verify the first license was ingested correctly + self.assertEqual(1, len(provider_data_after_first_license['licenses'])) + self.assertEqual('cosmetologist', provider_data_after_first_license['licenses'][0]['licenseType']) + self.assertEqual('oh', provider_data_after_first_license['licenseJurisdiction']) + self.assertEqual('Björk', provider_data_after_first_license['givenName']) + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # Same license type as OH, but KY upload with a newer issuance date → new “home” license jurisdiction for type + message['detail'].update( + { + 'licenseType': 'cosmetologist', + 'jurisdiction': 'ky', + 'dateOfIssuance': '2020-06-06', + 'licenseNumber': 'B0608337260', + 'givenName': 'Audrey', + } + ) + + mock_put_events = MagicMock(return_value={'FailedEntryCount': 0, 'Entries': [{'EventId': 'evt-1'}]}) + # Patch the EventBridge client bound on this lambda's config (setUp replaces the global singleton each test). + with patch.object(ingest_handler.config.events_client, 'put_events', mock_put_events): + event = {'Records': [{'messageId': '456', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + mock_put_events.assert_called_once() + entries = mock_put_events.call_args.kwargs['Entries'] + self.assertEqual(1, len(entries)) + home_change_entry = entries[0] + self.assertEqual( + { + 'Detail': json.dumps( + { + 'compact': 'socw', + 'jurisdiction': 'ky', + 'eventTime': '2024-11-08T23:59:59+00:00', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'licenseType': 'cosmetologist', + 'formerHomeJurisdiction': 'oh', + } + ), + 'DetailType': 'provider.homeStateChange', + 'EventBusName': 'license-data-events', + 'Source': 'org.compactconnect.provider-data', + }, + home_change_entry, + ) + + provider_data = self._get_provider_via_api(provider_id) + + self.assertEqual(2, len(provider_data['licenses'])) + oh_license = next((lic for lic in provider_data['licenses'] if lic['jurisdiction'] == 'oh'), None) + ky_license = next((lic for lic in provider_data['licenses'] if lic['jurisdiction'] == 'ky'), None) + + # Verify both licenses exist + self.assertIsNotNone(oh_license, 'Ohio license not found') + self.assertIsNotNone(ky_license, 'Kentucky license not found') + + # Verify license details + self.assertEqual('cosmetologist', oh_license['licenseType']) + self.assertEqual('A0608337260', oh_license['licenseNumber']) + self.assertEqual('2010-06-06', oh_license['dateOfIssuance']) + self.assertEqual('Björk', oh_license['givenName']) + + self.assertEqual('cosmetologist', ky_license['licenseType']) + self.assertEqual('B0608337260', ky_license['licenseNumber']) + self.assertEqual('2020-06-06', ky_license['dateOfIssuance']) + self.assertEqual('Audrey', ky_license['givenName']) + + self.assertEqual('ky', provider_data['licenseJurisdiction']) + self.assertEqual('Audrey', provider_data['givenName']) + + def test_multiple_license_types_different_jurisdictions_does_not_trigger_home_jurisdiction_change( + self, + ): + """ + In this case, we have a practitioner with two existing license types in a jurisdiction. A new license is added + from jurisdiction that is more recent than the corresponding license type in the original jurisdiction, but not + the most recent license. The home state should be determined solely by the most recently issued/renewed license, + regardless of the license type. + """ + import handlers.ingest as ingest_handler + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + # add a new license type, + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'providerId': provider_id, + 'licenseType': 'esthetician', + 'dateOfIssuance': date.fromisoformat('2024-05-06'), + 'jurisdiction': 'oh', + } + ) + # update the original to later date + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'providerId': provider_id, + 'licenseType': 'cosmetologist', + 'jurisdiction': 'oh', + 'dateOfRenewal': date.fromisoformat('2026-06-06'), + } + ) + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # Same license type in KY, with a newer issuance date then the same license in OH, + # but not the most recent renewal date. No new "home" license jurisdiction event should be issued. + message['detail'].update( + { + 'licenseType': 'esthetician', + 'jurisdiction': 'ky', + 'dateOfIssuance': '2025-06-06', + 'licenseNumber': 'B0608337260', + 'givenName': 'Audrey', + } + ) + + mock_put_events = MagicMock(return_value={'FailedEntryCount': 0, 'Entries': [{'EventId': 'evt-1'}]}) + # Patch the EventBridge client bound on this lambda's config (setUp replaces the global singleton each test). + with patch.object(ingest_handler.config.events_client, 'put_events', mock_put_events): + event = {'Records': [{'messageId': '456', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + mock_put_events.assert_not_called() + + # Verify provider record remains the same + provider_data = self._get_provider_via_api(provider_id) + self.assertEqual('oh', provider_data['licenseJurisdiction']) + self.assertEqual('Björk', provider_data['givenName']) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_investigation.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_investigation.py new file mode 100644 index 0000000000..9871ed26af --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_investigation.py @@ -0,0 +1,1232 @@ +import json +from datetime import datetime +from unittest.mock import patch +from uuid import UUID + +from cc_common.data_model.update_tier_enum import UpdateTierEnum +from common_test.test_constants import ( + DEFAULT_AA_SUBMITTING_USER_ID, + DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + DEFAULT_LICENSE_JURISDICTION, + DEFAULT_PRIVILEGE_JURISDICTION, +) +from moto import mock_aws + +from .. import TstFunction + +PRIVILEGE_INVESTIGATION_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation' +) +LICENSE_INVESTIGATION_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation' +) +PRIVILEGE_INVESTIGATION_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/privileges/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation/{investigationId}' +) +LICENSE_INVESTIGATION_ID_ENDPOINT_RESOURCE = ( + '/v1/compacts/{compact}/providers/{providerId}/licenses/' + 'jurisdiction/{jurisdiction}/licenseType/{licenseType}/investigation/{investigationId}' +) + +TEST_INVESTIGATION_START_DATE = '2023-01-15' +TEST_INVESTIGATION_CLOSE_DATE = '2023-02-15' +TEST_ENCUMBRANCE_EFFECTIVE_DATE = '2023-01-15' + + +def _generate_test_investigation_close_with_encumbrance_body(): + from cc_common.data_model.schema.common import ClinicalPrivilegeActionCategory, EncumbranceType + + return { + 'encumbrance': { + 'encumbranceEffectiveDate': TEST_ENCUMBRANCE_EFFECTIVE_DATE, + # These Enums are expected to be `str` type, so we'll directly access their .value + 'encumbranceType': EncumbranceType.SUSPENSION.value, + 'clinicalPrivilegeActionCategories': [ClinicalPrivilegeActionCategory.FRAUD.value], + }, + } + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPostPrivilegeInvestigation(TstFunction): + """Test suite for privilege investigation endpoints.""" + + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test({'socw': ['ne']}) + + def _when_testing_privilege_investigation(self): + self.test_data_generator.put_default_provider_record_in_provider_table() + license_record = self.test_data_generator.put_default_license_record_in_provider_table() + + test_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': PRIVILEGE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': license_record.compact, + 'providerId': str(license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': license_record.licenseTypeAbbreviation, + }, + }, + ) + + # return both the test event and the test privilege record + return test_event, license_record + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_privilege_investigation_handler(self, mock_publish_event): + from handlers.investigation import investigation_handler + from handlers.providers import get_provider + + event, test_license_record = self._when_testing_privilege_investigation() + + response = investigation_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'OK'}, + response_body, + ) + + # Verify that the investigation record was added to the provider data table + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + investigation_records = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + privilege_license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(investigation_records)) + investigation = investigation_records[0] + + # Verify the investigation record fields + expected_investigation = { + 'type': 'investigation', + 'compact': test_license_record.compact, + 'providerId': test_license_record.providerId, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseType, + 'investigationAgainst': 'privilege', + 'submittingUser': UUID(DEFAULT_AA_SUBMITTING_USER_ID), + 'creationDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'dateOfUpdate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'investigationId': investigation.investigationId, + } + self.assertEqual(expected_investigation, investigation.to_dict()) + + # Verify that investigation objects are included in the API response (from investigation records only) + api_event = self.test_data_generator.generate_test_api_event( + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.readGeneral', + value_overrides={ + 'httpMethod': 'GET', + 'resource': '/v1/compacts/{compact}/providers/{providerId}', + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + }, + }, + ) + + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + + # Verify that the privilege has investigation objects + privilege = provider_data['privileges'][0] + + expected_privilege = { + 'providerId': str(test_license_record.providerId), + 'investigationStatus': 'underInvestigation', + 'investigations': [ + { + 'type': 'investigation', + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseType, + 'submittingUser': DEFAULT_AA_SUBMITTING_USER_ID, + 'creationDate': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'dateOfUpdate': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationId': privilege['investigations'][0]['investigationId'], # Dynamic field + } + ], + } + + self.assertDictPartialMatch(expected_privilege, privilege) + + # Verify event was published with correct details + mock_publish_event.assert_called_once() + call_args = mock_publish_event.call_args[1] + + expected_event_args = { + 'source': 'org.compactconnect.provider-data', + 'detail_type': 'privilege.investigation', + 'event_batch_writer': None, + 'detail': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': test_license_record.licenseTypeAbbreviation, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'privilege', + 'investigationId': call_args['detail']['investigationId'], # Dynamic field + }, + } + self.assertEqual(expected_event_args, call_args) + + def test_privilege_investigation_handler_returns_access_denied_if_compact_admin(self): + """Verifying that only state admins are allowed to create privilege investigations""" + from handlers.investigation import investigation_handler + + event, test_license_record = self._when_testing_privilege_investigation() + + event['requestContext']['authorizer']['claims']['scope'] = f'openid email {test_license_record.compact}/admin' + + response = investigation_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_privilege_investigation_handler_handles_event_publishing_failure(self, mock_publish_event): + """Test that privilege investigation handler fails when event publishing fails.""" + from handlers.investigation import investigation_handler + + event, _ = self._when_testing_privilege_investigation() + mock_publish_event.side_effect = Exception('Event publishing failed') + + with self.assertRaises(Exception) as context: + investigation_handler(event, self.mock_context) + self.assertEqual('Event publishing failed', str(context.exception)) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPostLicenseInvestigation(TstFunction): + """Test suite for license investigation endpoints.""" + + def _load_license_data(self): + """Load license test data from JSON file""" + + # Load provider record first (needed for encumbrance creation) + self.test_data_generator.put_default_provider_record_in_provider_table() + license_data = self.test_data_generator.generate_default_license() + self.test_data_generator.store_record_in_provider_table(license_data.serialize_to_database_record()) + return license_data + + def _when_testing_valid_license_investigation(self, body_overrides: dict | None = None): + test_license_record = self._load_license_data() + test_body = {} + if body_overrides: + test_body.update(body_overrides) + + test_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': LICENSE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseTypeAbbreviation, + }, + 'body': json.dumps(test_body), + }, + ) + + # return both the event and test license record + return test_event, test_license_record + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_license_investigation_handler(self, mock_publish_event): + from cc_common.data_model.schema.common import InvestigationStatusEnum + from handlers.investigation import investigation_handler + from handlers.providers import get_provider + + event, test_license_record = self._when_testing_valid_license_investigation() + + response = investigation_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'OK'}, + response_body, + ) + + # Verify that the investigation record was added to the provider data table + # Perform a query to list all investigations for the provider using the starts_with key condition + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + investigation_records = provider_user_records.get_investigation_records_for_license( + license_jurisdiction=test_license_record.jurisdiction, + license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(investigation_records)) + investigation = investigation_records[0] + + # Verify the investigation record fields + expected_investigation = { + 'type': 'investigation', + 'compact': test_license_record.compact, + 'providerId': test_license_record.providerId, + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseType, + 'investigationAgainst': 'license', + 'submittingUser': UUID(DEFAULT_AA_SUBMITTING_USER_ID), + 'creationDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'dateOfUpdate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'investigationId': investigation.investigationId, + } + self.assertEqual(expected_investigation, investigation.to_dict()) + + # Verify that the license record was updated to be under investigation + updated_license_record = provider_user_records.get_license_records()[0] + + self.assertEqual(InvestigationStatusEnum.UNDER_INVESTIGATION, updated_license_record.investigationStatus) + + # Verify that investigation objects are included in the API response + api_event = self.test_data_generator.generate_test_api_event( + scope_override=f'openid email {test_license_record.jurisdiction}/socw.readGeneral', + value_overrides={ + 'httpMethod': 'GET', + 'resource': '/v1/compacts/{compact}/providers/{providerId}', + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + }, + }, + ) + + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + + # Verify that the license has investigation objects + license_obj = provider_data['licenses'][0] + investigation = license_obj['investigations'][0] + + expected_license = { + 'providerId': str(test_license_record.providerId), + 'investigationStatus': 'underInvestigation', + 'investigations': [ + { + 'type': 'investigation', + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseType, + 'submittingUser': DEFAULT_AA_SUBMITTING_USER_ID, + 'creationDate': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'dateOfUpdate': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationId': investigation['investigationId'], # Dynamic field + } + ], + } + + self.assertDictPartialMatch(expected_license, license_obj) + + # Verify event was published with correct details + mock_publish_event.assert_called_once() + call_args = mock_publish_event.call_args[1] + + expected_event_args = { + 'source': 'org.compactconnect.provider-data', + 'detail_type': 'license.investigation', + 'event_batch_writer': None, + 'detail': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseTypeAbbreviation': test_license_record.licenseTypeAbbreviation, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'license', + 'investigationId': call_args['detail']['investigationId'], # Dynamic field + }, + } + self.assertEqual(expected_event_args, call_args) + + def test_license_investigation_handler_returns_access_denied_if_compact_admin(self): + """Verifying that only state admins are allowed to create license investigations""" + from handlers.investigation import investigation_handler + + event, test_license_record = self._when_testing_valid_license_investigation() + + event['requestContext']['authorizer']['claims']['scope'] = f'openid email {test_license_record.compact}/admin' + + response = investigation_handler(event, self.mock_context) + self.assertEqual(403, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'Access denied'}, + response_body, + ) + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_license_investigation_handler_handles_event_publishing_failure(self, mock_publish_event): + """Test that license investigation handler fails when event publishing fails.""" + from handlers.investigation import investigation_handler + + event, _ = self._when_testing_valid_license_investigation() + mock_publish_event.side_effect = Exception('Event publishing failed') + + with self.assertRaises(Exception) as context: + investigation_handler(event, self.mock_context) + self.assertEqual('Event publishing failed', str(context.exception)) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPatchPrivilegeInvestigationClose(TstFunction): + """Test suite for privilege investigation close endpoints.""" + + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test({'socw': ['ne']}) + + def _when_testing_privilege_investigation_close(self, body_overrides: dict | None = None): + self.test_data_generator.put_default_provider_record_in_provider_table() + test_license_record = self.test_data_generator.put_default_license_record_in_provider_table() + test_body = {} + if body_overrides: + test_body.update(body_overrides) + + # First create an investigation + create_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': PRIVILEGE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseTypeAbbreviation, + }, + }, + ) + + from handlers.investigation import investigation_handler + + investigation_handler(create_event, self.mock_context) + + # Get the investigation ID using the data client + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + investigation_records = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + privilege_license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(investigation_records)) + investigation_id = investigation_records[0].investigationId + + # Now create the close event + test_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': PRIVILEGE_INVESTIGATION_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseTypeAbbreviation, + 'investigationId': str(investigation_id), + }, + 'body': json.dumps(test_body), + }, + ) + + return test_event, test_license_record, investigation_id + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_privilege_investigation_close_handler(self, mock_publish_event): + from handlers.investigation import investigation_handler + from handlers.providers import get_provider + + event, test_license_record, investigation_id = self._when_testing_privilege_investigation_close() + + response = investigation_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'OK'}, + response_body, + ) + + # Verify that the investigation record was updated + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + # Get all investigation records (including closed ones) + all_investigations = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + privilege_license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + filter_condition=lambda inv: inv.investigationId == investigation_id, + include_closed=True, + ) + self.assertEqual(1, len(all_investigations)) + investigation = all_investigations[0] + + expected_investigation = { + 'type': 'investigation', + 'compact': test_license_record.compact, + 'providerId': test_license_record.providerId, + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseType, + 'investigationAgainst': 'privilege', + 'investigationId': investigation_id, + 'submittingUser': UUID(DEFAULT_AA_SUBMITTING_USER_ID), + 'creationDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'closeDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'closingUser': UUID(DEFAULT_AA_SUBMITTING_USER_ID), + 'dateOfUpdate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + } + + self.assertEqual(expected_investigation, investigation.to_dict()) + + # Verify that investigation objects are removed from the API response (from investigation records only) + api_event = self.test_data_generator.generate_test_api_event( + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.readGeneral', + value_overrides={ + 'httpMethod': 'GET', + 'resource': '/v1/compacts/{compact}/providers/{providerId}', + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + }, + }, + ) + + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + + # Verify that the privilege has no investigation objects + privilege = provider_data['privileges'][0] + expected_privilege = { + 'investigations': [], + } + + self.assertEqual(expected_privilege['investigations'], privilege['investigations']) + + # Verify event was published with correct details (should be called twice: creation + closure) + self.assertEqual(2, mock_publish_event.call_count) + call_args = mock_publish_event.call_args[1] + + expected_event_args = { + 'source': 'org.compactconnect.provider-data', + 'detail_type': 'privilege.investigationClosed', + 'event_batch_writer': None, + 'detail': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseTypeAbbreviation': test_license_record.licenseTypeAbbreviation, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'privilege', + 'investigationId': call_args['detail']['investigationId'], # Dynamic field + }, + } + self.assertEqual(expected_event_args, call_args) + + def test_privilege_investigation_close_with_encumbrance_creates_encumbrance(self): + from handlers.investigation import investigation_handler + + event, test_license_record, investigation_id = self._when_testing_privilege_investigation_close( + body_overrides=_generate_test_investigation_close_with_encumbrance_body() + ) + + response = investigation_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that an encumbrance was created + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + encumbrance_records = provider_user_records.get_adverse_action_records_for_privilege( + privilege_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + privilege_license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(encumbrance_records)) + + # Verify that the investigation record has the resulting encumbrance ID + all_investigations = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + privilege_license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + filter_condition=lambda inv: inv.investigationId == investigation_id, + include_closed=True, + ) + self.assertEqual(1, len(all_investigations)) + investigation = all_investigations[0] + + self.assertIsNotNone(investigation.resultingEncumbranceId) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestMultipleSimultaneousPrivilegeInvestigations(TstFunction): + """Test suite for multiple simultaneous privilege investigations.""" + + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test( + {'socw': [DEFAULT_LICENSE_JURISDICTION, DEFAULT_PRIVILEGE_JURISDICTION]} + ) + + def _load_license_data(self): + """Load privilege test data using test data generator""" + # Load provider record first + self.test_data_generator.put_default_provider_record_in_provider_table() + return self.test_data_generator.put_default_license_record_in_provider_table() + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_closing_one_of_multiple_investigations_maintains_investigation_status(self, mock_publish_event): + """Test that closing one investigation while another is open maintains investigation status.""" + from cc_common.data_model.schema.common import InvestigationStatusEnum + from handlers.investigation import investigation_handler + from handlers.providers import get_provider + + test_license_record = self._load_license_data() + + # Create first investigation + first_investigation_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': PRIVILEGE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseTypeAbbreviation, + }, + }, + ) + + response = investigation_handler(first_investigation_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Get the first investigation ID + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + investigation_records = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + privilege_license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(investigation_records)) + first_investigation_id = investigation_records[0].investigationId + + # Create second investigation + second_investigation_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': PRIVILEGE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseTypeAbbreviation, + }, + }, + ) + + response = investigation_handler(second_investigation_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Get the second investigation ID + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + investigation_records = provider_user_records.get_investigation_records_for_privilege( + privilege_jurisdiction=DEFAULT_PRIVILEGE_JURISDICTION, + privilege_license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(2, len(investigation_records)) + second_investigation_id = [ + inv.investigationId for inv in investigation_records if inv.investigationId != first_investigation_id + ][0] + + # Close the second investigation + close_second_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': PRIVILEGE_INVESTIGATION_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseTypeAbbreviation, + 'investigationId': str(second_investigation_id), + }, + 'body': json.dumps({}), + }, + ) + + response = investigation_handler(close_second_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify that the privilege record still shows under investigation + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + updated_privilege_record = provider_user_records.generate_privileges_for_provider()[0] + + self.assertEqual( + InvestigationStatusEnum.UNDER_INVESTIGATION, + updated_privilege_record.get('investigationStatus'), + ) + + # Verify that one investigation is still visible in the API response + api_event = self.test_data_generator.generate_test_api_event( + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.readGeneral', + value_overrides={ + 'httpMethod': 'GET', + 'resource': '/v1/compacts/{compact}/providers/{providerId}', + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + }, + }, + ) + + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + privilege = provider_data['privileges'][0] + + self.assertEqual(1, len(privilege['investigations'])) + self.assertEqual(str(first_investigation_id), privilege['investigations'][0]['investigationId']) + + # Verify that investigation closed event WAS published (should be 3 calls: 2 creation + 1 closure) + self.assertEqual(3, mock_publish_event.call_count) + call_types = [call[1]['detail_type'] for call in mock_publish_event.call_args_list] + self.assertEqual(2, call_types.count('privilege.investigation')) + self.assertEqual(1, call_types.count('privilege.investigationClosed')) + + # Now close the first investigation + close_first_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {DEFAULT_PRIVILEGE_JURISDICTION}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': PRIVILEGE_INVESTIGATION_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': DEFAULT_PRIVILEGE_JURISDICTION, + 'licenseType': test_license_record.licenseTypeAbbreviation, + 'investigationId': str(first_investigation_id), + }, + 'body': json.dumps({}), + }, + ) + + response = investigation_handler(close_first_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify that the privilege record no longer has investigation status + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + updated_privilege_record = provider_user_records.generate_privileges_for_provider()[0] + + self.assertIsNone(updated_privilege_record.get('investigationStatus')) + + # Verify that there are no investigations visible in the API response + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + privilege = provider_data['privileges'][0] + + self.assertEqual(0, len(privilege['investigations'])) + + # Verify that investigation closed events were published (should be 4 calls total: 2 creation + 2 closure) + self.assertEqual(4, mock_publish_event.call_count) + call_types = [call[1]['detail_type'] for call in mock_publish_event.call_args_list] + self.assertEqual(2, call_types.count('privilege.investigation')) + self.assertEqual(2, call_types.count('privilege.investigationClosed')) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestPatchLicenseInvestigationClose(TstFunction): + """Test suite for license investigation close endpoints.""" + + def _load_license_data(self): + """Load license test data using test data generator""" + # Load provider record first (needed for encumbrance creation) + self.test_data_generator.put_default_provider_record_in_provider_table() + license_data = self.test_data_generator.generate_default_license() + self.test_data_generator.store_record_in_provider_table(license_data.serialize_to_database_record()) + return license_data + + def _when_testing_license_investigation_close(self, body_overrides: dict | None = None): + test_license_record = self._load_license_data() + test_body = {} + if body_overrides: + test_body.update(body_overrides) + + # First create an investigation + create_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': LICENSE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseTypeAbbreviation, + }, + }, + ) + + from handlers.investigation import investigation_handler + + investigation_handler(create_event, self.mock_context) + + # Get the investigation ID using the data client + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + investigation_records = provider_user_records.get_investigation_records_for_license( + license_jurisdiction=test_license_record.jurisdiction, + license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(investigation_records)) + investigation_id = investigation_records[0].investigationId + + # Now create the close event + test_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': LICENSE_INVESTIGATION_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseTypeAbbreviation, + 'investigationId': str(investigation_id), + }, + 'body': json.dumps(test_body), + }, + ) + + return test_event, test_license_record, investigation_id + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_license_investigation_close_handler(self, mock_publish_event): + from handlers.investigation import investigation_handler + from handlers.providers import get_provider + + event, test_license_record, investigation_id = self._when_testing_license_investigation_close() + + response = investigation_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + self.assertEqual( + {'message': 'OK'}, + response_body, + ) + + # Verify that the investigation record was updated + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + # Get all investigation records (including closed ones) + all_investigations = provider_user_records.get_investigation_records_for_license( + license_jurisdiction=test_license_record.jurisdiction, + license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + filter_condition=lambda inv: inv.investigationId == investigation_id, + include_closed=True, + ) + self.assertEqual(1, len(all_investigations)) + investigation = all_investigations[0] + + expected_investigation = { + 'type': 'investigation', + 'compact': test_license_record.compact, + 'providerId': test_license_record.providerId, + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseType, + 'investigationAgainst': 'license', + 'investigationId': investigation_id, + 'submittingUser': UUID(DEFAULT_AA_SUBMITTING_USER_ID), + 'creationDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'closeDate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + 'closingUser': UUID(DEFAULT_AA_SUBMITTING_USER_ID), + 'dateOfUpdate': datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP), + } + + self.assertEqual(expected_investigation, investigation.to_dict()) + + # Verify that the license record no longer has investigation status + updated_license_record = provider_user_records.get_license_records()[0] + + self.assertIsNone(updated_license_record.investigationStatus) + + # Verify that investigation objects are removed from the API response + api_event = self.test_data_generator.generate_test_api_event( + scope_override=f'openid email {test_license_record.jurisdiction}/socw.readGeneral', + value_overrides={ + 'httpMethod': 'GET', + 'resource': '/v1/compacts/{compact}/providers/{providerId}', + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + }, + }, + ) + + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + + # Verify that the license has no investigation objects + license_obj = provider_data['licenses'][0] + expected_license = { + 'investigations': [], + } + + self.assertEqual(expected_license['investigations'], license_obj['investigations']) + + # Verify event was published with correct details (should be called twice: creation + closure) + self.assertEqual(2, mock_publish_event.call_count) + call_args = mock_publish_event.call_args[1] + + expected_event_args = { + 'source': 'org.compactconnect.provider-data', + 'detail_type': 'license.investigationClosed', + 'event_batch_writer': None, + 'detail': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseTypeAbbreviation': test_license_record.licenseTypeAbbreviation, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'investigationAgainst': 'license', + 'investigationId': call_args['detail']['investigationId'], # Dynamic field + }, + } + self.assertEqual(expected_event_args, call_args) + + def test_license_investigation_close_with_encumbrance_creates_encumbrance(self): + from handlers.investigation import investigation_handler + + event, test_license_record, investigation_id = self._when_testing_license_investigation_close( + body_overrides=_generate_test_investigation_close_with_encumbrance_body() + ) + + response = investigation_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + # Verify that an encumbrance was created + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + ) + encumbrance_records = provider_user_records.get_adverse_action_records_for_license( + license_jurisdiction=test_license_record.jurisdiction, + license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(encumbrance_records)) + + # Verify that the investigation record has the resulting encumbrance ID + all_investigations = provider_user_records.get_investigation_records_for_license( + license_jurisdiction=test_license_record.jurisdiction, + license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + filter_condition=lambda inv: inv.investigationId == investigation_id, + include_closed=True, + ) + self.assertEqual(1, len(all_investigations)) + investigation = all_investigations[0] + + self.assertIsNotNone(investigation.resultingEncumbranceId) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestMultipleSimultaneousLicenseInvestigations(TstFunction): + """Test suite for multiple simultaneous license investigations.""" + + def _load_license_data(self): + """Load license test data using test data generator""" + # Load provider record first + self.test_data_generator.put_default_provider_record_in_provider_table() + license_data = self.test_data_generator.generate_default_license() + self.test_data_generator.store_record_in_provider_table(license_data.serialize_to_database_record()) + return license_data + + @patch('cc_common.event_bus_client.EventBusClient._publish_event') + def test_closing_one_of_multiple_investigations_maintains_investigation_status(self, mock_publish_event): + """Test that closing one investigation while another is open maintains investigation status.""" + from cc_common.data_model.schema.common import InvestigationStatusEnum, UpdateCategory + from handlers.investigation import investigation_handler + from handlers.providers import get_provider + + test_license_record = self._load_license_data() + + # Create first investigation + first_investigation_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': LICENSE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseTypeAbbreviation, + }, + }, + ) + + response = investigation_handler(first_investigation_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Get the first investigation ID + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + investigation_records = provider_user_records.get_investigation_records_for_license( + license_jurisdiction=test_license_record.jurisdiction, + license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(1, len(investigation_records)) + first_investigation_id = investigation_records[0].investigationId + + # Create second investigation + second_investigation_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'POST', + 'resource': LICENSE_INVESTIGATION_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseTypeAbbreviation, + }, + }, + ) + + response = investigation_handler(second_investigation_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Get the second investigation ID + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + investigation_records = provider_user_records.get_investigation_records_for_license( + license_jurisdiction=test_license_record.jurisdiction, + license_type_abbreviation=test_license_record.licenseTypeAbbreviation, + ) + self.assertEqual(2, len(investigation_records)) + second_investigation_id = [ + inv.investigationId for inv in investigation_records if inv.investigationId != first_investigation_id + ][0] + + # Close the second investigation + close_second_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': LICENSE_INVESTIGATION_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseTypeAbbreviation, + 'investigationId': str(second_investigation_id), + }, + 'body': json.dumps({}), + }, + ) + + response = investigation_handler(close_second_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify that the license record still shows under investigation + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + updated_license_record = provider_user_records.get_license_records()[0] + + self.assertEqual( + InvestigationStatusEnum.UNDER_INVESTIGATION, + updated_license_record.investigationStatus, + ) + + # Verify that one investigation is still visible in the API response + api_event = self.test_data_generator.generate_test_api_event( + scope_override=f'openid email {test_license_record.jurisdiction}/socw.readGeneral', + value_overrides={ + 'httpMethod': 'GET', + 'resource': '/v1/compacts/{compact}/providers/{providerId}', + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + }, + }, + ) + + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + license_obj = provider_data['licenses'][0] + + self.assertEqual(1, len(license_obj['investigations'])) + self.assertEqual(str(first_investigation_id), license_obj['investigations'][0]['investigationId']) + + # Verify that there are two INVESTIGATION update records + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + update_records = provider_user_records.get_update_records_for_license( + jurisdiction=test_license_record.jurisdiction, license_type=test_license_record.licenseType + ) + + investigation_update_records = [ + record for record in update_records if record.updateType == UpdateCategory.INVESTIGATION + ] + self.assertEqual(2, len(investigation_update_records)) + + # Verify that there are no CLOSING_INVESTIGATION update records + closing_update_records = [ + record for record in update_records if record.updateType == UpdateCategory.CLOSING_INVESTIGATION + ] + self.assertEqual(0, len(closing_update_records)) + + # Verify that investigation closed event WAS published (should be 3 calls: 2 creation + 1 closure) + self.assertEqual(3, mock_publish_event.call_count) + call_types = [call[1]['detail_type'] for call in mock_publish_event.call_args_list] + self.assertEqual(2, call_types.count('license.investigation')) + self.assertEqual(1, call_types.count('license.investigationClosed')) + + # Now close the first investigation + close_first_event = self.test_data_generator.generate_test_api_event( + sub_override=DEFAULT_AA_SUBMITTING_USER_ID, + scope_override=f'openid email {test_license_record.jurisdiction}/socw.admin', + value_overrides={ + 'httpMethod': 'PATCH', + 'resource': LICENSE_INVESTIGATION_ID_ENDPOINT_RESOURCE, + 'pathParameters': { + 'compact': test_license_record.compact, + 'providerId': str(test_license_record.providerId), + 'jurisdiction': test_license_record.jurisdiction, + 'licenseType': test_license_record.licenseTypeAbbreviation, + 'investigationId': str(first_investigation_id), + }, + 'body': json.dumps({}), + }, + ) + + response = investigation_handler(close_first_event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify that the license record no longer has investigation status + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + updated_license_record = provider_user_records.get_license_records()[0] + + self.assertIsNone(updated_license_record.investigationStatus) + + # Verify that there are no investigations visible in the API response + api_response = get_provider(api_event, self.mock_context) + self.assertEqual(200, api_response['statusCode']) + + provider_data = json.loads(api_response['body']) + license_obj = provider_data['licenses'][0] + + self.assertEqual(0, len(license_obj['investigations'])) + + # Verify that there are still two INVESTIGATION update records + provider_user_records = self.config.data_client.get_provider_user_records( + compact=test_license_record.compact, + provider_id=test_license_record.providerId, + include_update_tier=UpdateTierEnum.TIER_THREE, + ) + update_records = provider_user_records.get_update_records_for_license( + jurisdiction=test_license_record.jurisdiction, license_type=test_license_record.licenseType + ) + + investigation_update_records = [ + record for record in update_records if record.updateType == UpdateCategory.INVESTIGATION + ] + self.assertEqual(2, len(investigation_update_records)) + + # Verify that there is one CLOSING_INVESTIGATION update record + closing_update_records = [ + record for record in update_records if record.updateType == UpdateCategory.CLOSING_INVESTIGATION + ] + self.assertEqual(1, len(closing_update_records)) + + # Verify that investigation closed events were published (should be 4 calls total: 2 creation + 2 closure) + self.assertEqual(4, mock_publish_event.call_count) + call_types = [call[1]['detail_type'] for call in mock_publish_event.call_args_list] + self.assertEqual(2, call_types.count('license.investigation')) + self.assertEqual(2, call_types.count('license.investigationClosed')) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py new file mode 100644 index 0000000000..379695af77 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py @@ -0,0 +1,529 @@ +import json +from datetime import datetime +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from common_test.sign_request import sign_request +from moto import mock_aws + +from .. import TstFunction + +mock_flag_client = MagicMock() +mock_flag_client.return_value = True + + +@mock_aws +@patch('cc_common.feature_flag_client.is_feature_enabled', mock_flag_client) +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestLicenses(TstFunction): + def setUp(self): + super().setUp() + # Load test keys for signature authentication + with open('../common/tests/resources/client_private_key.pem') as f: + self.private_key_pem = f.read() + with open('../common/tests/resources/client_public_key.pem') as f: + self.public_key_pem = f.read() + + # Load signature public key into the compact configuration table for functional testing + self._load_signature_public_key('socw', 'oh', 'test-key-001', self.public_key_pem) + + def _load_signature_public_key(self, compact: str, jurisdiction: str, key_id: str, public_key_pem: str): + """Load a signature public key into the compact configuration table.""" + item = { + 'pk': f'{compact}#SIGNATURE_KEYS#{jurisdiction}', + 'sk': f'{compact}#JURISDICTION#{jurisdiction}#{key_id}', + 'publicKey': public_key_pem, + 'compact': compact, + 'jurisdiction': jurisdiction, + 'keyId': key_id, + 'createdAt': '2024-01-01T00:00:00Z', + } + self._compact_configuration_table.put_item(Item=item) + + def _create_signed_event(self, event: dict) -> dict: + """Add signature headers to an event for optional signature authentication.""" + from cc_common.config import config + + # Generate current timestamp and nonce + timestamp = config.current_standard_datetime.isoformat() + nonce = str(uuid4()) + key_id = 'test-key-001' + + # Sign the request + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp, + nonce=nonce, + key_id=key_id, + private_key_pem=self.private_key_pem, + ) + + # Add signature headers to event + event['headers'].update(headers) + return event + + def test_post_licenses_puts_expected_messages_on_the_queue(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + with open('../common/tests/resources/api/license-post.json') as f: + event['body'] = json.dumps([json.load(f)]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + # assert that the message was sent to the preprocessing queue + queue_messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(1, len(queue_messages)) + + expected_message = json.loads(event['body'])[0] + # add the compact, jurisdiction, and eventTime to the expected message + expected_message['compact'] = 'socw' + expected_message['jurisdiction'] = 'oh' + expected_message['eventTime'] = '2024-11-08T23:59:59+00:00' + self.assertEqual(expected_message, json.loads(queue_messages[0].body)) + + def test_post_licenses_does_not_let_request_body_override_path_parameters(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + # Test case where request body attempts to specify a different compact and jurisdiction + license_data.update({'compact': 'coun', 'jurisdiction': 'ne'}) + event['body'] = json.dumps( + [ + license_data, + ] + ) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + # assert that the message was sent to the preprocessing queue + queue_messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(1, len(queue_messages)) + + expected_message = json.loads(event['body'])[0] + # the expected compact and jurisdiction from the path parameters should not be modified + expected_message['compact'] = 'socw' + expected_message['jurisdiction'] = 'oh' + expected_message['eventTime'] = '2024-11-08T23:59:59+00:00' + self.assertEqual(expected_message, json.loads(queue_messages[0].body)) + + def test_post_licenses_invalid_license_type(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + license_data['licenseType'] = 'occupational therapist' + event['body'] = json.dumps([license_data]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + { + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': {'0': {'licenseType': ['Must be one of: cosmetologist, esthetician.']}}, + }, + json.loads(resp['body']), + ) + + def test_post_licenses_handles_invalid_json_request_body(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + # Test case where list contains strings instead of dictionaries + event['body'] = json.dumps( + [ + license_data, + ['this is totally a license'], + 'and another license', + ] + ) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + { + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': { + '1': {'INVALID_JSON_OBJECT': ['Must be a JSON object.']}, + '2': {'INVALID_JSON_OBJECT': ['Must be a JSON object.']}, + }, + }, + json.loads(resp['body']), + ) + + def test_post_licenses_handles_empty_license_object(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + # Test case where list contains strings instead of dictionaries + event['body'] = json.dumps([license_data, {}]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + { + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': { + '1': { + 'compactEligibility': ['Missing data for required field.'], + 'dateOfBirth': ['Missing data for required field.'], + 'dateOfExpiration': ['Missing data for required field.'], + 'dateOfIssuance': ['Missing data for required field.'], + 'familyName': ['Missing data for required field.'], + 'givenName': ['Missing data for required field.'], + 'homeAddressCity': ['Missing data for required field.'], + 'homeAddressPostalCode': ['Missing data for required field.'], + 'homeAddressState': ['Missing data for required field.'], + 'homeAddressStreet1': ['Missing data for required field.'], + 'licenseNumber': ['Missing data for required field.'], + 'licenseStatus': ['Missing data for required field.'], + 'licenseType': ['Missing data for required field.'], + 'ssn': ['Missing data for required field.'], + } + }, + }, + json.loads(resp['body']), + ) + + def test_post_licenses_handles_invalid_request_body_not_list(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + # Test case where request body is not a list + event['body'] = json.dumps({'message': 'hi'}) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual({'message': 'Request body must be an array of license objects'}, json.loads(resp['body'])) + + def test_post_licenses_handles_invalid_request_body_not_json(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + # Test case where request body is not deserializable + event['body'] = 'hello' + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + {'message': 'Invalid JSON: Expecting value: line 1 column 1 (char 0)'}, json.loads(resp['body']) + ) + + def test_post_licenses_handles_empty_request_body(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + # Test case where request body is not deserializable + event['body'] = None + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + {'message': 'Invalid request body'}, + json.loads(resp['body']), + ) + + def test_post_licenses_unknown_field_returns_error(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + license_data['someOtherField'] = 'foobar' + event['body'] = json.dumps([license_data]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + { + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': {'0': {'someOtherField': ['Unknown field.']}}, + }, + json.loads(resp['body']), + ) + + def test_post_licenses_null_field_returns_error(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + license_data['licenseStatusName'] = None + event['body'] = json.dumps([license_data, license_data]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + { + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': { + '0': {'licenseStatusName': ['Field may not be null.']}, + '1': {'licenseStatusName': ['Field may not be null.']}, + }, + }, + json.loads(resp['body']), + ) + + def test_post_licenses_returns_400_if_repeated_ssns_detected(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + event['body'] = json.dumps([license_data, license_data]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + self.assertEqual( + { + 'message': 'Invalid license records in request. See errors for more detail.', + 'errors': { + 'SSN': 'Same SSN for the same license type detected on multiple rows. ' + 'Every record must have a unique SSN per license type within the same request.', + }, + }, + json.loads(resp['body']), + ) + + def test_post_licenses_succeeds_with_same_ssn_different_license_types(self): + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + with open('../common/tests/resources/api/license-post.json') as f: + license_data_1 = json.load(f) + + # Create second license with same SSN but different license type + license_data_2 = license_data_1.copy() + license_data_1['licenseType'] = 'esthetician' + license_data_2['licenseType'] = 'cosmetologist' + + event['body'] = json.dumps([license_data_1, license_data_2]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + # assert that the messages were sent to the preprocessing queue + queue_messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(2, len(queue_messages)) + + def test_post_licenses_strips_whitespace_from_string_fields(self): + """Test that whitespace is stripped from all string fields in license data.""" + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + # Load base license data and add whitespace to string fields + with open('../common/tests/resources/api/license-post.json') as f: + license_data = json.load(f) + request_body = license_data.copy() + + # Add whitespace around various string fields + request_body['givenName'] = ' ' + license_data['givenName'] + ' ' + request_body['familyName'] = ' ' + license_data['familyName'] + ' ' + request_body['licenseType'] = ' ' + license_data['licenseType'] + ' ' + request_body['homeAddressStreet1'] = ' ' + license_data['homeAddressStreet1'] + ' ' + request_body['homeAddressCity'] = ' ' + license_data['homeAddressCity'] + ' ' + request_body['homeAddressState'] = ' ' + license_data['homeAddressState'] + ' ' + request_body['homeAddressPostalCode'] = ' ' + license_data['homeAddressPostalCode'] + ' ' + + # Add optional fields with whitespace + request_body['middleName'] = ' ' + license_data['middleName'] + ' ' + request_body['suffix'] = ' ' + license_data.get('suffix', 'Jr.') + ' ' + request_body['licenseNumber'] = ' ' + license_data['licenseNumber'] + ' ' + request_body['emailAddress'] = ' ' + license_data['emailAddress'] + ' ' + + event['body'] = json.dumps([request_body]) + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = post_licenses(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + + # Verify the message was sent to the preprocessing queue with trimmed data + queue_messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(1, len(queue_messages)) + + message_data = json.loads(queue_messages[0].body) + + # Verify that whitespace was stripped from all string fields + self.assertEqual(license_data['givenName'], message_data['givenName']) # Should be trimmed + self.assertEqual(license_data['familyName'], message_data['familyName']) # Should be trimmed + self.assertEqual(license_data['licenseType'], message_data['licenseType']) # Should be trimmed + self.assertEqual(license_data['homeAddressStreet1'], message_data['homeAddressStreet1']) # Should be trimmed + self.assertEqual(license_data['homeAddressCity'], message_data['homeAddressCity']) # Should be trimmed + self.assertEqual(license_data['homeAddressState'], message_data['homeAddressState']) # Should be trimmed + self.assertEqual( + license_data['homeAddressPostalCode'], message_data['homeAddressPostalCode'] + ) # Should be trimmed + self.assertEqual(license_data['middleName'], message_data['middleName']) # Should be trimmed + self.assertEqual(license_data.get('suffix', 'Jr.'), message_data['suffix']) # Should be trimmed + self.assertEqual(license_data['licenseNumber'], message_data['licenseNumber']) # Should be trimmed + self.assertEqual(license_data['emailAddress'], message_data['emailAddress']) # Should be trimmed + + def test_post_licenses_succeeds_without_signature_when_no_keys_configured(self): + """ + Test that posting licenses succeeds without signature when no signature keys are configured for the + jurisdiction. + """ + from handlers.licenses import post_licenses + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + with open('../common/tests/resources/api/license-post.json') as f: + event['body'] = json.dumps([json.load(f)]) + + # Do NOT add signature authentication headers - this should succeed when no keys are configured + # First, remove any existing signature keys for this jurisdiction + self._compact_configuration_table.delete_item( + Key={'pk': 'socw#SIGNATURE_KEYS#oh', 'sk': 'socw#JURISDICTION#oh#test-key-001'} + ) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + # assert that the message was sent to the preprocessing queue + queue_messages = self._license_preprocessing_queue.receive_messages(MaxNumberOfMessages=10) + self.assertEqual(1, len(queue_messages)) + + expected_message = json.loads(event['body'])[0] + # add the compact, jurisdiction, and eventTime to the expected message + expected_message['compact'] = 'socw' + expected_message['jurisdiction'] = 'oh' + expected_message['eventTime'] = '2024-11-08T23:59:59+00:00' + self.assertEqual(expected_message, json.loads(queue_messages[0].body)) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py new file mode 100644 index 0000000000..2bae90efa4 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py @@ -0,0 +1,350 @@ +import json +from datetime import datetime +from unittest.mock import patch +from urllib.parse import quote + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestQueryProviders(TstFunction): + def test_query_by_provider_id_sanitizes_data_even_with_read_private_permission(self): + self._load_provider_data() + + from handlers.providers import query_providers + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral socw/readPrivate' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps({'query': {'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'}}) + + resp = query_providers(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + with open('../common/tests/resources/api/provider-response.json') as f: + expected_provider = json.load(f) + + body = json.loads(resp['body']) + self.assertEqual( + { + 'providers': [expected_provider], + 'pagination': {'pageSize': 100, 'lastKey': None, 'prevLastKey': None}, + 'query': {'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'}, + }, + body, + ) + + def test_query_providers_updated_sorting(self): + from handlers.providers import query_providers + + # 20 providers with licenses in oh (two batches) + self._generate_providers(home='oh', start_serial=9999) + self._generate_providers(home='oh', start_serial=9899) + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral socw/readPrivate' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps( + {'sorting': {'key': 'dateOfUpdate'}, 'query': {'jurisdiction': 'oh'}, 'pagination': {'pageSize': 10}}, + ) + + resp = query_providers(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual(10, len(body['providers'])) + self.assertEqual({'providers', 'pagination', 'query', 'sorting'}, body.keys()) + self.assertIsInstance(body['pagination']['lastKey'], str) + # Check we're actually sorted + dates_of_update = [provider['dateOfUpdate'] for provider in body['providers']] + self.assertListEqual(sorted(dates_of_update), dates_of_update) + + def test_query_providers_family_name_sorting(self): + from handlers.providers import query_providers + + # 20 providers with licenses in oh (two batches); first 10 have challenging name characters + names = [ + ('山田', '1'), + ('後藤', '2'), + ('清水', '3'), + ('近藤', '4'), + ('Anderson', '5'), + ('Bañuelos', '6'), + ('de la Fuente', '7'), + ('Dennis', '8'), + ('Figueroa', '9'), + ('Frías', '10'), + ] + self._generate_providers(home='oh', start_serial=9999, names=names) + self._generate_providers(home='oh', start_serial=9899) + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps( + {'sorting': {'key': 'familyName'}, 'query': {'jurisdiction': 'oh'}, 'pagination': {'pageSize': 10}}, + ) + + resp = query_providers(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual(10, len(body['providers'])) + self.assertEqual({'providers', 'pagination', 'query', 'sorting'}, body.keys()) + self.assertEqual({'key': 'familyName', 'direction': 'ascending'}, body['sorting']) + self.assertIsInstance(body['pagination']['lastKey'], str) + # Check we're actually sorted + family_names = [provider['familyName'].lower() for provider in body['providers']] + self.assertListEqual(sorted(family_names, key=quote), family_names) + + def test_query_providers_by_family_name(self): + from handlers.providers import query_providers + + # 10 providers with licenses in oh, including Tess and Ted Testerly + self._generate_providers( + home='oh', + start_serial=9999, + names=(('Testerly', 'Tess'), ('Testerly', 'Ted')), + ) + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps( + { + 'sorting': {'key': 'familyName'}, + 'query': {'jurisdiction': 'oh', 'familyName': 'Testerly'}, + 'pagination': {'pageSize': 10}, + }, + ) + + resp = query_providers(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual(2, len(body['providers'])) + + def test_query_providers_given_name_only_not_allowed(self): + from handlers.providers import query_providers + + # 10 providers with licenses in oh, including Tess and Ted Testerly + self._generate_providers( + home='oh', + start_serial=9999, + names=(('Testerly', 'Tess'), ('Testerly', 'Ted')), + ) + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps( + { + 'sorting': {'key': 'familyName'}, + 'query': {'jurisdiction': 'oh', 'givenName': 'Tess'}, + 'pagination': {'pageSize': 10}, + }, + ) + + resp = query_providers(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + + def test_query_providers_default_sorting(self): + """If sorting is not specified, familyName is default""" + from handlers.providers import query_providers + + # 20 providers with licenses (10 in oh, 10 in ne) + self._generate_providers(home='oh', start_serial=9999) + self._generate_providers(home='ne', start_serial=9899) + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps({'query': {}}) + + resp = query_providers(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual(20, len(body['providers'])) + self.assertEqual({'providers', 'pagination', 'query', 'sorting'}, body.keys()) + self.assertEqual({'key': 'familyName', 'direction': 'ascending'}, body['sorting']) + self.assertIsNone(body['pagination']['lastKey']) + # Check we're actually sorted + family_names = [provider['familyName'].lower() for provider in body['providers']] + self.assertListEqual(sorted(family_names, key=quote), family_names) + + def test_query_providers_invalid_sorting(self): + from handlers.providers import query_providers + + # 20 providers with licenses (10 in oh, 10 in ne) + self._generate_providers(home='oh', start_serial=9999) + self._generate_providers(home='ne', start_serial=9899) + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps({'query': {'jurisdiction': 'oh'}, 'sorting': {'key': 'invalid'}}) + + resp = query_providers(event, self.mock_context) + + # Should reject the query, with 400 + self.assertEqual(400, resp['statusCode']) + + def test_query_providers_strips_whitespace_from_query_fields(self): + """Test that whitespace is stripped from multiple fields simultaneously.""" + from handlers.providers import query_providers + + # Create providers with known names for testing + self._generate_providers( + home='oh', + start_serial=9999, + names=(('Testerly', 'Tess'), ('Testerly', 'Ted')), + ) + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral' + event['pathParameters'] = {'compact': 'socw'} + + # Test multiple fields with whitespace + event['body'] = json.dumps( + { + 'query': { + 'givenName': ' Ted ', + 'familyName': ' Testerly ', + 'jurisdiction': ' oh ', + }, + 'pagination': {'pageSize': 10}, + } + ) + + resp = query_providers(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual(1, len(body['providers'])) # Should find Ted Testerly + found_provider = body['providers'][0] + self.assertEqual('Ted', found_provider['givenName']) + self.assertEqual('Testerly', found_provider['familyName']) + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestGetProvider(TstFunction): + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test({'socw': ['ne']}) + + @staticmethod + def _get_sensitive_hash(): + with open('../common/tests/resources/dynamo/license-update.json') as f: + sk = json.load(f)['sk'] + # The actual sensitive part is the hash at the end of the key + return sk.split('/')[-1] + + def _call_get_provider_and_return_provider_data(self, scopes: str): + from handlers.providers import get_provider + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = scopes + event['pathParameters'] = {'compact': 'socw', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} + event['queryStringParameters'] = None + + resp = get_provider(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + provider_data = json.loads(resp['body']) + # The sk for a license-update record is sensitive so we'll do an extra, pretty broad, check just to make sure + # we guard against future changes that might accidentally send the key out via the API. See discussion on + # key generation in the LicenseUpdateRecordSchema for details. + sensitive_hash = self._get_sensitive_hash() + self.assertNotIn(sensitive_hash, resp['body']) + + return provider_data + + def _when_testing_get_provider_response_based_on_read_access(self, scopes: str, expected_provider: dict): + self._load_provider_data() + + provider_data = self._call_get_provider_and_return_provider_data(scopes) + self.assertEqual(expected_provider, provider_data) + + def _when_testing_get_provider_with_read_private_access(self, scopes: str): + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + + self._when_testing_get_provider_response_based_on_read_access(scopes, expected_provider) + + def test_get_provider_with_compact_level_read_private_access(self): + self._when_testing_get_provider_with_read_private_access( + scopes='openid email socw/readGeneral socw/readPrivate', + ) + + def test_get_provider_with_matching_license_jurisdiction_level_read_private_access(self): + # test provider has a license in oh + self._when_testing_get_provider_with_read_private_access( + scopes='openid email socw/readGeneral oh/socw.readPrivate' + ) + + def test_get_provider_missing_provider_id(self): + from handlers.providers import get_provider + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has read permission for cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral' + # providerId _should_ be included in these pathParameters. We're leaving it out for this test. + event['pathParameters'] = {'compact': 'socw'} + event['queryStringParameters'] = None + + resp = get_provider(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + + def test_get_provider_returns_expected_general_response_when_caller_does_not_have_read_private_scope(self): + with open('../common/tests/resources/api/provider-detail-response.json') as f: + expected_provider = json.load(f) + expected_provider.pop('ssnLastFour') + expected_provider.pop('dateOfBirth') + + del expected_provider['licenses'][0]['ssnLastFour'] + del expected_provider['licenses'][0]['dateOfBirth'] + + self._when_testing_get_provider_response_based_on_read_access( + scopes='openid email socw/readGeneral', expected_provider=expected_provider + ) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py new file mode 100644 index 0000000000..d13de7d2fd --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py @@ -0,0 +1,193 @@ +import json +from datetime import date, datetime +from unittest.mock import patch + +from moto import mock_aws + +from .. import TstFunction + +# ProviderPublicResponseSchema + LicensePublicResponseSchema + PrivilegePublicResponseSchema +EXPECTED_PROVIDER_RESPONSE = { + 'type': 'provider', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'dateOfUpdate': '2024-07-08T23:59:59+00:00', + 'compact': 'socw', + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'licenses': [ + { + 'type': 'license', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'dateOfExpiration': '2025-04-04', + 'licenseNumber': 'A0608337260', + } + ], + 'privileges': [ + { + 'type': 'privilege', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'compact': 'socw', + 'jurisdiction': 'ne', + 'licenseJurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': '2025-04-04', + 'administratorSetStatus': 'active', + 'status': 'active', + } + ], +} + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestPublicGetProvider(TstFunction): + def setUp(self): + super().setUp() + self.set_live_compact_jurisdictions_for_test({'socw': ['ne']}) + + def test_public_get_provider_response_with_expected_fields_filtered(self): + self._load_provider_data() + + from handlers.public_lookup import public_get_provider + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # public endpoint does not have authorizer + del event['requestContext']['authorizer'] + event['pathParameters'] = {'compact': 'socw', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} + event['queryStringParameters'] = None + + resp = public_get_provider(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + provider_data = json.loads(resp['body']) + + self.assertEqual(EXPECTED_PROVIDER_RESPONSE, provider_data) + + def test_public_get_provider_response_only_returns_most_recent_licenses(self): + self._load_provider_data() + # adding another license for same license type from another state, with an older issuance and renewal date + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'dateOfIssuance': date(2019, 1, 1), + 'dateOfRenewal': date(2020, 1, 1), + 'licenseNumber': 'olderCosmLicense', + 'jurisdiction': 'az', + } + ) + + from handlers.public_lookup import public_get_provider + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # public endpoint does not have authorizer + del event['requestContext']['authorizer'] + event['pathParameters'] = {'compact': 'socw', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} + event['queryStringParameters'] = None + + resp = public_get_provider(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + provider_data = json.loads(resp['body']) + + # the older license should not be included in the response + self.assertEqual(EXPECTED_PROVIDER_RESPONSE, provider_data) + + def test_public_get_provider_response_returns_multiple_license_types(self): + self._load_provider_data() + # adding another license for same license type from another state, with an older issuance and renewal date + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'dateOfIssuance': date(2019, 1, 1), + 'dateOfRenewal': date(2020, 1, 1), + 'licenseNumber': 'olderCosmLicense', + 'jurisdiction': 'az', + } + ) + + # add two more licenses for another license type + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'licenseType': 'esthetician', + 'dateOfIssuance': date(2019, 1, 1), + 'dateOfRenewal': date(2020, 1, 1), + 'licenseNumber': 'olderEstLicense', + 'jurisdiction': 'az', + } + ) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'licenseType': 'esthetician', + 'dateOfIssuance': date(2024, 1, 1), + 'dateOfRenewal': date(2025, 1, 1), + 'jurisdiction': 'oh', + 'licenseNumber': 'mostRecentEstLicense', + 'dateOfExpiration': date(2026, 1, 1), + } + ) + + from handlers.public_lookup import public_get_provider + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # public endpoint does not have authorizer + del event['requestContext']['authorizer'] + event['pathParameters'] = {'compact': 'socw', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} + event['queryStringParameters'] = None + + resp = public_get_provider(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + provider_data = json.loads(resp['body']) + + # the older license should not be included in the response + expected_licenses = [ + { + 'type': 'license', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'dateOfExpiration': '2025-04-04', + 'licenseNumber': 'A0608337260', + }, + { + 'type': 'license', + 'compact': 'socw', + 'jurisdiction': 'oh', + 'licenseType': 'esthetician', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'dateOfExpiration': '2026-01-01', + 'licenseNumber': 'mostRecentEstLicense', + }, + ] + self.assertEqual(expected_licenses, provider_data['licenses']) + + def test_public_get_provider_missing_provider_id(self): + from handlers.public_lookup import public_get_provider + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # public endpoint does not have authorizer + del event['requestContext']['authorizer'] + # providerId _should_ be included in these pathParameters. We're leaving it out for this test. + event['pathParameters'] = {'compact': 'socw'} + event['queryStringParameters'] = None + + resp = public_get_provider(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_state_api.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_state_api.py new file mode 100644 index 0000000000..f64f1071ca --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_state_api.py @@ -0,0 +1,138 @@ +import json +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +from common_test.sign_request import sign_request +from moto import mock_aws + +from tests.function import TstFunction + + +@mock_aws +class SignatureTestBase(TstFunction): + """Base class for tests that require signature authentication setup.""" + + def setUp(self): + super().setUp() + # Load test keys for signature authentication + with open('../common/tests/resources/client_private_key.pem') as f: + self.private_key_pem = f.read() + with open('../common/tests/resources/client_public_key.pem') as f: + self.public_key_pem = f.read() + + # Load signature public keys into the compact configuration table for functional testing + self._setup_signature_keys() + + def _setup_signature_keys(self): + """Setup signature keys for testing. Override in subclasses to customize key setup.""" + # Default setup - load keys for 'socw' compact with 'oh' and 'ne' jurisdictions + self._load_signature_public_key('socw', 'oh', 'test-key-001', self.public_key_pem) + self._load_signature_public_key('socw', 'ne', 'test-key-001', self.public_key_pem) + + def _load_signature_public_key(self, compact: str, jurisdiction: str, key_id: str, public_key_pem: str): + """Load a signature public key into the compact configuration table.""" + item = { + 'pk': f'{compact}#SIGNATURE_KEYS#{jurisdiction}', + 'sk': f'{compact}#JURISDICTION#{jurisdiction}#{key_id}', + 'publicKey': public_key_pem, + 'compact': compact, + 'jurisdiction': jurisdiction, + 'keyId': key_id, + 'createdAt': '2024-01-01T00:00:00Z', + } + self._compact_configuration_table.put_item(Item=item) + + def _create_signed_event(self, event: dict) -> dict: + """Add signature headers to an event for signature authentication.""" + from cc_common.config import config + + # Generate current timestamp and nonce + timestamp = config.current_standard_datetime + nonce = str(uuid4()) + key_id = 'test-key-001' + + # Sign the request + headers = sign_request( + method=event['httpMethod'], + path=event['path'], + query_params=event.get('queryStringParameters') or {}, + timestamp=timestamp.isoformat(), + nonce=nonce, + key_id=key_id, + private_key_pem=self.private_key_pem, + ) + + # Add signature headers to event + event['headers'].update(headers) + return event + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) +class TestBulkUploadUrlHandler(SignatureTestBase): + def _setup_signature_keys(self): + """Setup signature keys for testing. Only need 'oh' jurisdiction for this test.""" + + self._load_signature_public_key('socw', 'oh', 'test-key-001', self.public_key_pem) + + def test_bulk_upload_url_handler_success(self): + """Test successful bulk upload URL generation with optional signature authentication.""" + from handlers.state_api import bulk_upload_url_handler + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + # Add signature authentication headers + event = self._create_signed_event(event) + + resp = bulk_upload_url_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertIn('upload', body) + upload = body['upload'] + self.assertIn('url', upload) + self.assertIn('fields', upload) + self.assertIn('key', upload['fields']) + self.assertIn('policy', upload['fields']) + self.assertIn('x-amz-algorithm', upload['fields']) + self.assertIn('x-amz-credential', upload['fields']) + self.assertIn('x-amz-date', upload['fields']) + self.assertIn('x-amz-signature', upload['fields']) + + # Verify the key follows the expected pattern: compact/jurisdiction/uuid + key = upload['fields']['key'] + self.assertTrue(key.startswith('socw/oh/')) + self.assertEqual(len(key.split('/')), 3) + + def test_bulk_upload_url_handler_missing_signature_rejected(self): + """ + Test that bulk upload URL generation is rejected when signature keys are configured but no signature is + provided. + """ + from handlers.state_api import bulk_upload_url_handler + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has write permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/readGeneral oh/socw.write' + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + # Do NOT add signature authentication headers - this should cause the request to be rejected + # since signature keys are configured for this compact/jurisdiction + + resp = bulk_upload_url_handler(event, self.mock_context) + + self.assertEqual(401, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertIn('message', body) + # The error message should indicate missing required signature authentication headers + self.assertIn('x-key-id', body['message'].lower()) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_license_csv_reader.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_license_csv_reader.py new file mode 100644 index 0000000000..27c488f86a --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/function/test_license_csv_reader.py @@ -0,0 +1,26 @@ +from io import TextIOWrapper +from uuid import uuid4 + +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestCSVParser(TstFunction): + def test_csv_parser(self): + from cc_common.config import logger + from cc_common.data_model.schema.license.api import LicensePostRequestSchema + from license_csv_reader import LicenseCSVReader + + # Upload our test file to mocked 'S3' then retrieve it, so we can specifically + # test our reader's ability to process data from boto3's StreamingBody + key = f'socw/oh/{uuid4().hex}' + self._bucket.upload_file('../common/tests/resources/licenses.csv', key) + stream = TextIOWrapper(self._bucket.Object(key).get()['Body'], encoding='utf-8') + + schema = LicensePostRequestSchema() + reader = LicenseCSVReader() + for license_row in reader.licenses(stream): + validated = schema.load({'compact': 'socw', 'jurisdiction': 'oh', **license_row}) + logger.debug('Read validated license', license=reader.schema.dump(validated)) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/resources/put-event.json b/backend/social-work-app/lambdas/python/provider-data-v1/tests/resources/put-event.json new file mode 100644 index 0000000000..264f4c1638 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/resources/put-event.json @@ -0,0 +1,39 @@ +{ + "Records":[ + { + "eventVersion":"2.1", + "eventSource":"aws:s3", + "awsRegion":"us-west-2", + "eventTime":"1970-01-01T00:00:00.000Z", + "eventName":"ObjectCreated:Put", + "userIdentity":{ + "principalId":"AIDAJDPLRKLG7UEXAMPLE" + }, + "requestParameters":{ + "sourceIPAddress":"127.0.0.1" + }, + "responseElements":{ + "x-amz-request-id":"C3D13FE58DE4C810", + "x-amz-id-2":"FMyUVURIY8/IgAtTv8xRjskZQpcIZ9KG4V5Wp6S7S/JRWeUWerMUE5JgHvANOjpD" + }, + "s3":{ + "s3SchemaVersion":"1.0", + "configurationId":"testConfigRule", + "bucket":{ + "name":"mybucket", + "ownerIdentity":{ + "principalId":"A3NL1KOZZKExample" + }, + "arn":"arn:aws:s3:::mybucket" + }, + "object":{ + "key":"socw/oh/abcde", + "size":1024, + "eTag":"d41d8cd98f00b204e9800998ecf8427e", + "versionId":"096fKKXTRTtl3on89fVO.nfljtsv6qko", + "sequencer":"0055AED6DCD90281E5" + } + } + } + ] +} diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/__init__.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/__init__.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_bulk_upload_unit.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_bulk_upload_unit.py new file mode 100644 index 0000000000..2829d6d746 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_bulk_upload_unit.py @@ -0,0 +1,201 @@ +import json +from datetime import UTC, datetime +from io import BytesIO +from unittest.mock import patch + +from botocore.exceptions import ClientError +from botocore.response import StreamingBody + +from tests import TstLambdas + + +class TestProcessS3Event(TstLambdas): + @patch('handlers.bulk_upload.process_bulk_upload_file', autospec=True) + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.bulk_upload.config', autospec=False) + def test_process_s3_event(self, mock_config, mock_process): + from handlers.bulk_upload import parse_bulk_upload_file + + mock_config.s3_client.get_object.response = {'Body': StreamingBody(b'foo', '3')} + + mock_process.return_value = None + + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + bucket = event['Records'][0]['s3']['bucket']['name'] + key = event['Records'][0]['s3']['object']['key'] + + parse_bulk_upload_file(event, self.mock_context) + + # Happy-path execution should always end with the object being deleted + mock_config.s3_client.delete_object.assert_called_with(Bucket=bucket, Key=key) + + # Verify that we didn't go into exception handling and put a failure event + mock_config.events_client.put_events.assert_not_called() + + @patch('handlers.bulk_upload.process_bulk_upload_file', autospec=True) + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.bulk_upload.config', autospec=False) + def test_internal_exception(self, mock_config, mock_process): + from handlers.bulk_upload import parse_bulk_upload_file + + mock_config.s3_client.get_object.response = {'Body': StreamingBody(b'foo', '3')} + + # What if we've misconfigured something, so we can't access an AWS resource? + mock_process.side_effect = ClientError( + error_response={'Error': {'Code': 'AccessDeniedError'}}, + operation_name='DoAWSThing', + ) + + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + with self.assertRaises(ClientError): + parse_bulk_upload_file(event, self.mock_context) + + # We should not delete the object, as we failed to process it + mock_config.s3_client.delete_object.assert_not_called() + + # Because this failure is our problem we won't send a failure event, which is intended + # to indicate a problem with the actual data + mock_config.events_client.put_events.assert_not_called() + + @patch('handlers.bulk_upload.process_bulk_upload_file', autospec=True) + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.bulk_upload.config', autospec=False) + def test_bad_data(self, mock_config, mock_process): + from handlers.bulk_upload import parse_bulk_upload_file + + mock_config.s3_client.get_object.response = {'Body': StreamingBody(b'foo', '3')} + mock_config.events_client.put_events.return_value = {'FailedEntryCount': 0, 'Entries': [{'EventId': '123'}]} + + # Force a UnicodeDecodeError to reuse + error = None + not_unicode = b'\x83' + try: + not_unicode.decode('utf-8') + except UnicodeDecodeError as e: + error = e + + # What if the uploaded file is not properly utf-8 encoded? + mock_process.side_effect = error + + with open('../common/tests/resources/put-event.json') as f: + event = json.load(f) + + bucket = event['Records'][0]['s3']['bucket']['name'] + key = event['Records'][0]['s3']['object']['key'] + + parse_bulk_upload_file(event, self.mock_context) + + # We should delete the object, as it contains invalid data + mock_config.s3_client.delete_object.assert_called_with(Bucket=bucket, Key=key) + + # Because this was a failure due to invalid data, we will fire a failure event + mock_config.events_client.put_events.assert_called_once() + + +class TestProcessBulkUploadFile(TstLambdas): + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.bulk_upload.send_licenses_to_preprocessing_queue') + def test_good_data(self, mock_send_licenses_to_preprocessing_queue): + from handlers.bulk_upload import process_bulk_upload_file + + # this method returns a list of any message ids that failed to send, in this test case, there are no failures + mock_send_licenses_to_preprocessing_queue.return_value = [] + + with open('../common/tests/resources/licenses.csv', 'rb') as f: + line_count = len(f.readlines()) + f.seek(0) + content_length = len(f.read()) + f.seek(0) + + stream = StreamingBody(f, content_length) + + process_bulk_upload_file( + event_time=datetime.now(tz=UTC), + body=stream, + object_key='socw/oh/1234', + compact='socw', + jurisdiction='oh', + ) + + # Collect events sent to SQS for inspection + + # There should only be successful ingest events + entries = [ + entry + for call in mock_send_licenses_to_preprocessing_queue.call_args_list + for entry in call.kwargs['licenses_data'] + ] + # Make sure we put the right number of events on the queue + self.assertEqual(line_count - 1, len(entries)) + + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.bulk_upload.config', autospec=False) + @patch('handlers.bulk_upload.send_licenses_to_preprocessing_queue') + def test_bad_data(self, mock_send_licenses_to_preprocessing_queue, mock_config): + from handlers.bulk_upload import process_bulk_upload_file + + # mock static response for the events client when we put messages on the event bus + mock_config.events_client.put_events.return_value = {'FailedEntryCount': 0, 'Entries': [{'EventId': '123'}]} + # this method returns a list of any message ids that failed to send, in this test case, there are no failures + mock_send_licenses_to_preprocessing_queue.return_value = [] + + # We'll do a little processing to mangle our CSV data a bit + with open('../common/tests/resources/licenses.csv') as f: + f.seek(0) + csv_data = [line.split(',') for line in f] + # SSN of line 3 + csv_data[2][7] = '1234' + # License type of line 5 + csv_data[4][2] = '' + + mangled_rows = [','.join(row) for row in csv_data] + mangled_data = '\n'.join(mangled_rows).encode('utf-8') + content_length = len(mangled_data) + + stream = StreamingBody(BytesIO(mangled_data), content_length) + + process_bulk_upload_file( + event_time=datetime.now(tz=UTC), + body=stream, + object_key='socw/oh/1234', + compact='socw', + jurisdiction='oh', + ) + + # Collect events put for validation failures + # There should be two failures + event_writer_entries = [ + entry for call in mock_config.events_client.put_events.call_args_list for entry in call.kwargs['Entries'] + ] + self.assertEqual( + 2, len([entry for entry in event_writer_entries if entry['DetailType'] == 'license.validation-error']) + ) + + # Make sure we're capturing _some_ valid data from the license for feedback + bad_ssn_event_details = json.loads(event_writer_entries[0]['Detail']) + self.assertIn('familyName', bad_ssn_event_details['validData'].keys()) + + bad_license_type_details = json.loads(event_writer_entries[1]['Detail']) + self.assertIn('dateOfIssuance', bad_license_type_details['validData']) + # Make sure we don't include sensitive data in these events + self.assertNotIn('ssn', bad_license_type_details['validData']) + + # Now we collect how many entries were sent to SQS + # there should be three successful messages + preprocessor_entries = [ + entry + for call in mock_send_licenses_to_preprocessing_queue.call_args_list + for entry in call.kwargs['licenses_data'] + ] + # the payload contract for these messages is covered in other tests, so we just check that the expected + # number of messages was sent + self.assertEqual(3, len(preprocessor_entries)) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_ingest.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_ingest.py new file mode 100644 index 0000000000..28f8fff4d7 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_ingest.py @@ -0,0 +1,47 @@ +import json +from unittest.mock import patch + +from tests import TstLambdas + + +class TestIngest(TstLambdas): + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.ingest.config', autospec=False) + def test_preprocess_license_ingest_removes_ssn_from_record(self, mock_config): + from handlers.ingest import preprocess_license_ingest + + test_ssn = '123-12-1234' + test_provider_id = 'test_id' + test_event_bus_name = 'test-event-bus' + + mock_config.event_bus_name = test_event_bus_name + # this method returns any license numbers that failed, so we return an empty list for this test + mock_config.data_client.get_or_create_provider_id.return_value = test_provider_id + + with open('../common/tests/resources/ingest/preprocessor-sqs-message.json') as f: + message = json.load(f) + # set fixed ssn here to ensure we are checking the expected value + message['ssn'] = test_ssn + + event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + resp = preprocess_license_ingest(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + expected_event_bus_message = json.loads(json.dumps(message)) + expected_event_bus_message.pop('ssn') + expected_event_bus_message['providerId'] = test_provider_id + expected_event_bus_message['ssnLastFour'] = '1234' + + # Because this was a failure due to invalid data, we will fire a failure event + mock_config.events_client.put_events.assert_called_once_with( + Entries=[ + { + 'Source': 'org.compactconnect.provider-data', + 'DetailType': 'license.ingest', + 'Detail': json.dumps(expected_event_bus_message), + 'EventBusName': test_event_bus_name, + } + ] + ) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_licenses.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_licenses.py new file mode 100644 index 0000000000..53b038e425 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_handlers/test_licenses.py @@ -0,0 +1,135 @@ +# ruff: noqa: ARG002 unused-argument +import json +from datetime import datetime +from unittest.mock import patch + +from cc_common.exceptions import CCInternalException + +from tests import TstLambdas + + +class TestPostLicenses(TstLambdas): + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.licenses.config', autospec=False) + @patch('handlers.licenses.send_licenses_to_preprocessing_queue') + @patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') + def test_post_licenses(self, mock_get_configured_keys, mock_send_licenses_to_preprocessing_queue, mock_config): + from handlers.licenses import post_licenses + + mock_config.current_standard_datetime = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + # this method returns any license numbers that failed, so we return an empty list for this test + mock_send_licenses_to_preprocessing_queue.return_value = [] + # Mock signature authentication to return no configured keys (allows request to proceed without signature) + mock_get_configured_keys.return_value = {} + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has scopes for oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.write' + + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + with open('../common/tests/resources/api/license-post.json') as f: + event['body'] = json.dumps([json.load(f)]) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual({'message': 'OK'}, json.loads(resp['body'])) + + # get expected sqs body from common resource file + with open('../common/tests/resources/ingest/preprocessor-sqs-message.json') as f: + expected_sqs_body = json.load(f) + # set the event time to the mock time + expected_sqs_body['eventTime'] = '2024-11-08T23:59:59+00:00' + + # Collect events put for inspection + # There should be one successful ingest event + license_data_records = mock_send_licenses_to_preprocessing_queue.call_args.kwargs['licenses_data'] + # add the event time to the record, which is performed by the common code + license_data_records[0]['eventTime'] = mock_send_licenses_to_preprocessing_queue.call_args.kwargs['event_time'] + self.assertEqual(1, len(license_data_records)) + self.assertEqual(expected_sqs_body, license_data_records[0]) + + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.licenses.config', autospec=False) + @patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') + def test_cross_compact(self, mock_get_configured_keys, mock_config): + from handlers.licenses import post_licenses + + # Mock signature authentication to return no configured keys (allows request to proceed without signature) + mock_get_configured_keys.return_value = {} + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has scopes for cosm, not octp + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.write' + + event['pathParameters'] = {'compact': 'octp', 'jurisdiction': 'oh'} + + with open('../common/tests/resources/api/license-post.json') as f: + event['body'] = json.dumps([json.load(f)]) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(403, resp['statusCode']) + + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.licenses.config', autospec=False) + @patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') + def test_wrong_jurisdiction(self, mock_get_configured_keys, mock_config): # noqa: ARG001 unused-argument + from handlers.licenses import post_licenses + + # Mock signature authentication to return no configured keys (allows request to proceed without signature) + mock_get_configured_keys.return_value = {} + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has scopes for oh, not ne + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.write' + + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'ne'} + + with open('../common/tests/resources/api/license-post.json') as f: + event['body'] = json.dumps([json.load(f)]) + + resp = post_licenses(event, self.mock_context) + + self.assertEqual(403, resp['statusCode']) + + # We can't autospec because it causes the patch to evaluate properties that look up environment variables that we + # don't intend to set for these tests. + @patch('handlers.licenses.config', autospec=False) + @patch('handlers.licenses.send_licenses_to_preprocessing_queue') + @patch('cc_common.signature_auth._get_configured_keys_for_jurisdiction') + def test_event_error(self, mock_get_configured_keys, mock_send_licenses_to_preprocessing_queue, mock_config): + """If we have trouble publishing our events to AWS EventBridge, we should + return a 500 (raise a CCInternalException). + """ + from handlers.licenses import post_licenses + + mock_config.current_standard_datetime = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + # this method returns any license numbers that failed, so we return one here + mock_send_licenses_to_preprocessing_queue.return_value = ['mock-license-number'] + # Mock signature authentication to return no configured keys (allows request to proceed without signature) + mock_get_configured_keys.return_value = {} + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has scopes for oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.write' + + event['pathParameters'] = {'compact': 'socw', 'jurisdiction': 'oh'} + + with open('../common/tests/resources/api/license-post.json') as f: + event['body'] = json.dumps([json.load(f)]) + + with self.assertRaises(CCInternalException): + post_licenses(event, self.mock_context) diff --git a/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_license_csv_reader.py b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_license_csv_reader.py new file mode 100644 index 0000000000..f900abef28 --- /dev/null +++ b/backend/social-work-app/lambdas/python/provider-data-v1/tests/unit/test_license_csv_reader.py @@ -0,0 +1,15 @@ +from tests import TstLambdas + + +class TestCSVParser(TstLambdas): + def test_csv_parser(self): + from cc_common.config import logger + from cc_common.data_model.schema.license.api import LicensePostRequestSchema + from license_csv_reader import LicenseCSVReader + + schema = LicensePostRequestSchema() + with open('../common/tests/resources/licenses.csv') as f: + reader = LicenseCSVReader() + for license_row in reader.licenses(f): + validated = schema.load({'compact': 'socw', 'jurisdiction': 'oh', **license_row}) + logger.debug('Read validated license', license_data=reader.schema.dump(validated)) diff --git a/backend/social-work-app/lambdas/python/search/custom_resource_handler.py b/backend/social-work-app/lambdas/python/search/custom_resource_handler.py new file mode 100644 index 0000000000..cb46deb960 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/custom_resource_handler.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +from abc import ABC, abstractmethod +from typing import TypedDict + +from aws_lambda_powertools.logging.lambda_context import build_lambda_context_model +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import logger + + +class CustomResourceResponse(TypedDict, total=False): + """Return body for the custom resource handler.""" + + PhysicalResourceId: str + Data: dict + NoEcho: bool + + +class CustomResourceHandler(ABC): + """Base class for custom resource migrations. + + This class provides a framework for implementing CloudFormation custom resources. + It handles the routing of CloudFormation events to appropriate methods and provides a consistent + logging pattern. + + Subclasses must implement the on_create, on_update, and on_delete methods. + + Instances of this class are callable and can be used directly as Lambda handlers. + """ + + def __init__(self, handler_name: str): + """Initialize the custom resource handler. + + :type handler_name: str + """ + self.handler_name = handler_name + + def __call__(self, event: dict, _context: LambdaContext) -> CustomResourceResponse | None: + return self._on_event(event, _context) + + def _on_event(self, event: dict, _context: LambdaContext) -> CustomResourceResponse | None: + """CloudFormation event handler using the CDK provider framework. + See: https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.custom_resources/README.html + + This method routes the event to the appropriate handler method based on the request type. + + :param event: The lambda event with properties in ResourceProperties + :type event: dict + :param _context: Lambda context + :type _context: LambdaContext + :return: Optional result from the handler method + :rtype: Optional[CustomResourceResponse] + :raises ValueError: If the request type is not supported + """ + + # @logger.inject_lambda_context doesn't work on instance methods, so we'll build the context manually + lambda_context = build_lambda_context_model(_context) + logger.structure_logs(**lambda_context.__dict__) + + logger.info(f'{self.handler_name} handler started') + + properties = event.get('ResourceProperties', {}) + request_type = event['RequestType'] + + match request_type: + case 'Create': + try: + resp = self.on_create(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} creation', exc_info=e) + raise + case 'Update': + try: + resp = self.on_update(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} update', exc_info=e) + raise + case 'Delete': + try: + resp = self.on_delete(properties) + except Exception as e: + logger.error(f'Error in {self.handler_name} delete', exc_info=e) + raise + case _: + raise ValueError(f'Unexpected request type: {request_type}') + + logger.info(f'{self.handler_name} handler complete') + return resp + + @abstractmethod + def on_create(self, properties: dict) -> CustomResourceResponse | None: + """Handle Create events. + + This method should be implemented by subclasses to perform the migration when a resource is being created. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ + + @abstractmethod + def on_update(self, properties: dict) -> CustomResourceResponse | None: + """Handle Update events. + + This method should be implemented by subclasses to perform the migration when a resource is being updated. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ + + @abstractmethod + def on_delete(self, properties: dict) -> CustomResourceResponse | None: + """Handle Delete events. + + This method should be implemented by subclasses to handle deletion of the migration. In many cases, this can + be a no-op as the migration is temporary and deletion should have no effect. + + :param properties: The ResourceProperties from the CloudFormation event + :type properties: dict + :return: Any result to be returned to CloudFormation + :rtype: Optional[CustomResourceResponse] + """ diff --git a/backend/social-work-app/lambdas/python/search/handlers/__init__.py b/backend/social-work-app/lambdas/python/search/handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/search/handlers/manage_opensearch_indices.py b/backend/social-work-app/lambdas/python/search/handlers/manage_opensearch_indices.py new file mode 100644 index 0000000000..cb9f0c6b07 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/handlers/manage_opensearch_indices.py @@ -0,0 +1,144 @@ +import time + +from cc_common.config import config, logger +from cc_common.exceptions import CCInternalException +from custom_resource_handler import CustomResourceHandler, CustomResourceResponse +from opensearch_client import INITIAL_INDEX_VERSION, OpenSearchClient + +# Readiness check configuration +# OpenSearch domains may take time to become responsive after CloudFormation reports them as created. +DOMAIN_READINESS_CHECK_INTERVAL_SECONDS = 10 +DOMAIN_READINESS_MAX_ATTEMPTS = 30 # 30 attempts * 10 seconds = 5 minutes max wait + + +class OpenSearchIndexManager(CustomResourceHandler): + """ + Custom resource handler to create OpenSearch indices for compacts. + + Creates versioned indices (e.g., compact_socw_providers_v1) with aliases + (e.g., compact_socw_providers) to enable safe blue-green migrations for + future mapping changes. Queries use the alias, allowing the underlying + index to be swapped without application changes. + See https://docs.opensearch.org/latest/im-plugin/index-alias/ + """ + + def on_create(self, properties: dict) -> CustomResourceResponse | None: + """ + Create the versioned indices and aliases on creation. + """ + logger.info( + 'Starting OpenSearch index creation', + opensearch_host=config.opensearch_host_endpoint, + ) + + # Wait for domain to become responsive + client = self._wait_for_domain_ready() + + # Get index configuration from custom resource properties + number_of_shards = int(properties['numberOfShards']) + number_of_replicas = int(properties['numberOfReplicas']) + + logger.info( + 'Index configuration', + number_of_shards=number_of_shards, + number_of_replicas=number_of_replicas, + ) + + compacts = config.compacts + for compact in compacts: + # Create versioned index name (e.g., compact_socw_providers_v1) + index_name = f'compact_{compact}_providers_{INITIAL_INDEX_VERSION}' + # Create alias name (e.g., compact_socw_providers) + alias_name = f'compact_{compact}_providers' + client.create_provider_index_with_alias( + index_name=index_name, + alias_name=alias_name, + number_of_shards=number_of_shards, + number_of_replicas=number_of_replicas, + ) + + def on_update(self, properties: dict) -> CustomResourceResponse | None: + """ + No-op on update. + """ + + def on_delete(self, _properties: dict) -> CustomResourceResponse | None: + """ + No-op on delete. + """ + + def _wait_for_domain_ready(self) -> OpenSearchClient: + """ + Wait for the OpenSearch domain to become responsive. + + Newly created OpenSearch domains may not be immediately responsive even after + CloudFormation reports them as created. This method attempts to create a client + and verify connectivity with retries before proceeding with index creation. + + :return: A connected OpenSearchClient instance + :raises CCInternalException: If the domain is not responsive after max attempts + """ + last_exception = None + + for attempt in range(1, DOMAIN_READINESS_MAX_ATTEMPTS + 1): + try: + logger.info( + 'Attempting to connect to OpenSearch domain', + attempt=attempt, + max_attempts=DOMAIN_READINESS_MAX_ATTEMPTS, + ) + client = OpenSearchClient() + # Perform a lightweight health check to verify connectivity + # This will use the client's internal retry logic + cluster_health = client.cluster_health() + logger.info( + 'Successfully connected to OpenSearch domain', + cluster_status=cluster_health.get('status'), + number_of_nodes=cluster_health.get('number_of_nodes'), + ) + return client + except CCInternalException as e: + # CCInternalException is raised by OpenSearchClient after its internal retries are exhausted + last_exception = e + if attempt < DOMAIN_READINESS_MAX_ATTEMPTS: + logger.warning( + 'Domain not yet responsive, waiting before retry', + attempt=attempt, + max_attempts=DOMAIN_READINESS_MAX_ATTEMPTS, + wait_seconds=DOMAIN_READINESS_CHECK_INTERVAL_SECONDS, + error=str(e), + ) + time.sleep(DOMAIN_READINESS_CHECK_INTERVAL_SECONDS) + else: + logger.error( + 'Domain did not become responsive within timeout', + attempts=DOMAIN_READINESS_MAX_ATTEMPTS, + error=str(e), + ) + except Exception as e: # noqa BLE001 + # Handle unexpected exceptions (e.g., connection errors during client initialization) + last_exception = e + if attempt < DOMAIN_READINESS_MAX_ATTEMPTS: + logger.warning( + 'Connection attempt failed, waiting before retry', + attempt=attempt, + max_attempts=DOMAIN_READINESS_MAX_ATTEMPTS, + wait_seconds=DOMAIN_READINESS_CHECK_INTERVAL_SECONDS, + error=str(e), + ) + time.sleep(DOMAIN_READINESS_CHECK_INTERVAL_SECONDS) + else: + logger.error( + 'Failed to connect to OpenSearch domain after max attempts', + attempts=DOMAIN_READINESS_MAX_ATTEMPTS, + error=str(e), + ) + + raise CCInternalException( + f'OpenSearch domain did not become responsive after {DOMAIN_READINESS_MAX_ATTEMPTS} attempts ' + f'({DOMAIN_READINESS_MAX_ATTEMPTS * DOMAIN_READINESS_CHECK_INTERVAL_SECONDS} seconds). ' + f'Last error: {last_exception}' + ) + + +on_event = OpenSearchIndexManager('opensearch-index-manager') diff --git a/backend/social-work-app/lambdas/python/search/handlers/populate_provider_documents.py b/backend/social-work-app/lambdas/python/search/handlers/populate_provider_documents.py new file mode 100644 index 0000000000..7c14b8200d --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/handlers/populate_provider_documents.py @@ -0,0 +1,442 @@ +""" +Lambda handler to populate OpenSearch with provider documents. + +This Lambda scans the provider table using the providerDateOfUpdate GSI, +retrieves complete provider records, sanitizes them, and bulk indexes them +into OpenSearch. + +This Lambda is intended to be invoked manually through the AWS console for +initial data population or re-indexing operations. + +The Lambda supports pagination across multiple invocations. If processing +cannot complete within 12 minutes, it will return the current compact and +last pagination key. The developer can then re-invoke the Lambda with this +output as input to continue processing. + +Example input for resumption: +{ + "startingCompact": "socw", + "startingLastKey": {"pk": "...", "sk": "..."} +} + +Optional parameters: + +- resetIndexes: If true, deletes and recreates all compact provider indexes before + indexing (uses numberOfShards / numberOfReplicas). Run during low traffic; do not + combine with resumption (startingCompact / startingLastKey) for the same run. +- numberOfShards: Primary shard count for recreated indexes (default: 1). +- numberOfReplicas: Replica shard count for recreated indexes (default: 0). + +Race Condition Consideration: +A potential race condition can occur when running this function while provider +data is being actively updated: +1. This Lambda queries the current data from DynamoDB for a provider +2. A change is made in DynamoDB for that same provider +3. The DynamoDB stream handler queries the data and indexes the change into + OpenSearch after the ~30 second delay of sitting in SQS +4. This Lambda finally indexes the stale data into OpenSearch, overwriting + the change indexed by the DynamoDB stream handler + +For this reason, it is recommended that this process be run during a period of +low traffic. Given that it is a one-time process to initially populate the +table, the risk is low and if needed, this Lambda function can be run again to +synchronize all the provider documents. + +Note that the resetIndexes parameter is intended for development environments +due to a limitation with how OpenSearch will randomly drop your data nodes if +you only have 1 in your cluster. If the OpenSearch Domain drops that node due +to network failures, aliases and indices will be lost and if the ingest pipeline +inserts records before the aliases are recreated, OpenSearch will automatically +create those indices under the alias name, but without the proper index mapping +which will break our search endpoints. This reset functionality allows devs in +test environments to reset those aliases/indices into a clean state before +populating all the provider records. +""" + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.exceptions import CCInternalException +from marshmallow import ValidationError +from opensearch_client import INITIAL_INDEX_VERSION, OpenSearchClient +from utils import generate_provider_opensearch_documents + +# Batch size for DynamoDB pagination +DYNAMODB_PAGE_SIZE = 1000 +# Batch size for OpenSearch bulk indexing (1 provider averages ~2KB, 1000 * 2KB = 2MB) +OPENSEARCH_BULK_SIZE = 1000 +# Time threshold in milliseconds - stop when less than 3 minutes remain +# This leaves a 3-minute buffer before the 15-minute Lambda timeout +TIME_THRESHOLD_MS = 60 * 3000 + + +def populate_provider_documents(event: dict, context: LambdaContext): + """ + Populate OpenSearch indices with provider documents. + + Retrieves complete provider records, sanitizes them using ProviderGeneralResponseSchema, + and bulk indexes them into the appropriate OpenSearch indices. + + If processing cannot complete within 12 minutes, the function returns pagination + information that can be passed as input to continue processing. + + :param event: Lambda event with optional parameters: + - startingCompact: The compact to start/resume processing from + - startingLastKey: The DynamoDB pagination key to resume from + - resetIndexes: If true, delete and recreate all compact indexes first + - numberOfShards: Shards for recreated indexes (default 1) + - numberOfReplicas: Replicas for recreated indexes (default 0) + :param context: Lambda context + :return: Summary of indexing operation, including pagination info if incomplete + """ + data_client = config.data_client + opensearch_client = OpenSearchClient() + + reset_indexes = bool(event.get('resetIndexes', False)) + number_of_shards = int(event.get('numberOfShards', 1)) + number_of_replicas = int(event.get('numberOfReplicas', 0)) + + if reset_indexes: + # this reset functionality is only intended for development environments + if config.environment_name == 'prod': + raise CCInternalException('resetIndexes is not supported in production environments') + logger.info( + 'resetIndexes=True: deleting and recreating all compact indexes', + number_of_shards=number_of_shards, + number_of_replicas=number_of_replicas, + ) + for compact in config.compacts: + alias_name = f'compact_{compact}_providers' + index_name = f'compact_{compact}_providers_{INITIAL_INDEX_VERSION}' + opensearch_client.delete_provider_index_with_alias(alias_name=alias_name) + opensearch_client.create_provider_index_with_alias( + index_name=index_name, + alias_name=alias_name, + number_of_shards=number_of_shards, + number_of_replicas=number_of_replicas, + ) + logger.info('Index reset complete. Proceeding with population.') + + # Get optional pagination parameters from event for resumption (normal mode) + starting_compact = event.get('startingCompact') + starting_last_key = event.get('startingLastKey') + + # Track statistics + stats = { + 'total_providers_processed': 0, + 'total_licenses_indexed': 0, + 'total_providers_failed': 0, + 'compacts_processed': [], + 'errors': [], + 'completed': True, # Will be set to False if we need to paginate + } + + # Determine which compacts to process + compacts_to_process = config.compacts + + # If resuming, skip compacts before the starting compact + if starting_compact: + if starting_compact in compacts_to_process: + start_index = compacts_to_process.index(starting_compact) + compacts_to_process = compacts_to_process[start_index:] + logger.info( + 'Resuming from compact', + starting_compact=starting_compact, + starting_last_key=starting_last_key, + ) + else: + logger.warning( + 'Starting compact not found, processing all compacts', + starting_compact=starting_compact, + ) + starting_last_key = None # Reset last key if compact not found + + for compact_index, compact in enumerate(compacts_to_process): + logger.info('Processing compact', compact=compact) + + documents_to_index = [] + compact_stats = { + 'providers_processed': 0, + 'licenses_indexed': 0, + 'providers_failed': 0, + } + + # Track pagination state + # Use starting_last_key only for the first compact being processed (resumption case). + # The starting_last_key is specific to the compact that was being processed when we timed out, + # so it's only valid for that compact (which is now the first in compacts_to_process). + # For all subsequent compacts, we start from the beginning with last_key = None. + last_key = starting_last_key if compact_index == 0 else None + # Track the key used to fetch the current batch (needed for retry on indexing failure) + batch_start_key = last_key + has_more = True + + while has_more: + # Check if we're running out of time before starting a new batch + remaining_time_ms = context.get_remaining_time_in_millis() + if remaining_time_ms < TIME_THRESHOLD_MS: + # We need to stop and return pagination info for resumption + logger.info( + 'Approaching time limit, returning pagination info', + remaining_time_ms=remaining_time_ms, + current_compact=compact, + last_key=last_key, + ) + + # Index any remaining documents before returning + try: + _index_records_and_track_stats(documents_to_index, compact, opensearch_client, compact_stats) + except CCInternalException as e: + # Indexing failed after retries, return pagination info for manual retry + return _build_error_response( + stats, + compact_stats, + compact, + batch_start_key, + str(e), + ) + + # Update stats for current compact + stats['total_providers_processed'] += compact_stats['providers_processed'] + stats['total_licenses_indexed'] += compact_stats['licenses_indexed'] + stats['total_providers_failed'] += compact_stats['providers_failed'] + if compact_stats['providers_processed'] > 0: + stats['compacts_processed'].append( + { + 'compact': compact, + **compact_stats, + } + ) + + # Return pagination info for resumption + stats['completed'] = False + stats['resumeFrom'] = { + 'startingCompact': compact, + 'startingLastKey': last_key, + } + + logger.info( + 'Returning for pagination', + total_providers_processed=stats['total_providers_processed'], + total_licenses_indexed=stats['total_licenses_indexed'], + resume_from=stats['resumeFrom'], + ) + + return stats + + # Build pagination parameters + dynamo_pagination = {'pageSize': DYNAMODB_PAGE_SIZE} + if last_key: + dynamo_pagination['lastKey'] = last_key + + # Save the key used to fetch this batch (for retry if indexing fails) + batch_start_key = last_key + + # Query providers from the GSI + result = data_client.get_providers_sorted_by_updated( + compact=compact, + scan_forward=True, + pagination=dynamo_pagination, + ) + + providers = result.get('items', []) + last_key = result.get('pagination', {}).get('lastKey') + has_more = last_key is not None + + logger.info( + 'Retrieved providers batch', + compact=compact, + batch_size=len(providers), + has_more=has_more, + ) + + # Process each provider in the batch + for provider_record in providers: + compact_stats['providers_processed'] += 1 + provider_id = provider_record.get('providerId') + + if not provider_id: + logger.warning('Provider record missing providerId', record=provider_record) + compact_stats['providers_failed'] += 1 + continue + + try: + serializable_documents = generate_provider_opensearch_documents(compact, provider_id) + documents_to_index.extend(serializable_documents) + + except ValidationError as e: + logger.warning( + 'Failed to process provider record', + provider_id=provider_id, + compact=compact, + errors=e.messages, + ) + compact_stats['providers_failed'] += 1 + continue + + # Bulk index when batch is full + if len(documents_to_index) >= OPENSEARCH_BULK_SIZE: + try: + _index_records_and_track_stats(documents_to_index, compact, opensearch_client, compact_stats) + documents_to_index = [] + except CCInternalException as e: + # Indexing failed after retries, return pagination info for manual retry + return _build_error_response( + stats, + compact_stats, + compact, + batch_start_key, + str(e), + ) + + # Index any remaining documents for this compact + if documents_to_index: + try: + _index_records_and_track_stats(documents_to_index, compact, opensearch_client, compact_stats) + except CCInternalException as e: + # Indexing failed after retries, return pagination info for manual retry + return _build_error_response( + stats, + compact_stats, + compact, + batch_start_key, + str(e), + ) + + # Update overall stats + stats['total_providers_processed'] += compact_stats['providers_processed'] + stats['total_licenses_indexed'] += compact_stats['licenses_indexed'] + stats['total_providers_failed'] += compact_stats['providers_failed'] + stats['compacts_processed'].append( + { + 'compact': compact, + **compact_stats, + } + ) + + logger.info( + 'Completed processing compact', + compact=compact, + providers_processed=compact_stats['providers_processed'], + licenses_indexed=compact_stats['licenses_indexed'], + providers_failed=compact_stats['providers_failed'], + ) + + logger.info( + 'Completed populating provider documents', + total_providers_processed=stats['total_providers_processed'], + total_licenses_indexed=stats['total_licenses_indexed'], + total_providers_failed=stats['total_providers_failed'], + ) + + return stats + + +def _index_records_and_track_stats( + documents_to_index: list[dict], compact: str, opensearch_client: OpenSearchClient, compact_stats: dict +): + index_name = f'compact_{compact}_providers' + if documents_to_index: + failed_ids = _bulk_index_documents(opensearch_client, index_name, documents_to_index) + compact_stats['licenses_indexed'] += len(documents_to_index) - len(failed_ids) + if failed_ids: + compact_stats['providers_failed'] += len(failed_ids) + logger.warning( + 'Some documents failed to index in batch', + compact=compact, + failed_count=len(failed_ids), + failed_document_ids=list(failed_ids), + ) + + +def _build_error_response( + stats: dict, compact_stats: dict, compact: str, batch_start_key: dict | None, error_message: str +) -> dict: + """ + Build an error response with pagination info for retry after indexing failure. + + :param stats: The overall statistics dictionary + :param compact_stats: The current compact's statistics + :param compact: The compact being processed when the error occurred + :param batch_start_key: The pagination key used to fetch the batch that failed to index + :param error_message: The error message from the failed indexing attempt + :return: Response dictionary with error info and pagination for retry + """ + logger.error( + 'Bulk indexing failed after retries, returning pagination info for retry', + compact=compact, + batch_start_key=batch_start_key, + error=error_message, + ) + + # Update stats for current compact + stats['total_providers_processed'] += compact_stats['providers_processed'] + stats['total_licenses_indexed'] += compact_stats['licenses_indexed'] + stats['total_providers_failed'] += compact_stats['providers_failed'] + if compact_stats['providers_processed'] > 0: + stats['compacts_processed'].append( + { + 'compact': compact, + **compact_stats, + } + ) + + # Return pagination info for retry - use batch_start_key so the failed batch is re-fetched + stats['completed'] = False + stats['resumeFrom'] = { + 'startingCompact': compact, + 'startingLastKey': batch_start_key, + } + stats['errors'].append( + { + 'compact': compact, + 'error': error_message, + } + ) + + return stats + + +def _bulk_index_documents(opensearch_client: OpenSearchClient, index_name: str, documents: list[dict]) -> set[str]: + """ + Bulk index documents into OpenSearch. + + :param opensearch_client: The OpenSearch client + :param index_name: The index to write to + :param documents: List of documents to index + :return: Set of failed document IDs (empty set if all documents succeeded) + :raises CCInternalException: If bulk indexing fails after max retry attempts + """ + if not documents: + return set() + + # This will raise CCInternalException if all retries fail + response = opensearch_client.bulk_index(index_name=index_name, documents=documents, id_field='documentId') + + # Check for errors in the bulk response (individual document failures, not connection issues) + if response.get('errors'): + failed_ids = set() + for item in response.get('items', []): + index_result = item.get('index', {}) + if index_result.get('error'): + doc_id = index_result.get('_id') + failed_ids.add(doc_id) + logger.warning( + 'Bulk index item error', + document_id=doc_id, + error=index_result.get('error'), + ) + logger.warning( + 'Bulk index completed with errors', + index_name=index_name, + total_documents=len(documents), + error_count=len(failed_ids), + failed_document_ids=list(failed_ids), + ) + return failed_ids + + logger.info( + 'Indexed documents', + index_name=index_name, + document_count=len(documents), + ) + return set() diff --git a/backend/social-work-app/lambdas/python/search/handlers/provider_update_ingest.py b/backend/social-work-app/lambdas/python/search/handlers/provider_update_ingest.py new file mode 100644 index 0000000000..cec5f36a75 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/handlers/provider_update_ingest.py @@ -0,0 +1,255 @@ +""" +Lambda handler to process SQS messages containing DynamoDB stream events and index +provider documents into OpenSearch. + +This Lambda is triggered by SQS (via EventBridge Pipe from DynamoDB streams) from +the provider table. It processes events in batches, deduplicates provider IDs by +compact, and bulk indexes the sanitized provider documents into the appropriate +OpenSearch indices. + +The handler classifies events by their DynamoDB eventName: +- INSERT/MODIFY: Generate one document per license and upsert via composite documentId +- REMOVE: Delete all documents for the provider, then re-check DynamoDB and re-index + any remaining license documents + +The handler uses the @sqs_batch_handler decorator which passes all SQS messages +to the handler at once, enabling batch processing and deduplication. The handler +returns batchItemFailures directly for partial success handling. +""" + +from boto3.dynamodb.types import TypeDeserializer +from cc_common.config import config, logger +from cc_common.exceptions import CCInternalException, CCNotFoundException +from cc_common.utils import sqs_batch_handler +from marshmallow import ValidationError +from opensearch_client import OpenSearchClient +from utils import generate_provider_opensearch_documents + +# Instantiate the OpenSearch client outside of the handler to cache connection between invocations +opensearch_client = OpenSearchClient(timeout=30) + + +@sqs_batch_handler +def provider_update_ingest_handler(records: list[dict]) -> dict: + """ + Process DynamoDB stream events from SQS and index provider documents into OpenSearch. + + This function: + 1. Classifies events by eventName (REMOVE vs INSERT/MODIFY) + 2. Deduplicates provider IDs per compact + 3. For INSERT/MODIFY: generates one document per license and bulk upserts + 4. For REMOVE: deletes all docs for the provider, re-checks DynamoDB, re-indexes remaining + + :param records: List of SQS records, each containing 'messageId' and 'body' (DynamoDB stream record) + :return: Response with batch item failures for partial success handling + """ + if not records: + logger.info('No records to process') + return {'batchItemFailures': []} + + logger.info('Processing SQS batch with DynamoDB stream records', record_count=len(records)) + + # Track providers to update and delete separately per compact + providers_to_update: dict[str, set[str]] = {compact: set() for compact in config.compacts} + providers_to_delete: dict[str, set[str]] = {compact: set() for compact in config.compacts} + + # Track which message IDs correspond to which compact/provider for failure reporting + record_mapping: dict[str, tuple[str, str]] = {} # message_id -> (compact, provider_id) + + for record in records: + message_id = record['messageId'] + # The body contains the DynamoDB stream record sent via EventBridge Pipe + stream_record = record['body'] + + # Try to get the data from NewImage first, fall back to OldImage for deletes + image = stream_record.get('dynamodb', {}).get('NewImage') or stream_record.get('dynamodb', {}).get('OldImage') + + if not image: + logger.error('Record has no image data', message_id=message_id) + continue + + # Extract compact and providerId from the DynamoDB image + deserialized_image = TypeDeserializer().deserialize(value={'M': image}) + compact = deserialized_image.get('compact') + provider_id = deserialized_image.get('providerId') + record_type = deserialized_image.get('type') + + if not compact or not provider_id: + logger.error( + 'Record missing required fields', + record_type=record_type, + message_id=message_id, + ) + continue + + if compact not in providers_to_update: + logger.warning('Unknown compact in record', compact=compact, provider_id=provider_id) + continue + + record_mapping[message_id] = (compact, provider_id) + + is_remove_event = stream_record.get('eventName') == 'REMOVE' + if is_remove_event: + providers_to_delete[compact].add(provider_id) + else: + providers_to_update[compact].add(provider_id) + + batch_item_failures = [] + failed_providers: dict[str, set] = {compact: set() for compact in config.compacts} + + # --- Process INSERT/MODIFY events --- + for compact, provider_ids in providers_to_update.items(): + # Exclude providers that are also in the delete set (REMOVE takes precedence) + provider_ids = provider_ids - providers_to_delete[compact] + + if not provider_ids: + continue + + index_name = f'compact_{compact}_providers' + logger.info('Processing providers for update', compact=compact, provider_count=len(provider_ids)) + + documents_to_index = [] + + for provider_id in provider_ids: + try: + docs = generate_provider_opensearch_documents(compact, provider_id) + documents_to_index.extend(docs) + except CCNotFoundException as e: + logger.warning( + 'No provider records found. This may occur if a license upload rollback was performed or if records' + ' were manually deleted. Will delete provider document from index.', + provider_id=provider_id, + compact=compact, + error=str(e), + ) + providers_to_delete[compact].add(provider_id) + except ValidationError as e: + logger.warning( + 'Failed to process provider for indexing', + provider_id=provider_id, + compact=compact, + error=str(e), + ) + failed_providers[compact].add(provider_id) + + if documents_to_index: + try: + response = opensearch_client.bulk_index( + index_name=index_name, documents=documents_to_index, id_field='documentId' + ) + + if response.get('errors'): + for item in response.get('items', []): + index_result = item.get('index', {}) + if index_result.get('error'): + doc_id = index_result.get('_id', '') + provider_id = doc_id.split('#')[0] if '#' in doc_id else doc_id + logger.error( + 'Document indexing failed', + document_id=doc_id, + provider_id=provider_id, + error=index_result.get('error'), + ) + failed_providers[compact].add(provider_id) + + logger.info( + 'Bulk indexed documents', + index_name=index_name, + document_count=len(documents_to_index), + had_errors=response.get('errors', False), + ) + except CCInternalException as e: + # All documents for this compact failed to index + logger.error( + 'Failed to bulk index documents after retries', + index_name=index_name, + document_count=len(documents_to_index), + error=str(e), + ) + for doc in documents_to_index: + failed_providers[compact].add(doc['providerId']) + + # --- Process REMOVE events --- + for compact, provider_ids in providers_to_delete.items(): + if not provider_ids: + continue + + index_name = f'compact_{compact}_providers' + logger.info('Processing providers for delete', compact=compact, provider_count=len(provider_ids)) + + for provider_id in provider_ids: + try: + result = opensearch_client.delete_provider_documents( + index_name=index_name, + provider_id=provider_id, + ) + logger.info( + 'Deleted provider documents from index', + index_name=index_name, + provider_id=provider_id, + deleted_count=result.get('deleted', 0), + ) + except CCInternalException as e: + logger.error( + 'Failed to delete provider documents from index', + index_name=index_name, + provider_id=provider_id, + error=str(e), + ) + failed_providers[compact].add(provider_id) + continue + + # Re-check DynamoDB -- the REMOVE may have been for a single record while + # the provider still has other records remaining. + try: + docs = generate_provider_opensearch_documents(compact, provider_id) + if docs: + response = opensearch_client.bulk_index( + index_name=index_name, documents=docs, id_field='documentId' + ) + logger.info( + 'Re-indexed remaining documents after delete', + index_name=index_name, + provider_id=provider_id, + document_count=len(docs), + ) + if response.get('errors'): + for item in response.get('items', []): + index_result = item.get('index', {}) + if index_result.get('error'): + logger.error( + 'Document re-indexing failed after delete', + document_id=index_result.get('_id'), + error=index_result.get('error'), + ) + failed_providers[compact].add(provider_id) + except CCNotFoundException: + logger.info( + 'Provider no longer exists after REMOVE event, delete is complete', + provider_id=provider_id, + compact=compact, + ) + except CCInternalException as e: + logger.error( + 'Failed to re-index remaining documents after delete', + index_name=index_name, + provider_id=provider_id, + error=str(e), + ) + failed_providers[compact].add(provider_id) + + # Build batch item failures response + for message_id, (compact, provider_id) in record_mapping.items(): + if provider_id in failed_providers[compact]: + logger.info( + 'Returning message id in batch item failures for failed provider', + compact=compact, + provider_id=provider_id, + message_id=message_id, + ) + batch_item_failures.append({'itemIdentifier': message_id}) + + if batch_item_failures: + logger.warning('Reporting batch item failures', failure_count=len(batch_item_failures)) + + return {'batchItemFailures': batch_item_failures} diff --git a/backend/social-work-app/lambdas/python/search/handlers/public_search.py b/backend/social-work-app/lambdas/python/search/handlers/public_search.py new file mode 100644 index 0000000000..e2f0761780 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/handlers/public_search.py @@ -0,0 +1,291 @@ +import json +from base64 import b64decode, b64encode + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.common import CompactEligibilityStatus +from cc_common.data_model.schema.provider.api import ( + ProviderOpenSearchDocumentSchema, + PublicLicenseSearchResponseSchema, + PublicQueryProvidersRequestSchema, +) +from cc_common.exceptions import CCInvalidRequestException +from cc_common.utils import api_handler +from marshmallow import ValidationError +from opensearch_client import OpenSearchClient + +# Instantiate the OpenSearch client outside the handler to cache the connection between invocations +# Set timeout to 20 seconds to give API gateway time to respond with response +opensearch_client = OpenSearchClient(timeout=20) + + +@api_handler +def public_search_api_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Public query providers endpoint (no auth). + Translates structured query (licenseNumber, familyName, givenName, jurisdiction) into OpenSearch + nested query and returns license-level results with existing pagination schema. + + Indexing is one OpenSearch document per license; each hit maps to one license row. + """ + http_method = event.get('httpMethod') + resource_path = event.get('resource') + if (http_method, resource_path) != ('POST', '/v1/public/compacts/{compact}/providers/query'): + raise CCInvalidRequestException(f'Unsupported method or resource: {http_method} {resource_path}') + + return _public_query_licenses(event, context) + + +def _unlifted_adverse_action_found(adverse_actions: list[dict]) -> bool: + for aa in adverse_actions: + if not aa.get('effectiveLiftDate'): + return True + return False + + +def _provider_has_unlifted_adverse_actions_associated_with_license( + license_row: dict, license_privileges: list[dict] +) -> bool: + # A home state license is determined to be restricted + # if there is an unlifted encumbrance on the license or + # any of the privileges associated with the license + if _unlifted_adverse_action_found(license_row.get('adverseActions')): + return True + for privilege in license_privileges: + if _unlifted_adverse_action_found(privilege.get('adverseActions')): + return True + return False + + +def _determine_license_eligibility(*, provider_source: dict) -> str: + """ + Derive public licenseEligibility from the full provider OpenSearch document. + + Each indexed document contains exactly one license. Ineligible if that license's compactEligibility is + ineligible, or if any adverse action on that license or on a privilege in the same document lacks + effectiveLiftDate. + """ + schema = ProviderOpenSearchDocumentSchema() + try: + loaded_provider = schema.load(provider_source) + except ValidationError as e: + logger.error( + 'Failed to load provider document for eligibility', + provider_id=provider_source.get('providerId'), + errors=e.messages, + ) + return CompactEligibilityStatus.INELIGIBLE.value + + licenses_list = loaded_provider.get('licenses') or [] + if not licenses_list: + logger.warning( + 'Loaded provider has no licenses for eligibility', + provider_id=loaded_provider.get('providerId'), + ) + return CompactEligibilityStatus.INELIGIBLE.value + + license_row = licenses_list[0] + if license_row.get('compactEligibility') == CompactEligibilityStatus.INELIGIBLE.value: + return CompactEligibilityStatus.INELIGIBLE.value + + if _provider_has_unlifted_adverse_actions_associated_with_license(license_row, loaded_provider['privileges']): + return CompactEligibilityStatus.INELIGIBLE.value + + return CompactEligibilityStatus.ELIGIBLE.value + + +def _public_query_licenses(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + path_params = event.get('pathParameters') or {} + compact_raw = path_params.get('compact') + compact = _normalize_and_validate_compact_path_parameter(compact_raw) + + body = _parse_and_validate_public_query_body(event) + query_obj = body.get('query', {}) + pagination = body.get('pagination') or {} + page_size = pagination.get('pageSize') or config.default_page_size + + cursor = _decode_public_cursor(pagination.get('lastKey')) + search_body = _build_public_license_search_body(compact=compact, body=body, cursor=cursor) + index_name = f'compact_{compact}_providers' + + logger.info('Executing public license search', compact=compact, index_name=index_name) + response = opensearch_client.search(index_name=index_name, body=search_body) + + hits = response.get('hits', {}).get('hits', []) + license_schema = PublicLicenseSearchResponseSchema() + providers = [] + + for hit in hits: + source = hit.get('_source', {}) + provider_id = source.get('providerId') + if source.get('compact') != compact: + logger.warning( + 'Provider compact does not match path, skipping', + provider_id=provider_id, + path_compact=compact, + ) + continue + + try: + licenses = source.get('licenses') or [] + if not licenses: + logger.warning('OpenSearch hit has no licenses array', provider_id=provider_id) + continue + + license_fields = licenses[0].copy() + license_fields['providerId'] = source['providerId'] + license_fields['compact'] = source['compact'] + license_fields['givenName'] = source['givenName'] + license_fields['familyName'] = source['familyName'] + license_fields['licenseEligibility'] = _determine_license_eligibility(provider_source=source) + + # home state is stored under the 'jurisdiction' field on the license record, but + # the frontend expects this to be labeled 'licenseJurisdiction' for parity with other + # public search response schemas. + license_fields['licenseJurisdiction'] = license_fields.pop('jurisdiction', None) + sanitized = license_schema.load(license_fields) + providers.append(sanitized) + except ValidationError as e: + logger.error( + 'Failed to sanitize license record', + provider_id=provider_id, + errors=e.messages, + ) + + last_sort = hits[-1].get('sort') if hits else None + # Full page from OpenSearch => may have more results; use last hit's sort values for search_after + last_key = None + if last_sort is not None and len(hits) >= page_size: + last_key = _encode_public_cursor(last_sort) + + sorting = body.get('sorting') or {} + resolved_sort_key = sorting.get('key') or 'familyName' + resolved_direction = sorting.get('direction') or 'ascending' + + return { + 'providers': providers, + 'pagination': { + 'pageSize': page_size, + 'lastKey': last_key, + 'prevLastKey': pagination.get('lastKey'), + }, + 'query': query_obj, + 'sorting': {'key': resolved_sort_key, 'direction': resolved_direction}, + } + + +def _normalize_and_validate_compact_path_parameter(compact_raw: str | None) -> str: + """ + Strip and case-fold path compact for lookup; return the canonical value from config.compacts. + Rejects missing or unsupported values at the API boundary (400). + """ + if compact_raw is None or not str(compact_raw).strip(): + raise CCInvalidRequestException('Invalid or missing compact') + standardized_compact = str(compact_raw).lower().strip() + if standardized_compact not in config.compacts: + logger.info('Invalid compact provided in path parameter.', compact_path_parameter=standardized_compact) + raise CCInvalidRequestException('Unsupported compact') + return standardized_compact + + +def _parse_and_validate_public_query_body(event: dict) -> dict: + try: + schema = PublicQueryProvidersRequestSchema() + raw_body = event.get('body') or '{}' + body = schema.loads(raw_body) + except ValidationError as e: + logger.warning('Invalid public query request body', errors=e.messages) + raise CCInvalidRequestException(f'Invalid request: {e.messages}') from e + + return body + + +def _decode_public_cursor(last_key: str | None) -> dict | None: + """ + Decode and validate the public cursor (base64 JSON with search_after list). + Raises CCInvalidRequestException if lastKey is present but invalid. + """ + if not last_key: + return None + try: + decoded = json.loads(b64decode(last_key).decode('utf-8')) + except Exception as e: + raise CCInvalidRequestException('Invalid lastKey') from e + if not isinstance(decoded, dict): + raise CCInvalidRequestException('Invalid lastKey') + search_after = decoded.get('search_after') + if not isinstance(search_after, list) or len(search_after) == 0: + raise CCInvalidRequestException('Invalid lastKey') + return {'search_after': search_after} + + +def _encode_public_cursor(search_after: list) -> str: + payload = {'search_after': search_after} + return b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') + + +def _build_public_opensearch_sort(body: dict) -> list: + """ + Map API sorting (familyName | dateOfUpdate, ascending | descending) to OpenSearch sort clauses. + Uses top-level dateOfUpdate for date sort; _id ascending is always the final tiebreaker. + """ + sorting = body.get('sorting') or {} + sort_key = sorting.get('key') or 'familyName' + sort_direction = sorting.get('direction', 'ascending') + os_dir = 'asc' if sort_direction == 'ascending' else 'desc' + + match sort_key: + case 'familyName': + return [ + # we use nested sorting for familyName and givenName because the top level field is associated + # with the most recent issued license record, which if multiple licenses are issued for the same + # provider, the familyName and givenName may be different between the licenses. + {'licenses.familyName.keyword': {'order': os_dir, 'nested': {'path': 'licenses'}}}, + {'licenses.givenName.keyword': {'order': os_dir, 'nested': {'path': 'licenses'}}}, + {'providerId': os_dir}, + {'_id': 'asc'}, + ] + case 'dateOfUpdate': + return [ + {'dateOfUpdate': os_dir}, + {'_id': 'asc'}, + ] + case _: + raise CCInvalidRequestException(f"Invalid sort key: '{sort_key}'") + + +def _build_public_license_search_body(*, compact: str, body: dict, cursor: dict | None = None) -> dict: + query_obj = body.get('query', {}) + pagination = body.get('pagination') or {} + page_size = pagination.get('pageSize') or config.default_page_size + + search_after = cursor.get('search_after') if cursor else None + + # For public search, we only match against licenses that are most recent + # for its license type. This value is set when the document is indexed into OpenSearch. + nested_must: list[dict] = [{'term': {'licenses.mostRecentLicenseForType': True}}] + if query_obj.get('licenseNumber'): + nested_must.append({'term': {'licenses.licenseNumber': query_obj['licenseNumber']}}) + if query_obj.get('jurisdiction'): + nested_must.append({'term': {'licenses.jurisdiction': query_obj['jurisdiction'].lower()}}) + if query_obj.get('familyName'): + nested_must.append({'match': {'licenses.familyName': query_obj['familyName']}}) + if query_obj.get('givenName'): + nested_must.append({'match': {'licenses.givenName': query_obj['givenName']}}) + + nested_query = {'nested': {'path': 'licenses', 'query': {'bool': {'must': nested_must}}}} + + must = [ + {'term': {'compact': compact}}, + nested_query, + ] + + search_body = { + 'query': {'bool': {'must': must}}, + 'size': page_size, + 'sort': _build_public_opensearch_sort(body), + } + if search_after is not None: + search_body['search_after'] = search_after + + return search_body diff --git a/backend/social-work-app/lambdas/python/search/handlers/search.py b/backend/social-work-app/lambdas/python/search/handlers/search.py new file mode 100644 index 0000000000..9fdfa08236 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/handlers/search.py @@ -0,0 +1,258 @@ +from re import match + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import logger +from cc_common.data_model.schema.common import CCPermissionsAction +from cc_common.data_model.schema.provider.api import ( + ProviderGeneralResponseSchema, + SearchProvidersRequestSchema, +) +from cc_common.exceptions import CCInvalidRequestException +from cc_common.utils import api_handler, authorize_compact_level_only_action, get_event_scopes +from marshmallow import ValidationError +from opensearch_client import OpenSearchClient + +# Default and maximum page sizes for search results +MAX_PROVIDER_PAGE_SIZE = 100 + + +# Instantiate the OpenSearch client outside of the handler to cache connection between invocations +# Set timeout to 20 seconds to give API gateway time to respond with response +opensearch_client = OpenSearchClient(timeout=20) + + +@api_handler +@authorize_compact_level_only_action(action=CCPermissionsAction.READ_GENERAL) +def search_api_handler(event: dict, context: LambdaContext): + """ + Main entry point for search API. + Routes to the appropriate handler based on the HTTP method and resource path. + + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param context: Lambda context + """ + # Extract the HTTP method and resource path + http_method = event.get('httpMethod') + resource_path = event.get('resource') + + # Route to the appropriate handler + api_method = (http_method, resource_path) + match api_method: + case ('POST', '/v1/compacts/{compact}/providers/search'): + return _search_providers(event, context) + + # If we get here, the method/resource combination is not supported + raise CCInvalidRequestException(f'Unsupported method or resource: {http_method} {resource_path}') + + +def _search_providers(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Search providers using OpenSearch. + + This endpoint accepts an OpenSearch DSL query body and returns sanitized provider records. + Pagination follows OpenSearch DSL using `from`/`size` or `search_after` with `sort`. + + See: https://docs.opensearch.org/latest/search-plugins/searching-data/paginate/ + + :param event: Standard API Gateway event, API schema documented in the CDK ApiStack + :param LambdaContext context: + :return: Dictionary with providers array and pagination metadata + """ + compact = event['pathParameters']['compact'] + + # Parse and validate the request body using the schema + body = _parse_and_validate_request_body(event) + + # If the request body references dateOfBirth (e.g. in query or sort), verify readPrivate permission + _validate_date_of_birth_permission(body, compact, get_event_scopes(event)) + + # Build the OpenSearch search body + search_body = _build_opensearch_search_body(body, size_override=MAX_PROVIDER_PAGE_SIZE) + + # Build the index name for this compact + index_name = f'compact_{compact}_providers' + + logger.info('Executing OpenSearch provider search', compact=compact, index_name=index_name) + + # Execute the search + response = opensearch_client.search(index_name=index_name, body=search_body) + + # Extract hits from the response + hits_data = response.get('hits', {}) + hits = hits_data.get('hits', []) + total = hits_data.get('total', {}) + + # Sanitize the provider records using ProviderGeneralResponseSchema + general_schema = ProviderGeneralResponseSchema() + sanitized_providers = [] + last_sort = None + + for hit in hits: + source = hit.get('_source', {}) + try: + sanitized_provider = general_schema.load(source) + # Verify compact matches path parameter + if sanitized_provider.get('compact') != compact: + logger.error( + 'Provider compact field does not match path parameter', + # This case is most likely the result of abuse or misconfiguration. + # We log the request body for triaging purposes. We redact the leaf values + # from the request body to obscure PII. + request_body=_redact_leaf_values(body), + provider_id=source.get('providerId'), + provider_compact=sanitized_provider.get('compact'), + path_compact=compact, + ) + # do not include the provider in the results + total['value'] -= 1 + continue + sanitized_providers.append(sanitized_provider) + # Track the sort values from the last hit for search_after pagination + last_sort = hit.get('sort') + except ValidationError as e: + # Log the error but continue processing other records + logger.error( + 'Failed to sanitize provider record', + provider_id=source.get('providerId'), + errors=e.messages, + ) + + # Build response + response_body = { + 'providers': sanitized_providers, + 'total': total, + } + + # Include sort values from last hit to enable search_after pagination + if last_sort is not None: + response_body['lastSort'] = last_sort + + return response_body + + +def _parse_and_validate_request_body(event: dict) -> dict: + """ + Parse and validate the request body using the SearchProvidersRequestSchema. + + :param event: API Gateway event + :return: Validated request body + :raises CCInvalidRequestException: If the request body is invalid + """ + try: + schema = SearchProvidersRequestSchema() + return schema.loads(event.get('body', '{}')) + except ValidationError as e: + logger.warning('Invalid request body', errors=e.messages) + raise CCInvalidRequestException(f'Invalid request: {e.messages}') from e + + +def _redact_leaf_values(data: dict | list | str | int | bool | None) -> dict | list | str: + """ + Recursively redact all leaf field values in a data structure. + + This function preserves the structure of nested dictionaries + and lists while replacing all leaf values with "". + + :param data: The data structure to redact (dict, list, or primitive value) + :return: A copy of the data structure with all leaf values redacted + """ + if isinstance(data, dict): + return {key: _redact_leaf_values(value) for key, value in data.items()} + if isinstance(data, list): + return [_redact_leaf_values(item) for item in data] + + # Primitive value (str, int, float, bool, None) - this is a leaf, redact it + return '' + + +def _build_opensearch_search_body(body: dict, size_override: int) -> dict: + """ + Build the OpenSearch search body from the validated request. + + :param body: Validated request body + :return: OpenSearch search body + :raises CCInvalidRequestException: If search_after is used without sort + """ + search_body = { + 'query': body['query'], + } + + # Add pagination parameters following OpenSearch DSL + # 'from_' in Python maps to 'from' in the JSON (due to data_key in schema) + from_param = body.get('from_') + if from_param is not None: + search_body['from'] = from_param + + search_body['size'] = body.get('size', size_override) + + # Add sort if provided - required for search_after pagination + sort = body.get('sort') + if sort is not None: + search_body['sort'] = sort + + # Add search_after for cursor-based pagination + search_after = body.get('search_after') + if search_after is not None: + search_body['search_after'] = search_after + # search_after requires sort to be specified + if 'sort' not in search_body: + raise CCInvalidRequestException('sort is required when using search_after pagination') + + return search_body + + +def _query_references_field(obj, field_name: str) -> bool: + """ + Recursively check if the query DSL references the given field name. + + Checks whether any key equals the field name (or is a qualified name like "licenses.dateOfBirth"), + or any string value equals the field name or ends with ".{field_name}" (including standalone list + items like ["dateOfBirth"]). + + :param obj: The object to check (dict, list, or scalar) + :param field_name: The field name to search for + :return: True if the field name is found as a key or string value + """ + if isinstance(obj, str): + return obj == field_name or obj.endswith('.' + field_name) + if isinstance(obj, dict): + for key, value in obj.items(): + if key == field_name or key.endswith('.' + field_name): + return True + if _query_references_field(value, field_name): + return True + elif isinstance(obj, list): + for item in obj: + if _query_references_field(item, field_name): + return True + return False + + +def _caller_has_read_private_scope(compact: str, scopes: set[str]) -> bool: + """ + Check if the caller has readPrivate permission at either compact or jurisdiction level. + + :param compact: The compact abbreviation + :param scopes: The caller's scopes + :return: True if the caller has readPrivate permission + """ + action = CCPermissionsAction.READ_PRIVATE + + if f'{compact}/{action}' in scopes: + return True + + jurisdiction_scope_pattern = rf'.+/{compact}\.{action}$' + return any(match(jurisdiction_scope_pattern, scope) for scope in scopes) + + +def _validate_date_of_birth_permission(request_body: dict, compact: str, scopes: set[str]) -> None: + """ + Validate that the caller has readPrivate permission if the request body references dateOfBirth. + + :param request_body: Full search request body (query, sort, etc.) + :param compact: The compact abbreviation + :param scopes: The caller's scopes + :raises CCInvalidRequestException: If dateOfBirth is referenced and the caller lacks readPrivate permission + """ + if _query_references_field(request_body, 'dateOfBirth') and not _caller_has_read_private_scope(compact, scopes): + raise CCInvalidRequestException('Searching by dateOfBirth requires readPrivate permission') diff --git a/backend/social-work-app/lambdas/python/search/opensearch_client.py b/backend/social-work-app/lambdas/python/search/opensearch_client.py new file mode 100644 index 0000000000..ee43f31194 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/opensearch_client.py @@ -0,0 +1,647 @@ +import time + +import boto3 +from cc_common.config import config, logger +from cc_common.exceptions import CCInternalException, CCInvalidRequestException +from opensearchpy import AWSV4SignerAuth, OpenSearch, RequestsHttpConnection +from opensearchpy.exceptions import ConnectionTimeout, NotFoundError, RequestError, TransportError + +# Retry configuration for operations +MAX_RETRY_ATTEMPTS = 5 + +# Initial index version for new deployments (must stay in sync with index naming in handlers) +INITIAL_INDEX_VERSION = 'v1' +INITIAL_BACKOFF_SECONDS = 2 +MAX_BACKOFF_SECONDS = 32 + +DEFAULT_TIMEOUT = 30 + + +class OpenSearchClient: + def __init__(self, timeout: int = DEFAULT_TIMEOUT): + lambda_credentials = boto3.Session().get_credentials() + auth = AWSV4SignerAuth(credentials=lambda_credentials, region=config.environment_region, service='es') + self._client = OpenSearch( + hosts=[{'host': config.opensearch_host_endpoint, 'port': 443}], + http_auth=auth, + use_ssl=True, + verify_certs=True, + connection_class=RequestsHttpConnection, + timeout=timeout, + pool_maxsize=20, + ) + + def create_index(self, index_name: str, index_mapping: dict) -> None: + """ + Create an index with the specified mapping. + + :param index_name: The name of the index to create + :param index_mapping: The index configuration including settings and mappings + :raises CCInternalException: If all retry attempts fail + """ + self._execute_with_retry( + operation=lambda: self._client.indices.create(index=index_name, body=index_mapping), + operation_name=f'create_index({index_name})', + ) + + def index_exists(self, index_name: str) -> bool: + """ + Check if an index exists. + + :param index_name: The name of the index to check + :return: True if the index exists, False otherwise + :raises CCInternalException: If all retry attempts fail + """ + return self._execute_with_retry( + operation=lambda: self._client.indices.exists(index=index_name), + operation_name=f'index_exists({index_name})', + ) + + def alias_exists(self, alias_name: str) -> bool: + """ + Check if an alias exists. + + :param alias_name: The name of the alias to check + :return: True if the alias exists, False otherwise + :raises CCInternalException: If all retry attempts fail + """ + return self._execute_with_retry( + operation=lambda: self._client.indices.exists_alias(name=alias_name), + operation_name=f'alias_exists({alias_name})', + ) + + def create_alias(self, index_name: str, alias_name: str) -> None: + """ + Create an alias pointing to the specified index. + + :param index_name: The index to create the alias for + :param alias_name: The name of the alias to create + :raises CCInternalException: If all retry attempts fail + """ + self._execute_with_retry( + operation=lambda: self._client.indices.put_alias(index=index_name, name=alias_name), + operation_name=f'create_alias({alias_name} -> {index_name})', + ) + + def get_indices_for_alias(self, alias_name: str) -> list[str]: + """ + Return index names that the given alias points to. + + :param alias_name: The alias name to resolve + :return: List of concrete index names, or empty if the alias does not exist + """ + last_exception = None + backoff_seconds = INITIAL_BACKOFF_SECONDS + + for attempt in range(1, MAX_RETRY_ATTEMPTS + 1): + try: + response = self._client.indices.get_alias(name=alias_name) + return list(response.keys()) + except NotFoundError: + return [] + except (ConnectionTimeout, TransportError) as e: + last_exception = e + if attempt < MAX_RETRY_ATTEMPTS: + logger.warning( + 'Operation failed, retrying with backoff', + operation=f'get_indices_for_alias({alias_name})', + attempt=attempt, + max_attempts=MAX_RETRY_ATTEMPTS, + backoff_seconds=backoff_seconds, + error=str(e), + ) + time.sleep(backoff_seconds) + backoff_seconds = min(backoff_seconds * 2, MAX_BACKOFF_SECONDS) + else: + logger.error( + 'Operation failed after max retry attempts', + operation=f'get_indices_for_alias({alias_name})', + attempts=MAX_RETRY_ATTEMPTS, + error=str(e), + ) + + raise CCInternalException( + f'get_indices_for_alias({alias_name}) failed after {MAX_RETRY_ATTEMPTS} attempts. ' + f'Last error: {last_exception}' + ) + + def delete_index(self, index_name: str) -> None: + """ + Delete an index by name. Deleting an index removes any aliases to it. + + :param index_name: The index to delete + :raises CCInternalException: If all retry attempts fail + """ + self._execute_with_retry( + operation=lambda: self._client.indices.delete(index=index_name), + operation_name=f'delete_index({index_name})', + ) + + def create_provider_index_with_alias( + self, + index_name: str, + alias_name: str, + number_of_shards: int, + number_of_replicas: int, + ) -> None: + """ + Create the provider index and alias in OpenSearch if they don't exist. + + :param index_name: The versioned index name (e.g., compact_socw_providers_v1) + :param alias_name: The alias name (e.g., compact_socw_providers) + :param number_of_shards: Number of primary shards for the index + :param number_of_replicas: Number of replica shards for the index + """ + if self.alias_exists(alias_name): + logger.info(f"Alias '{alias_name}' already exists. Skipping index and alias creation.") + return + # Check if an index exists with the same name as the alias (this is most likely to happen in our development + # environments with only one data node. If the test OpenSearch Domain drops that node due to network failures + # aliases and indices will be lost and if the ingest pipeline inserts records before the aliases are recreated, + # OpenSearch will automatically create those indices under the alias name). + if self.index_exists(alias_name): + logger.info(f"Found index with alias name '{alias_name}'; deleting to allow alias creation...") + self.delete_index(alias_name) + logger.info(f"Index '{alias_name}' deleted.") + + if self.index_exists(index_name): + logger.info(f"Index '{index_name}' already exists. Creating alias only.") + self.create_alias(index_name, alias_name) + logger.info(f"Alias '{alias_name}' -> '{index_name}' created successfully.") + return + + logger.info(f"Creating index '{index_name}'...") + index_mapping = self._get_provider_index_mapping(number_of_shards, number_of_replicas) + self.create_index(index_name, index_mapping) + logger.info(f"Index '{index_name}' created successfully.") + + logger.info(f"Creating alias '{alias_name}' -> '{index_name}'...") + self.create_alias(index_name, alias_name) + logger.info(f"Alias '{alias_name}' -> '{index_name}' created successfully.") + + def delete_provider_index_with_alias(self, alias_name: str) -> None: + """ + Delete the versioned index (and its alias) for a provider index alias. + + Resolves underlying indices via the alias, then deletes them. If no alias + exists, attempts to delete the canonical versioned index name + ({alias_name}_{INITIAL_INDEX_VERSION}). + + :param alias_name: The alias name (e.g., compact_socw_providers) + """ + if self.alias_exists(alias_name): + indices = self.get_indices_for_alias(alias_name) + for idx_name in indices: + logger.info(f"Deleting index '{idx_name}' (via alias '{alias_name}')...") + self.delete_index(idx_name) + logger.info(f"Index '{idx_name}' deleted.") + return + + versioned_index_name = f'{alias_name}_{INITIAL_INDEX_VERSION}' + if self.index_exists(versioned_index_name): + logger.info(f"No alias found; deleting index '{versioned_index_name}' directly...") + self.delete_index(versioned_index_name) + logger.info(f"Index '{versioned_index_name}' deleted.") + else: + logger.info(f"No alias or index found for '{alias_name}'. Nothing to delete.") + + def _get_provider_index_mapping(self, number_of_shards: int, number_of_replicas: int) -> dict: + """ + Define the index mapping for provider documents. + + :param number_of_shards: Number of primary shards for the index + :param number_of_replicas: Number of replica shards for the index + :return: The index mapping dictionary + """ + adverse_action_properties = { + 'type': {'type': 'keyword'}, + 'adverseActionId': {'type': 'keyword'}, + 'compact': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'licenseTypeAbbreviation': {'type': 'keyword'}, + 'actionAgainst': {'type': 'keyword'}, + 'effectiveStartDate': {'type': 'date'}, + 'creationDate': {'type': 'date'}, + 'effectiveLiftDate': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'encumbranceType': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategories': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategory': {'type': 'keyword'}, + 'submittingUser': {'type': 'keyword'}, + 'liftingUser': {'type': 'keyword'}, + } + + investigation_properties = { + 'type': {'type': 'keyword'}, + 'investigationId': {'type': 'keyword'}, + 'compact': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'status': {'type': 'keyword'}, + 'dateOfUpdate': {'type': 'date'}, + } + + license_properties = { + 'providerId': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + 'dateOfUpdate': {'type': 'date'}, + 'compact': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'licenseStatusName': {'type': 'keyword'}, + 'licenseStatus': {'type': 'keyword'}, + 'jurisdictionUploadedLicenseStatus': {'type': 'keyword'}, + 'compactEligibility': {'type': 'keyword'}, + 'jurisdictionUploadedCompactEligibility': {'type': 'keyword'}, + 'licenseNumber': {'type': 'keyword'}, + 'givenName': { + 'type': 'text', + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}, + }, + 'middleName': { + 'type': 'text', + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}, + }, + 'familyName': { + 'type': 'text', + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}, + }, + 'suffix': {'type': 'keyword'}, + 'dateOfIssuance': {'type': 'date'}, + 'dateOfRenewal': {'type': 'date'}, + 'dateOfExpiration': {'type': 'date'}, + 'dateOfBirth': {'type': 'date'}, + 'homeAddressStreet1': {'type': 'text'}, + 'homeAddressStreet2': {'type': 'text'}, + 'homeAddressCity': { + 'type': 'text', + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}, + }, + 'homeAddressState': {'type': 'keyword'}, + 'homeAddressPostalCode': {'type': 'keyword'}, + 'emailAddress': {'type': 'keyword'}, + 'phoneNumber': {'type': 'keyword'}, + 'adverseActions': {'type': 'nested', 'properties': adverse_action_properties}, + 'investigations': {'type': 'nested', 'properties': investigation_properties}, + 'investigationStatus': {'type': 'keyword'}, + # This field is not in the original license record, but is added + # by the provider_record_util.generate_opensearch_documents method + # and is used to indicate the most recent license for filtering + # public search results + 'mostRecentLicenseForType': {'type': 'boolean'}, + } + + privilege_properties = { + 'type': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'compact': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseJurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'dateOfIssuance': {'type': 'date'}, + 'dateOfRenewal': {'type': 'date'}, + 'dateOfExpiration': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'adverseActions': {'type': 'nested', 'properties': adverse_action_properties}, + 'investigations': {'type': 'nested', 'properties': investigation_properties}, + 'administratorSetStatus': {'type': 'keyword'}, + 'compactTransactionId': {'type': 'keyword'}, + 'privilegeId': {'type': 'keyword'}, + 'status': {'type': 'keyword'}, + 'investigationStatus': {'type': 'keyword'}, + } + + return { + 'settings': { + 'index': { + 'number_of_shards': number_of_shards, + 'number_of_replicas': number_of_replicas, + }, + 'analysis': { + # Recommended by OpenSearch for international character sets; supports ASCII equivalents. + # See https://docs.opensearch.org/latest/analyzers/token-filters/asciifolding/ + 'filter': {'custom_ascii_folding': {'type': 'asciifolding', 'preserve_original': True}}, + 'analyzer': { + 'custom_ascii_analyzer': { + 'type': 'custom', + 'tokenizer': 'standard', + 'filter': ['lowercase', 'custom_ascii_folding'], + } + }, + }, + }, + 'mappings': { + 'properties': { + 'providerId': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + 'dateOfUpdate': {'type': 'date'}, + 'compact': {'type': 'keyword'}, + 'licenseJurisdiction': {'type': 'keyword'}, + 'licenseStatus': {'type': 'keyword'}, + 'compactEligibility': {'type': 'keyword'}, + 'givenName': { + 'type': 'text', + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}, + }, + 'middleName': { + 'type': 'text', + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}, + }, + 'familyName': { + 'type': 'text', + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'type': 'keyword', 'ignore_above': 256}}, + }, + 'suffix': {'type': 'keyword'}, + 'dateOfExpiration': {'type': 'date'}, + 'jurisdictionUploadedLicenseStatus': {'type': 'keyword'}, + 'jurisdictionUploadedCompactEligibility': {'type': 'keyword'}, + 'providerFamGivMid': {'type': 'keyword'}, + 'providerDateOfUpdate': {'type': 'date'}, + 'birthMonthDay': {'type': 'keyword'}, + 'adverseActions': {'type': 'nested', 'properties': adverse_action_properties}, + 'licenses': {'type': 'nested', 'properties': license_properties}, + 'privileges': {'type': 'nested', 'properties': privilege_properties}, + } + }, + } + + def cluster_health(self) -> dict: + """ + Get the cluster health status. + + Implements retry logic with exponential backoff for transient connection issues. + This is useful for checking if the cluster is responsive, especially after + a new domain is created. + + :return: The cluster health response from OpenSearch + :raises CCInternalException: If all retry attempts fail + """ + return self._execute_with_retry( + operation=lambda: self._client.cluster.health(), + operation_name='cluster_health', + ) + + def _execute_with_retry(self, operation: callable, operation_name: str): + """ + Execute an operation with retry logic and exponential backoff. + + This handles transient connection issues that can occur when: + - OpenSearch domain was just created and is still warming up + - Network connectivity issues within the VPC + - Temporary high load on the OpenSearch cluster + + :param operation: A callable that performs the operation + :param operation_name: A descriptive name for the operation (for logging) + :return: The result of the operation + :raises CCInternalException: If all retry attempts fail + """ + last_exception = None + backoff_seconds = INITIAL_BACKOFF_SECONDS + + for attempt in range(1, MAX_RETRY_ATTEMPTS + 1): + try: + return operation() + except (ConnectionTimeout, TransportError) as e: + last_exception = e + if attempt < MAX_RETRY_ATTEMPTS: + logger.warning( + 'Operation failed, retrying with backoff', + operation=operation_name, + attempt=attempt, + max_attempts=MAX_RETRY_ATTEMPTS, + backoff_seconds=backoff_seconds, + error=str(e), + ) + time.sleep(backoff_seconds) + # Exponential backoff with cap + backoff_seconds = min(backoff_seconds * 2, MAX_BACKOFF_SECONDS) + else: + logger.error( + 'Operation failed after max retry attempts', + operation=operation_name, + attempts=MAX_RETRY_ATTEMPTS, + error=str(e), + ) + + # All retry attempts failed + raise CCInternalException( + f'{operation_name} failed after {MAX_RETRY_ATTEMPTS} attempts. Last error: {last_exception}' + ) + + def search(self, index_name: str, body: dict) -> dict: + """ + Execute a search query against the specified index. + + :param index_name: The name of the index to search + :param body: The OpenSearch query body + :return: The search response from OpenSearch + :raises CCInvalidRequestException: If the query is invalid (400 error) or times out + """ + try: + return self._client.search(index=index_name, body=body) + except ConnectionTimeout as e: + logger.warning( + 'OpenSearch search request timed out', + index_name=index_name, + error=str(e), + ) + # We are returning this as an invalid request exception so the UI client picks it up as + # a 400 and displays the message to the client + raise CCInvalidRequestException( + 'Search request timed out. Please try again or narrow your search criteria.' + ) from e + except RequestError as e: + if e.status_code == 400: + # Extract the error message from the RequestError + error_message = self._extract_opensearch_error_reason(e) + logger.warning( + 'OpenSearch search request failed', + index_name=index_name, + status_code=e.status_code, + error_message=error_message, + ) + raise CCInvalidRequestException(f'Invalid search query: {error_message}') from e + # Re-raise non-400 RequestErrors + raise + + @staticmethod + def _extract_opensearch_error_reason(e: RequestError) -> str: + """ + Extract a human-readable error reason from an OpenSearch RequestError. + + The error info structure is typically: + {"error": {"root_cause": [{"type": "...", "reason": "..."}], ...}, "status": 400} + + :param e: The RequestError exception + :return: The extracted error reason, or a fallback string representation + """ + if not e.info: + return str(e.error) + + try: + # Navigate to error.root_cause[0].reason + root_causes = e.info.get('error', {}).get('root_cause', []) + if root_causes and isinstance(root_causes, list) and len(root_causes) > 0: + reason = root_causes[0].get('reason') + if reason: + return str(reason) + except (AttributeError, TypeError, KeyError): + # If navigation fails, fall back to string representation + logger.warning( + 'Failed to extract error reason from OpenSearch RequestError', + error=str(e), + ) + return str(e.error) + + def delete_provider_documents(self, index_name: str, provider_id: str) -> dict: + """ + Delete all OpenSearch documents for a given provider from the specified index. + + :param index_name: The name of the index to delete from + :param provider_id: The provider ID whose documents should be deleted + :return: The delete_by_query response from OpenSearch (includes 'deleted' count) + :raises CCInternalException: If all retry attempts fail + """ + query = {'term': {'providerId': provider_id}} + return self._execute_with_retry( + operation=lambda: self._client.delete_by_query(index=index_name, body={'query': query}), + operation_name=f'delete_provider_documents({index_name})', + ) + + def bulk_index(self, index_name: str, documents: list[dict], id_field: str = 'providerId') -> dict: + """ + Bulk index multiple documents into the specified index. + + This method implements retry logic with exponential backoff to handle transient + connection issues (e.g., ConnectionTimeout, TransportError). If all retry attempts + fail, a CCInternalException is raised to signal the caller to handle the failure. + + :param index_name: The name of the index to write to + :param documents: List of documents to index + :param id_field: The field name to use as the document ID (default: 'providerId') + :return: The bulk response from OpenSearch + :raises CCInternalException: If all retry attempts fail due to connection issues + """ + if not documents: + return {'items': [], 'errors': False} + + actions = [] + for doc in documents: + actions.append({'index': {'_id': doc[id_field]}}) + actions.append(doc) + + return self._bulk_index_with_retry(actions=actions, index_name=index_name, document_count=len(documents)) + + def bulk_delete(self, index_name: str, document_ids: list[str]) -> set[str]: + """ + Bulk delete multiple documents from the specified index. + + This method implements retry logic with exponential backoff to handle transient + connection issues (e.g., ConnectionTimeout, TransportError). If all retry attempts + fail, a CCInternalException is raised to signal the caller to handle the failure. + + :param index_name: The name of the index to delete from + :param document_ids: List of document IDs to delete + :return: A list of document ids that failed to delete (if any) + :raises CCInternalException: If all retry attempts fail due to connection issues + """ + failed_document_ids = set() + if not document_ids: + return failed_document_ids + + actions = [] + for doc_id in document_ids: + actions.append({'delete': {'_id': doc_id}}) + + response = self._bulk_operation_with_retry( + actions=actions, index_name=index_name, operation_count=len(document_ids), operation_type='delete' + ) + + # Check for individual delete failures + if response.get('errors'): + for item in response.get('items', []): + delete_result = item.get('delete', {}) + if delete_result.get('error'): + doc_id = delete_result.get('_id') + # 404 (not_found) is not an error for delete - the document was already gone + if delete_result.get('status') != 404: + logger.error( + 'Document deletion failed', + provider_id=doc_id, + error=delete_result.get('error'), + ) + failed_document_ids.add(doc_id) + + return failed_document_ids + + def _bulk_index_with_retry(self, actions: list, index_name: str, document_count: int) -> dict: + """ + Execute bulk index with retry logic and exponential backoff. + + :param actions: The bulk actions to execute + :param index_name: The name of the index to write to + :param document_count: Number of documents being indexed (for logging) + :return: The bulk response from OpenSearch + :raises CCInternalException: If all retry attempts fail + """ + return self._bulk_operation_with_retry( + actions=actions, index_name=index_name, operation_count=document_count, operation_type='index' + ) + + def _bulk_operation_with_retry( + self, actions: list, index_name: str, operation_count: int, operation_type: str + ) -> dict: + """ + Execute bulk operation with retry logic and exponential backoff. + + :param actions: The bulk actions to execute + :param index_name: The name of the index to operate on + :param operation_count: Number of operations being performed (for logging) + :param operation_type: Type of operation ('index' or 'delete') for logging + :return: The bulk response from OpenSearch + :raises CCInternalException: If all retry attempts fail + """ + last_exception = None + backoff_seconds = INITIAL_BACKOFF_SECONDS + + for attempt in range(1, MAX_RETRY_ATTEMPTS + 1): + try: + return self._client.bulk(body=actions, index=index_name) + except (ConnectionTimeout, TransportError) as e: + last_exception = e + if attempt < MAX_RETRY_ATTEMPTS: + logger.warning( + f'Bulk {operation_type} attempt failed, retrying with backoff', + attempt=attempt, + max_attempts=MAX_RETRY_ATTEMPTS, + backoff_seconds=backoff_seconds, + index_name=index_name, + operation_count=operation_count, + error=str(e), + ) + time.sleep(backoff_seconds) + # Exponential backoff with cap + backoff_seconds = min(backoff_seconds * 2, MAX_BACKOFF_SECONDS) + else: + logger.error( + f'Bulk {operation_type} failed after max retry attempts', + attempts=MAX_RETRY_ATTEMPTS, + index_name=index_name, + operation_count=operation_count, + error=str(e), + ) + + # All retry attempts failed + raise CCInternalException( + f'Failed to bulk {operation_type} {operation_count} documents to {index_name} ' + f'after {MAX_RETRY_ATTEMPTS} attempts. Last error: {last_exception}' + ) diff --git a/backend/social-work-app/lambdas/python/search/requirements-dev.in b/backend/social-work-app/lambdas/python/search/requirements-dev.in new file mode 100644 index 0000000000..e0c3124af6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb]>=5.0.12, <6 diff --git a/backend/social-work-app/lambdas/python/search/requirements-dev.txt b/backend/social-work-app/lambdas/python/search/requirements-dev.txt new file mode 100644 index 0000000000..df9a382d1f --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/requirements-dev.txt @@ -0,0 +1,62 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via werkzeug +moto[dynamodb]==5.2.1 + # via -r lambdas/python/search/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/search/requirements.in b/backend/social-work-app/lambdas/python/search/requirements.in new file mode 100644 index 0000000000..0c9e7c4994 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/requirements.in @@ -0,0 +1,2 @@ +# common requirements are managed in the common requirements.in file +opensearch-py>=3.1.0, <4.0.0 diff --git a/backend/social-work-app/lambdas/python/search/requirements.txt b/backend/social-work-app/lambdas/python/search/requirements.txt new file mode 100644 index 0000000000..18af4eceb7 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/requirements.txt @@ -0,0 +1,36 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements.in +# +certifi==2026.4.22 + # via + # opensearch-py + # requests +charset-normalizer==3.4.7 + # via requests +events==0.5 + # via opensearch-py +grpcio==1.80.0 + # via opensearch-protobufs +idna==3.15 + # via requests +opensearch-protobufs==1.2.0 + # via opensearch-py +opensearch-py==3.2.0 + # via -r lambdas/python/search/requirements.in +protobuf==7.34.1 + # via opensearch-protobufs +python-dateutil==2.9.0.post0 + # via opensearch-py +requests==2.34.1 + # via opensearch-py +six==1.17.0 + # via python-dateutil +typing-extensions==4.15.0 + # via grpcio +urllib3==2.7.0 + # via + # opensearch-py + # requests diff --git a/backend/social-work-app/lambdas/python/search/tests/__init__.py b/backend/social-work-app/lambdas/python/search/tests/__init__.py new file mode 100644 index 0000000000..a4e830fe8b --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/__init__.py @@ -0,0 +1,102 @@ +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'true', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'AWS_REGION': 'us-east-1', + 'ENVIRONMENT_NAME': 'test', + 'COMPACTS': '["socw"]', + 'PROVIDER_TABLE_NAME': 'provider-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-config-table', + 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', + 'LICENSE_GSI_NAME': 'licenseGSI', + 'LICENSE_UPLOAD_DATE_INDEX_NAME': 'licenseUploadDateGSI', + 'OPENSEARCH_HOST_ENDPOINT': 'vpc-providersearchd-5bzuqxhpxffk-w6dkpddu.us-east-1.es.amazonaws.com', + 'EXPORT_RESULTS_BUCKET_NAME': 'test-export-results-bucket', + 'JURISDICTIONS': json.dumps( + [ + 'al', + 'ak', + 'az', + 'ar', + 'ca', + 'co', + 'ct', + 'de', + 'dc', + 'fl', + 'ga', + 'hi', + 'id', + 'il', + 'in', + 'ia', + 'ks', + 'ky', + 'la', + 'me', + 'md', + 'ma', + 'mi', + 'mn', + 'ms', + 'mo', + 'mt', + 'ne', + 'nv', + 'nh', + 'nj', + 'nm', + 'ny', + 'nc', + 'nd', + 'oh', + 'ok', + 'or', + 'pa', + 'pr', + 'ri', + 'sc', + 'sd', + 'tn', + 'tx', + 'ut', + 'vt', + 'va', + 'vi', + 'wa', + 'wv', + 'wi', + 'wy', + ] + ), + 'LICENSE_TYPES': json.dumps( + { + 'socw': [ + {'name': 'cosmetologist', 'abbreviation': 'cos'}, + {'name': 'esthetician', 'abbreviation': 'esth'}, + ], + }, + ), + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/search/tests/function/__init__.py b/backend/social-work-app/lambdas/python/search/tests/function/__init__.py new file mode 100644 index 0000000000..6bbe238176 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/function/__init__.py @@ -0,0 +1,110 @@ +import os + +import boto3 +from moto import mock_aws + +from tests import TstLambdas + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + # we want to see any diffs in failed tests, regardless of how large the object is + self.maxDiff = None + + self.build_resources() + # This must be imported within the tests, since they import modules which require + # environment variables that are not set until the TstLambdas class is initialized + from common_test.test_data_generator import TestDataGenerator + + self.test_data_generator = TestDataGenerator + + self.addCleanup(self.delete_resources) + + def build_resources(self): + self.create_provider_table() + self.create_compact_configuration_table() + self.create_export_results_bucket() + + def delete_resources(self): + self._provider_table.delete() + # must delete all objects in the bucket before deleting the bucket + self._bucket.objects.delete() + self._bucket.delete() + self._compact_configuration_table.delete() + + def create_export_results_bucket(self): + """Create the mock S3 bucket for export results""" + self._bucket = boto3.resource('s3').create_bucket(Bucket=os.environ['EXPORT_RESULTS_BUCKET_NAME']) + + def create_provider_table(self): + self._provider_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'providerFamGivMid', 'AttributeType': 'S'}, + {'AttributeName': 'providerDateOfUpdate', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseGSISK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSIPK', 'AttributeType': 'S'}, + {'AttributeName': 'licenseUploadDateGSISK', 'AttributeType': 'S'}, + ], + TableName=os.environ['PROVIDER_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['PROV_FAM_GIV_MID_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerFamGivMid', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'providerDateOfUpdate', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['LICENSE_GSI_NAME'], + 'KeySchema': [ + {'AttributeName': 'licenseGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + { + 'IndexName': os.environ['LICENSE_UPLOAD_DATE_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'licenseUploadDateGSIPK', 'KeyType': 'HASH'}, + {'AttributeName': 'licenseUploadDateGSISK', 'KeyType': 'RANGE'}, + ], + 'Projection': { + 'ProjectionType': 'INCLUDE', + 'NonKeyAttributes': ['providerId'], + }, + }, + ], + ) + + def create_compact_configuration_table(self): + """Create the compact configuration table for testing.""" + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + ) diff --git a/backend/social-work-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py b/backend/social-work-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py new file mode 100644 index 0000000000..fcc0c312b6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py @@ -0,0 +1,521 @@ +from unittest.mock import Mock, call, patch + +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestOpenSearchIndexManager(TstFunction): + """Test suite for OpenSearchIndexManager custom resource.""" + + def setUp(self): + super().setUp() + + def _create_event(self, request_type: str, properties: dict = None) -> dict: + """Create a CloudFormation custom resource event.""" + default_properties = { + 'numberOfShards': 1, + 'numberOfReplicas': 0, + } + if properties: + default_properties.update(properties) + return { + 'RequestType': request_type, + 'ResourceProperties': default_properties, + } + + def _when_testing_mock_opensearch_client( + self, + mock_opensearch_client, + alias_exists_return_value: bool | dict = False, + index_exists_return_value: bool | dict = False, + ): + """ + Configure the mock OpenSearchClient for testing. + + :param mock_opensearch_client: The patched OpenSearchClient class + :param alias_exists_return_value: Either a boolean (applied to all aliases) + or a dict mapping alias names to booleans + :param index_exists_return_value: Either a boolean (applied to all indices) + or a dict mapping index names to booleans + :return: The mock client instance + """ + mock_client_instance = Mock() + mock_opensearch_client.return_value = mock_client_instance + + # Configure cluster_health mock (used by _wait_for_domain_ready) + mock_client_instance.cluster_health.return_value = { + 'status': 'green', + 'number_of_nodes': 1, + 'cluster_name': 'test-cluster', + } + + # Configure alias_exists mock + if isinstance(alias_exists_return_value, dict): + mock_client_instance.alias_exists.side_effect = lambda alias_name: alias_exists_return_value.get( + alias_name, False + ) + else: + mock_client_instance.alias_exists.return_value = alias_exists_return_value + + # Configure index_exists mock + if isinstance(index_exists_return_value, dict): + mock_client_instance.index_exists.side_effect = lambda index_name: index_exists_return_value.get( + index_name, False + ) + else: + mock_client_instance.index_exists.return_value = index_exists_return_value + + # We want to make assertions on the opensearch client calls that are made during index creation + # to ensure the alias/indices are created with the expected mapping (see the assertions made in the + # test_on_create_creates_versioned_indices_and_aliases_for_all_compacts_when_none_exist test). + # This setup points the mock to the actual function pointers in the opensearch_client client so that + # we are not mocking the methods that set the index mapping. + from opensearch_client import OpenSearchClient + + def _real_get_mapping(number_of_shards, number_of_replicas): + return OpenSearchClient._get_provider_index_mapping( # noqa: SLF001 private-member-access + mock_client_instance, number_of_shards, number_of_replicas + ) + + mock_client_instance._get_provider_index_mapping = _real_get_mapping # noqa: SLF001 private-member-access + + def _real_create_provider_index(*args, **kwargs): + return OpenSearchClient.create_provider_index_with_alias(mock_client_instance, *args, **kwargs) + + mock_client_instance.create_provider_index_with_alias.side_effect = _real_create_provider_index + + return mock_client_instance + + @patch('handlers.manage_opensearch_indices.OpenSearchClient') + def test_on_create_creates_versioned_indices_and_aliases_for_all_compacts_when_none_exist( + self, mock_opensearch_client + ): + """Test that on_create creates versioned indices and aliases for all compacts when they don't exist.""" + from handlers.manage_opensearch_indices import on_event + + # Set up the mock opensearch client - no aliases or indices exist + mock_client_instance = self._when_testing_mock_opensearch_client( + mock_opensearch_client, alias_exists_return_value=False, index_exists_return_value=False + ) + + # Create the event for a 'Create' request with explicit shard/replica configuration + event = self._create_event('Create', {'numberOfShards': 2, 'numberOfReplicas': 1}) + + # Call the handler + on_event(event, self.mock_context) + + # Assert that the OpenSearchClient was instantiated + mock_opensearch_client.assert_called_once() + + # Assert that alias_exists was called for each compact + expected_alias_exists_calls = [call('compact_socw_providers')] + mock_client_instance.alias_exists.assert_has_calls(expected_alias_exists_calls, any_order=False) + self.assertEqual(1, mock_client_instance.alias_exists.call_count) + + # Assert that create_index was called for each compact with versioned names + self.assertEqual(1, mock_client_instance.create_index.call_count) + + # Verify the versioned index names in create_index calls + create_index_calls = mock_client_instance.create_index.call_args_list + index_names_created = [call_args[0][0] for call_args in create_index_calls] + self.assertEqual( + ['compact_socw_providers_v1'], + index_names_created, + ) + + # Assert that create_alias was called for each compact + self.assertEqual(1, mock_client_instance.create_alias.call_count) + expected_alias_calls = [ + call('compact_socw_providers_v1', 'compact_socw_providers'), + ] + mock_client_instance.create_alias.assert_has_calls(expected_alias_calls, any_order=False) + + # Verify the mapping was passed to create_index with correct shard/replica configuration + for call_args in create_index_calls: + index_mapping = call_args[0][1] + # Verify the index settings use the provided shard/replica values + self.assertEqual(2, index_mapping['settings']['index']['number_of_shards']) + self.assertEqual(1, index_mapping['settings']['index']['number_of_replicas']) + # Verify the mapping has the expected structure + self.assertEqual( + { + 'mappings': { + 'properties': { + 'adverseActions': { + 'properties': { + 'actionAgainst': {'type': 'keyword'}, + 'adverseActionId': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategories': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategory': {'type': 'keyword'}, + 'compact': {'type': 'keyword'}, + 'creationDate': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'effectiveLiftDate': {'type': 'date'}, + 'effectiveStartDate': {'type': 'date'}, + 'encumbranceType': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'licenseTypeAbbreviation': {'type': 'keyword'}, + 'liftingUser': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'submittingUser': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + }, + 'type': 'nested', + }, + 'birthMonthDay': {'type': 'keyword'}, + 'compact': {'type': 'keyword'}, + 'compactEligibility': {'type': 'keyword'}, + 'dateOfExpiration': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'familyName': { + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'ignore_above': 256, 'type': 'keyword'}}, + 'type': 'text', + }, + 'givenName': { + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'ignore_above': 256, 'type': 'keyword'}}, + 'type': 'text', + }, + 'jurisdictionUploadedCompactEligibility': {'type': 'keyword'}, + 'jurisdictionUploadedLicenseStatus': {'type': 'keyword'}, + 'licenseJurisdiction': {'type': 'keyword'}, + 'licenseStatus': {'type': 'keyword'}, + 'licenses': { + 'properties': { + 'adverseActions': { + 'properties': { + 'actionAgainst': {'type': 'keyword'}, + 'adverseActionId': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategories': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategory': {'type': 'keyword'}, + 'compact': {'type': 'keyword'}, + 'creationDate': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'effectiveLiftDate': {'type': 'date'}, + 'effectiveStartDate': {'type': 'date'}, + 'encumbranceType': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'licenseTypeAbbreviation': {'type': 'keyword'}, + 'liftingUser': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'submittingUser': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + }, + 'type': 'nested', + }, + 'compact': {'type': 'keyword'}, + 'compactEligibility': {'type': 'keyword'}, + 'dateOfBirth': {'type': 'date'}, + 'dateOfExpiration': {'type': 'date'}, + 'dateOfIssuance': {'type': 'date'}, + 'dateOfRenewal': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'emailAddress': {'type': 'keyword'}, + 'familyName': { + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'ignore_above': 256, 'type': 'keyword'}}, + 'type': 'text', + }, + 'givenName': { + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'ignore_above': 256, 'type': 'keyword'}}, + 'type': 'text', + }, + 'homeAddressCity': { + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'ignore_above': 256, 'type': 'keyword'}}, + 'type': 'text', + }, + 'homeAddressPostalCode': {'type': 'keyword'}, + 'homeAddressState': {'type': 'keyword'}, + 'homeAddressStreet1': {'type': 'text'}, + 'homeAddressStreet2': {'type': 'text'}, + 'investigationStatus': {'type': 'keyword'}, + 'investigations': { + 'properties': { + 'compact': {'type': 'keyword'}, + 'dateOfUpdate': {'type': 'date'}, + 'investigationId': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'status': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + }, + 'type': 'nested', + }, + 'jurisdiction': {'type': 'keyword'}, + 'jurisdictionUploadedCompactEligibility': {'type': 'keyword'}, + 'jurisdictionUploadedLicenseStatus': {'type': 'keyword'}, + 'licenseNumber': {'type': 'keyword'}, + 'licenseStatus': {'type': 'keyword'}, + 'licenseStatusName': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'middleName': { + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'ignore_above': 256, 'type': 'keyword'}}, + 'type': 'text', + }, + 'mostRecentLicenseForType': {'type': 'boolean'}, + 'phoneNumber': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'suffix': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + }, + 'type': 'nested', + }, + 'middleName': { + 'analyzer': 'custom_ascii_analyzer', + 'fields': {'keyword': {'ignore_above': 256, 'type': 'keyword'}}, + 'type': 'text', + }, + 'privileges': { + 'properties': { + 'administratorSetStatus': {'type': 'keyword'}, + 'adverseActions': { + 'properties': { + 'actionAgainst': {'type': 'keyword'}, + 'adverseActionId': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategories': {'type': 'keyword'}, + 'clinicalPrivilegeActionCategory': {'type': 'keyword'}, + 'compact': {'type': 'keyword'}, + 'creationDate': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'effectiveLiftDate': {'type': 'date'}, + 'effectiveStartDate': {'type': 'date'}, + 'encumbranceType': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'licenseTypeAbbreviation': {'type': 'keyword'}, + 'liftingUser': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'submittingUser': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + }, + 'type': 'nested', + }, + 'compact': {'type': 'keyword'}, + 'compactTransactionId': {'type': 'keyword'}, + 'dateOfExpiration': {'type': 'date'}, + 'dateOfIssuance': {'type': 'date'}, + 'dateOfRenewal': {'type': 'date'}, + 'dateOfUpdate': {'type': 'date'}, + 'investigationStatus': {'type': 'keyword'}, + 'investigations': { + 'properties': { + 'compact': {'type': 'keyword'}, + 'dateOfUpdate': {'type': 'date'}, + 'investigationId': {'type': 'keyword'}, + 'jurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'status': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + }, + 'type': 'nested', + }, + 'jurisdiction': {'type': 'keyword'}, + 'licenseJurisdiction': {'type': 'keyword'}, + 'licenseType': {'type': 'keyword'}, + 'privilegeId': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'status': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + }, + 'type': 'nested', + }, + 'providerDateOfUpdate': {'type': 'date'}, + 'providerFamGivMid': {'type': 'keyword'}, + 'providerId': {'type': 'keyword'}, + 'suffix': {'type': 'keyword'}, + 'type': {'type': 'keyword'}, + } + }, + 'settings': { + 'analysis': { + 'analyzer': { + 'custom_ascii_analyzer': { + 'filter': ['lowercase', 'custom_ascii_folding'], + 'tokenizer': 'standard', + 'type': 'custom', + } + }, + 'filter': {'custom_ascii_folding': {'preserve_original': True, 'type': 'asciifolding'}}, + }, + 'index': {'number_of_replicas': 1, 'number_of_shards': 2}, + }, + }, + index_mapping, + ) + + @patch('handlers.manage_opensearch_indices.OpenSearchClient') + def test_on_create_skips_index_and_alias_creation_when_all_aliases_exist(self, mock_opensearch_client): + """Test that on_create skips index and alias creation when aliases already exist.""" + from handlers.manage_opensearch_indices import on_event + + # Set up the mock opensearch client - all aliases exist (meaning indices are already set up) + mock_client_instance = self._when_testing_mock_opensearch_client( + mock_opensearch_client, alias_exists_return_value=True + ) + + # Create the event for a 'Create' request + event = self._create_event('Create') + + # Call the handler + on_event(event, self.mock_context) + + # Assert that the OpenSearchClient was instantiated + mock_opensearch_client.assert_called_once() + + # Assert that alias_exists was called for each compact + self.assertEqual(1, mock_client_instance.alias_exists.call_count) + + # Assert that index_exists was NOT called since aliases already exist + mock_client_instance.index_exists.assert_not_called() + + # Assert that create_index was NOT called since aliases already exist + mock_client_instance.create_index.assert_not_called() + + # Assert that create_alias was NOT called since aliases already exist + mock_client_instance.create_alias.assert_not_called() + + @patch('handlers.manage_opensearch_indices.OpenSearchClient') + def test_on_create_creates_alias_only_when_index_exists_but_alias_does_not(self, mock_opensearch_client): + """Test that on_create creates only the alias when the index exists but the alias doesn't.""" + from handlers.manage_opensearch_indices import on_event + + # Set up the mock opensearch client - index exists but alias doesn't (edge case) + mock_client_instance = self._when_testing_mock_opensearch_client( + mock_opensearch_client, + alias_exists_return_value=False, + index_exists_return_value={ + 'compact_socw_providers_v1': True, + }, + ) + + # Create the event for a 'Create' request + event = self._create_event('Create') + + # Call the handler + on_event(event, self.mock_context) + + # Assert that alias_exists was called for each compact + self.assertEqual(1, mock_client_instance.alias_exists.call_count) + + # Assert that index_exists was called for each compact, and to check if an index was misconfigured + # under the alias name + self.assertEqual(2, mock_client_instance.index_exists.call_count) + + # Assert that create_index was NOT called since indices already exist + mock_client_instance.create_index.assert_not_called() + + # Assert that create_alias was called for each compact (to create the missing aliases) + self.assertEqual(1, mock_client_instance.create_alias.call_count) + expected_alias_calls = [ + call('compact_socw_providers_v1', 'compact_socw_providers'), + ] + mock_client_instance.create_alias.assert_has_calls(expected_alias_calls, any_order=False) + + @patch('handlers.manage_opensearch_indices.OpenSearchClient') + def test_on_update_is_noop(self, mock_opensearch_client): + """Test that on_update does not create or modify indices.""" + from handlers.manage_opensearch_indices import on_event + + # Create the event for an 'Update' request + event = self._create_event('Update') + + # Call the handler + result = on_event(event, self.mock_context) + + # Assert that the OpenSearchClient was NOT instantiated + mock_opensearch_client.assert_not_called() + + # Result should be None (no-op) + self.assertIsNone(result) + + @patch('handlers.manage_opensearch_indices.OpenSearchClient') + def test_on_delete_is_noop(self, mock_opensearch_client): + """Test that on_delete does not delete indices.""" + from handlers.manage_opensearch_indices import on_event + + # Create the event for a 'Delete' request + event = self._create_event('Delete') + + # Call the handler + result = on_event(event, self.mock_context) + + # Assert that the OpenSearchClient was NOT instantiated + mock_opensearch_client.assert_not_called() + + # Result should be None (no-op) + self.assertIsNone(result) + + @patch('handlers.manage_opensearch_indices.time.sleep') + @patch('handlers.manage_opensearch_indices.OpenSearchClient') + def test_on_create_retries_when_domain_not_immediately_responsive(self, mock_opensearch_client, mock_sleep): + """Test that on_create retries connecting to the domain when it's not immediately responsive.""" + from cc_common.exceptions import CCInternalException + from handlers.manage_opensearch_indices import on_event + + # First two calls fail, third succeeds + mock_client_instance = Mock() + mock_client_instance.cluster_health.return_value = { + 'status': 'green', + 'number_of_nodes': 1, + } + mock_client_instance.alias_exists.return_value = True # Skip index creation for simplicity + + call_count = 0 + + def side_effect(): + nonlocal call_count + call_count += 1 + if call_count <= 2: + raise CCInternalException('cluster_health failed after 5 attempts. Last error: ConnectionTimeout') + return mock_client_instance + + mock_opensearch_client.side_effect = side_effect + + # Create the event for a 'Create' request + event = self._create_event('Create') + + # Call the handler + on_event(event, self.mock_context) + + # Assert that OpenSearchClient was instantiated 3 times (2 failures + 1 success) + self.assertEqual(3, mock_opensearch_client.call_count) + + # Assert that sleep was called twice (once between each retry) + self.assertEqual(2, mock_sleep.call_count) + + @patch('handlers.manage_opensearch_indices.time.sleep') + @patch('handlers.manage_opensearch_indices.OpenSearchClient') + def test_on_create_raises_after_max_retries(self, mock_opensearch_client, mock_sleep): # noqa ARG002 unused-argument + """Test that on_create raises CCInternalException after max retries are exhausted.""" + from cc_common.exceptions import CCInternalException + from handlers.manage_opensearch_indices import ( + DOMAIN_READINESS_MAX_ATTEMPTS, + on_event, + ) + + # All calls fail + mock_opensearch_client.side_effect = CCInternalException( + 'cluster_health failed after 5 attempts. Last error: ConnectionTimeout' + ) + + # Create the event for a 'Create' request + event = self._create_event('Create') + + # Call the handler and expect an exception + with self.assertRaises(CCInternalException) as context: + on_event(event, self.mock_context) + + # Verify the error message mentions the number of attempts + self.assertIn(str(DOMAIN_READINESS_MAX_ATTEMPTS), str(context.exception)) + self.assertIn('did not become responsive', str(context.exception)) + + # Assert that OpenSearchClient was instantiated max attempts times + self.assertEqual(DOMAIN_READINESS_MAX_ATTEMPTS, mock_opensearch_client.call_count) diff --git a/backend/social-work-app/lambdas/python/search/tests/function/test_populate_provider_documents.py b/backend/social-work-app/lambdas/python/search/tests/function/test_populate_provider_documents.py new file mode 100644 index 0000000000..57bdd897db --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/function/test_populate_provider_documents.py @@ -0,0 +1,162 @@ +from unittest.mock import Mock, patch + +from common_test.test_constants import ( + DEFAULT_DATE_OF_BIRTH, + DEFAULT_LICENSE_EXPIRATION_DATE, + DEFAULT_LICENSE_ISSUANCE_DATE, + DEFAULT_LICENSE_RENEWAL_DATE, + DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + DEFAULT_PROVIDER_UPDATE_DATETIME, +) +from moto import mock_aws + +from . import TstFunction + +MOCK_SOCW_PROVIDER_ID = '00000000-0000-0000-0000-000000000001' + +test_license_type_mapping = { + 'socw': 'cosmetologist', +} +test_provider_id_mapping = { + 'socw': MOCK_SOCW_PROVIDER_ID, +} + + +@mock_aws +class TestPopulateProviderDocuments(TstFunction): + """Test suite for populate provider documents handler.""" + + def setUp(self): + super().setUp() + + def _put_test_provider_and_license_record_in_dynamodb_table(self, compact): + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'compact': compact, + 'providerId': test_provider_id_mapping[compact], + 'givenName': f'test{compact}GivenName', + 'familyName': f'test{compact}FamilyName', + }, + date_of_update_override=DEFAULT_PROVIDER_UPDATE_DATETIME, + ) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'compact': compact, + 'providerId': test_provider_id_mapping[compact], + 'givenName': f'test{compact}GivenName', + 'familyName': f'test{compact}FamilyName', + 'licenseType': test_license_type_mapping[compact], + }, + date_of_update_override=DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + ) + + def _put_failed_ingest_record_in_search_event_state_table( + self, compact: str, provider_id: str, sequence_number: str + ): + """Put a failed ingest record in the search event state table for testing.""" + import time + from datetime import timedelta + + pk = f'COMPACT#{compact}#FAILED_INGEST' + sk = f'PROVIDER#{provider_id}#SEQUENCE#{sequence_number}' + ttl = int(time.time()) + int(timedelta(days=7).total_seconds()) + + self.config.search_event_state_table.put_item( + Item={ + 'pk': pk, + 'sk': sk, + 'compact': compact, + 'providerId': provider_id, + 'sequenceNumber': sequence_number, + 'ttl': ttl, + } + ) + + def _when_testing_mock_opensearch_client(self, mock_opensearch_client, bulk_index_response: dict = None): + if not bulk_index_response: + bulk_index_response = {'items': [], 'errors': False} + + mock_client_instance = Mock() + mock_opensearch_client.return_value = mock_client_instance + mock_client_instance.bulk_index.return_value = bulk_index_response + return mock_client_instance + + def _generate_expected_document(self, compact): + provider_id = test_provider_id_mapping[compact] + license_type = test_license_type_mapping[compact] + return { + 'providerId': provider_id, + 'type': 'provider', + 'dateOfUpdate': DEFAULT_PROVIDER_UPDATE_DATETIME, + 'compact': compact, + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'compactEligibility': 'ineligible', + 'givenName': f'test{compact}GivenName', + 'middleName': 'Gunnar', + 'familyName': f'test{compact}FamilyName', + 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'birthMonthDay': '06-06', + 'adverseActions': [], + 'documentId': f'{provider_id}#oh#{license_type}', + 'licenses': [ + { + 'providerId': provider_id, + 'type': 'license', + 'dateOfUpdate': DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + 'compact': compact, + 'jurisdiction': 'oh', + 'licenseType': license_type, + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseStatus': 'inactive', + 'jurisdictionUploadedLicenseStatus': 'active', + 'compactEligibility': 'ineligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'licenseNumber': 'A0608337260', + 'givenName': f'test{compact}GivenName', + 'middleName': 'Gunnar', + 'mostRecentLicenseForType': True, + 'familyName': f'test{compact}FamilyName', + 'dateOfIssuance': DEFAULT_LICENSE_ISSUANCE_DATE, + 'dateOfRenewal': DEFAULT_LICENSE_RENEWAL_DATE, + 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, + 'dateOfBirth': DEFAULT_DATE_OF_BIRTH, + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'adverseActions': [], + 'investigations': [], + } + ], + 'privileges': [], + } + + @patch('handlers.populate_provider_documents.OpenSearchClient') + def test_populate_indexes_document_with_document_id(self, mock_opensearch_client): + """Test that populate handler indexes documents with id_field='documentId'.""" + from handlers.populate_provider_documents import populate_provider_documents + + mock_client_instance = self._when_testing_mock_opensearch_client(mock_opensearch_client) + self._put_test_provider_and_license_record_in_dynamodb_table('socw') + + mock_context = Mock() + mock_context.get_remaining_time_in_millis.return_value = 600000 + + result = populate_provider_documents({}, mock_context) + + self.assertTrue(result['completed']) + self.assertGreaterEqual(mock_client_instance.bulk_index.call_count, 1) + + bulk_index_call = mock_client_instance.bulk_index.call_args + self.assertEqual('compact_socw_providers', bulk_index_call.kwargs['index_name']) + self.assertEqual('documentId', bulk_index_call.kwargs['id_field']) + + indexed_documents = bulk_index_call.kwargs['documents'] + self.assertEqual(1, len(indexed_documents)) + self.assertEqual(self._generate_expected_document('socw'), indexed_documents[0]) diff --git a/backend/social-work-app/lambdas/python/search/tests/function/test_provider_update_ingest.py b/backend/social-work-app/lambdas/python/search/tests/function/test_provider_update_ingest.py new file mode 100644 index 0000000000..de24b90f6c --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/function/test_provider_update_ingest.py @@ -0,0 +1,740 @@ +import json +from datetime import date +from unittest.mock import MagicMock, patch + +from common_test.test_constants import ( + DEFAULT_DATE_OF_BIRTH, + DEFAULT_LICENSE_EXPIRATION_DATE, + DEFAULT_LICENSE_ISSUANCE_DATE, + DEFAULT_LICENSE_RENEWAL_DATE, + DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + DEFAULT_PROVIDER_UPDATE_DATETIME, +) +from moto import mock_aws + +from . import TstFunction + +MOCK_SOCW_PROVIDER_ID = '00000000-0000-0000-0000-000000000001' + +TEST_LICENSE_TYPE_MAPPING = { + 'socw': 'cosmetologist', +} +TEST_PROVIDER_ID_MAPPING = { + 'socw': MOCK_SOCW_PROVIDER_ID, +} + + +@mock_aws +class TestProviderUpdateIngest(TstFunction): + """Test suite for provider update ingest handler.""" + + def setUp(self): + super().setUp() + + def _put_test_provider_and_license_record_in_dynamodb_table(self, compact: str, provider_id: str = None): + """Helper to create test provider and license records in DynamoDB.""" + if provider_id is None: + provider_id = TEST_PROVIDER_ID_MAPPING[compact] + + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'compact': compact, + 'providerId': provider_id, + 'givenName': f'test{compact}GivenName', + 'familyName': f'test{compact}FamilyName', + }, + date_of_update_override=DEFAULT_PROVIDER_UPDATE_DATETIME, + ) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'compact': compact, + 'providerId': provider_id, + 'givenName': f'test{compact}GivenName', + 'familyName': f'test{compact}FamilyName', + 'licenseType': TEST_LICENSE_TYPE_MAPPING[compact], + }, + date_of_update_override=DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + ) + + def _create_dynamodb_stream_record( + self, + compact: str, + provider_id: str, + sequence_number: str, + event_name: str = 'MODIFY', + include_old_image: bool = True, + ) -> dict: + """ + Create a DynamoDB stream record in the format received by Lambda. + + DynamoDB stream records contain the image data in a specific format where + each attribute is wrapped with its type indicator (e.g., {'S': 'value'} for strings). + + :param compact: The compact abbreviation + :param provider_id: The provider ID + :param sequence_number: The stream sequence number + :param event_name: The event type (INSERT, MODIFY, REMOVE) + :param include_old_image: Whether to include OldImage (False for INSERT events) + """ + image_data = { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + 'compact': {'S': compact}, + 'providerId': {'S': provider_id}, + 'type': {'S': 'provider'}, + 'givenName': {'S': f'test{compact}GivenName'}, + 'familyName': {'S': f'test{compact}FamilyName'}, + } + + dynamodb_data = { + 'ApproximateCreationDateTime': 1234567890, + 'Keys': { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + }, + 'NewImage': image_data, + 'SequenceNumber': sequence_number, + 'SizeBytes': 256, + 'StreamViewType': 'NEW_AND_OLD_IMAGES', + } + + # Include OldImage only if requested (MODIFY events have both, INSERT events only have NewImage) + if include_old_image: + dynamodb_data['OldImage'] = image_data + + return { + 'eventID': f'event-{sequence_number}', + 'eventName': event_name, + 'eventVersion': '1.1', + 'eventSource': 'aws:dynamodb', + 'awsRegion': 'us-east-1', + 'dynamodb': dynamodb_data, + 'eventSourceARN': 'arn:aws:dynamodb:us-east-1:123456789012:table/provider-table/stream/1234', + } + + def _when_testing_mock_opensearch_client(self, mock_opensearch_client, bulk_index_response: dict = None): + """Helper to configure the mock OpenSearch client.""" + if not bulk_index_response: + bulk_index_response = {'items': [], 'errors': False} + + mock_opensearch_client.bulk_index.return_value = bulk_index_response + mock_opensearch_client.delete_provider_documents.return_value = {'deleted': 0, 'failures': []} + return mock_opensearch_client + + def _generate_expected_document(self, compact: str, provider_id: str = None) -> dict: + """Generate the expected document that should be indexed into OpenSearch.""" + if provider_id is None: + provider_id = TEST_PROVIDER_ID_MAPPING[compact] + + license_type = TEST_LICENSE_TYPE_MAPPING[compact] + return { + 'providerId': provider_id, + 'type': 'provider', + 'dateOfUpdate': DEFAULT_PROVIDER_UPDATE_DATETIME, + 'compact': compact, + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'inactive', + 'compactEligibility': 'ineligible', + 'givenName': f'test{compact}GivenName', + 'middleName': 'Gunnar', + 'familyName': f'test{compact}FamilyName', + 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'birthMonthDay': '06-06', + 'adverseActions': [], + 'documentId': f'{provider_id}#oh#{license_type}', + 'licenses': [ + { + 'providerId': provider_id, + 'type': 'license', + 'dateOfUpdate': DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + 'compact': compact, + 'jurisdiction': 'oh', + 'licenseType': license_type, + 'licenseStatusName': 'DEFINITELY_A_HUMAN', + 'licenseStatus': 'inactive', + 'jurisdictionUploadedLicenseStatus': 'active', + 'compactEligibility': 'ineligible', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'licenseNumber': 'A0608337260', + 'givenName': f'test{compact}GivenName', + 'middleName': 'Gunnar', + 'mostRecentLicenseForType': True, + 'familyName': f'test{compact}FamilyName', + 'dateOfIssuance': DEFAULT_LICENSE_ISSUANCE_DATE, + 'dateOfRenewal': DEFAULT_LICENSE_RENEWAL_DATE, + 'dateOfExpiration': DEFAULT_LICENSE_EXPIRATION_DATE, + 'dateOfBirth': DEFAULT_DATE_OF_BIRTH, + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'adverseActions': [], + 'investigations': [], + } + ], + 'privileges': [], + } + + def _create_dynamodb_stream_record_with_old_image_only( + self, compact: str, provider_id: str, sequence_number: str + ) -> dict: + """Create a DynamoDB stream record for REMOVE events (only OldImage, no NewImage).""" + image_data = { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + 'compact': {'S': compact}, + 'providerId': {'S': provider_id}, + 'type': {'S': 'provider'}, + 'givenName': {'S': f'test{compact}GivenName'}, + 'familyName': {'S': f'test{compact}FamilyName'}, + } + + return { + 'eventID': f'event-{sequence_number}', + 'eventName': 'REMOVE', + 'eventVersion': '1.1', + 'eventSource': 'aws:dynamodb', + 'awsRegion': 'us-east-1', + 'dynamodb': { + 'ApproximateCreationDateTime': 1234567890, + 'Keys': { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + }, + 'OldImage': image_data, + 'SequenceNumber': sequence_number, + 'SizeBytes': 256, + 'StreamViewType': 'NEW_AND_OLD_IMAGES', + }, + 'eventSourceARN': 'arn:aws:dynamodb:us-east-1:123456789012:table/provider-table/stream/1234', + } + + # ---- INSERT/MODIFY path tests ---- + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_opensearch_client_called_with_expected_parameters(self, mock_opensearch_client): + """Test that OpenSearch client is called with expected parameters when indexing a record.""" + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + self._put_test_provider_and_license_record_in_dynamodb_table('socw') + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) + + call_args = mock_opensearch_client.bulk_index.call_args + self.assertEqual('compact_socw_providers', call_args.kwargs['index_name']) + self.assertEqual([self._generate_expected_document('socw')], call_args.kwargs['documents']) + self.assertEqual('documentId', call_args.kwargs['id_field']) + + self.assertEqual({'batchItemFailures': []}, result) + + def _put_provider_with_two_socw_licenses_oh_newer_than_ky(self): + """Provider + OH cosmetologist (default dates) + KY cosmetologist (older issuance/renewal).""" + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'compact': 'socw', + 'providerId': MOCK_SOCW_PROVIDER_ID, + 'givenName': 'testcosmGivenName', + 'familyName': 'testcosmFamilyName', + }, + date_of_update_override=DEFAULT_PROVIDER_UPDATE_DATETIME, + ) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'compact': 'socw', + 'providerId': MOCK_SOCW_PROVIDER_ID, + 'givenName': 'testcosmGivenName', + 'familyName': 'testcosmFamilyName', + 'licenseType': TEST_LICENSE_TYPE_MAPPING['socw'], + 'jurisdiction': 'oh', + }, + date_of_update_override=DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + ) + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={ + 'compact': 'socw', + 'providerId': MOCK_SOCW_PROVIDER_ID, + 'givenName': 'testcosmGivenName', + 'familyName': 'testcosmFamilyName', + 'licenseType': TEST_LICENSE_TYPE_MAPPING['socw'], + 'jurisdiction': 'ky', + 'licenseNumber': 'KY-SOCW-001', + 'dateOfIssuance': date(2005, 1, 1), + 'dateOfRenewal': date(2010, 6, 1), + 'dateOfExpiration': date.fromisoformat(DEFAULT_LICENSE_EXPIRATION_DATE), + }, + date_of_update_override=DEFAULT_LICENSE_UPDATE_DATE_OF_UPDATE, + ) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_home_state_license_is_set_as_most_recent(self, mock_opensearch_client): + """Documents for providers with multiple licenses have the home state license indexed with + mostRecentLicenseForType set to true. All other licenses have mostRecentLicenseForType set to false.""" + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + self._put_provider_with_two_socw_licenses_oh_newer_than_ky() + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual({'batchItemFailures': []}, result) + self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) + documents = mock_opensearch_client.bulk_index.call_args.kwargs['documents'] + self.assertEqual(2, len(documents)) + documents_by_id = {doc['documentId']: doc for doc in documents} + oh_id = f'{MOCK_SOCW_PROVIDER_ID}#oh#cosmetologist' + ky_id = f'{MOCK_SOCW_PROVIDER_ID}#ky#cosmetologist' + self.assertTrue(documents_by_id[oh_id]['licenses'][0]['mostRecentLicenseForType']) + self.assertFalse(documents_by_id[ky_id]['licenses'][0]['mostRecentLicenseForType']) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_provider_ids_are_deduped_only_one_document_indexed(self, mock_opensearch_client): + """Test that duplicate provider IDs in the batch are deduplicated.""" + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + self._put_test_provider_and_license_record_in_dynamodb_table('socw') + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number-1', + event_name='INSERT', + ) + ), + }, + { + 'messageId': '12346', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number-2', + event_name='MODIFY', + ) + ), + }, + { + 'messageId': '12347', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number-3', + event_name='MODIFY', + ) + ), + }, + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) + + call_args = mock_opensearch_client.bulk_index.call_args + self.assertEqual(1, len(call_args.kwargs['documents'])) + self.assertEqual(MOCK_SOCW_PROVIDER_ID, call_args.kwargs['documents'][0]['providerId']) + self.assertEqual('documentId', call_args.kwargs['id_field']) + + self.assertEqual({'batchItemFailures': []}, result) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_validation_failure_returns_batch_item_failure(self, mock_opensearch_client): + """Test that a record that fails validation is returned in batchItemFailures.""" + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + provider = self.test_data_generator.generate_default_provider( + value_overrides={ + 'compact': 'socw', + 'providerId': MOCK_SOCW_PROVIDER_ID, + 'givenName': 'testGivenName', + 'familyName': 'testFamilyName', + } + ) + serialized_provider = provider.serialize_to_database_record() + serialized_provider['compact'] = 'foo' + self.config.provider_table.put_item(Item=serialized_provider) + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual(1, len(result['batchItemFailures'])) + self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_opensearch_indexing_failure_returns_batch_item_failure(self, mock_opensearch_client): + """Test that a record which fails to be indexed by OpenSearch is in batchItemFailures.""" + from handlers.provider_update_ingest import provider_update_ingest_handler + + document_id = f'{MOCK_SOCW_PROVIDER_ID}#oh#cosmetologist' + mock_opensearch_client.bulk_index.return_value = { + 'errors': True, + 'items': [ + { + 'index': { + '_id': document_id, + '_index': 'compact_socw_providers', + 'status': 400, + 'error': { + 'type': 'mapper_parsing_exception', + 'reason': 'failed to parse field', + }, + } + }, + ], + } + + self._put_test_provider_and_license_record_in_dynamodb_table('socw') + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number-1', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual(1, len(result['batchItemFailures'])) + self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_bulk_index_exception_returns_all_batch_item_failures(self, mock_opensearch_client): + """Test that when bulk_index raises an exception, all providers are marked as failed.""" + from cc_common.exceptions import CCInternalException + from handlers.provider_update_ingest import provider_update_ingest_handler + + mock_opensearch_client.bulk_index.side_effect = CCInternalException('Connection timeout after 5 retries') + + self._put_test_provider_and_license_record_in_dynamodb_table('socw') + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number-1', + ) + ), + }, + { + 'messageId': '12346', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number-2', + ) + ), + }, + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual(2, len(result['batchItemFailures'])) + self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) + self.assertEqual('12346', result['batchItemFailures'][1]['itemIdentifier']) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_empty_records_returns_empty_batch_failures(self, mock_opensearch_client): + """Test that an empty Records list returns empty batchItemFailures.""" + from handlers.provider_update_ingest import provider_update_ingest_handler + + event = {'Records': []} + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual({'batchItemFailures': []}, result) + mock_opensearch_client.bulk_index.assert_not_called() + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_insert_event_without_old_image_indexes_successfully(self, mock_opensearch_client): + """Test that INSERT events (newly created records) without OldImage are processed correctly.""" + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + self._put_test_provider_and_license_record_in_dynamodb_table('socw') + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + event_name='INSERT', + include_old_image=False, + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) + + call_args = mock_opensearch_client.bulk_index.call_args + self.assertEqual('compact_socw_providers', call_args.kwargs['index_name']) + self.assertEqual([self._generate_expected_document('socw')], call_args.kwargs['documents']) + self.assertEqual('documentId', call_args.kwargs['id_field']) + + # No delete_provider_documents should be called for INSERT events + mock_opensearch_client.delete_provider_documents.assert_not_called() + + self.assertEqual({'batchItemFailures': []}, result) + + # ---- REMOVE event path tests ---- + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_remove_event_with_remaining_records_deletes_then_reindexes(self, mock_opensearch_client): + """Test that REMOVE events trigger delete_provider_documents then re-index remaining records. + + When a single record (e.g., a license) is deleted but the provider still has other records + in DynamoDB, the handler should: + 1. Call delete_provider_documents to remove all documents for the provider + 2. Re-check DynamoDB and find the provider still exists + 3. Re-index the remaining license documents + """ + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Provider still exists in DynamoDB with remaining records + self._put_test_provider_and_license_record_in_dynamodb_table('socw') + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record_with_old_image_only( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + # delete_provider_documents should be called to remove all existing docs for this provider + mock_opensearch_client.delete_provider_documents.assert_called_once_with( + index_name='compact_socw_providers', + provider_id=MOCK_SOCW_PROVIDER_ID, + ) + + # bulk_index should be called with the remaining documents + self.assertEqual(1, mock_opensearch_client.bulk_index.call_count) + call_args = mock_opensearch_client.bulk_index.call_args + self.assertEqual('compact_socw_providers', call_args.kwargs['index_name']) + self.assertEqual([self._generate_expected_document('socw')], call_args.kwargs['documents']) + self.assertEqual('documentId', call_args.kwargs['id_field']) + + self.assertEqual({'batchItemFailures': []}, result) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_remove_event_provider_fully_deleted_no_reindex(self, mock_opensearch_client): + """Test that REMOVE events for a fully deleted provider just delete from OpenSearch. + + When a REMOVE event occurs and the provider no longer exists in DynamoDB at all, + the handler should: + 1. Call delete_provider_documents to remove all documents for the provider + 2. Re-check DynamoDB and find the provider does NOT exist + 3. NOT attempt to re-index + """ + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Do NOT create any provider records in DynamoDB - provider is fully deleted + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record_with_old_image_only( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + # delete_provider_documents should be called + mock_opensearch_client.delete_provider_documents.assert_called_once_with( + index_name='compact_socw_providers', + provider_id=MOCK_SOCW_PROVIDER_ID, + ) + + # bulk_index should NOT be called (provider no longer exists) + mock_opensearch_client.bulk_index.assert_not_called() + + self.assertEqual({'batchItemFailures': []}, result) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_delete_provider_documents_failure_returns_batch_item_failure(self, mock_opensearch_client): + """Test that when delete_provider_documents fails, the provider is returned in batchItemFailures.""" + from cc_common.exceptions import CCInternalException + from handlers.provider_update_ingest import provider_update_ingest_handler + + mock_opensearch_client.delete_provider_documents.side_effect = CCInternalException( + 'Connection timeout after 5 retries' + ) + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record_with_old_image_only( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + self.assertEqual(1, len(result['batchItemFailures'])) + self.assertEqual('12345', result['batchItemFailures'][0]['itemIdentifier']) + + @patch('handlers.provider_update_ingest.opensearch_client') + def test_cc_not_found_on_non_remove_event_logs_warning_no_reindex(self, mock_opensearch_client): + """Test that CCNotFoundException on a non-REMOVE event logs a warning without re-indexing. + + This is a safety net for race conditions where a MODIFY/INSERT event arrives but the + provider has already been deleted from DynamoDB. The handler should log a warning + and NOT attempt to re-index. + """ + from handlers.provider_update_ingest import provider_update_ingest_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Do NOT create any provider records in DynamoDB - simulates race condition + # where provider was deleted between event creation and processing + + event = { + 'Records': [ + { + 'messageId': '12345', + 'body': json.dumps( + self._create_dynamodb_stream_record( + compact='socw', + provider_id=MOCK_SOCW_PROVIDER_ID, + sequence_number='some-sequence-number', + event_name='MODIFY', + ) + ), + } + ] + } + + mock_context = MagicMock() + result = provider_update_ingest_handler(event, mock_context) + + # delete_provider_documents should be called to remove documents from OpenSearch + mock_opensearch_client.delete_provider_documents.assert_called_once_with( + index_name='compact_socw_providers', + provider_id=MOCK_SOCW_PROVIDER_ID, + ) + + # No bulk_index should be called (no documents to index) + mock_opensearch_client.bulk_index.assert_not_called() + + # No batch failures - this is expected behavior for a race condition + self.assertEqual({'batchItemFailures': []}, result) diff --git a/backend/social-work-app/lambdas/python/search/tests/function/test_public_search_providers.py b/backend/social-work-app/lambdas/python/search/tests/function/test_public_search_providers.py new file mode 100644 index 0000000000..397af7e317 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/function/test_public_search_providers.py @@ -0,0 +1,1049 @@ +import json +from datetime import date +from unittest.mock import patch + +from common_test.test_constants import ( + DEFAULT_LICENSE_ISSUANCE_DATE, + DEFAULT_LICENSE_UPDATE_DATETIME, + DEFAULT_PROVIDER_UPDATE_DATETIME, +) +from moto import mock_aws + +from . import TstFunction + +# Public search always scopes nested license clauses to the per-type "home" indexed row. +_MOST_RECENT_LICENSE_FOR_TYPE_TERM = {'term': {'licenses.mostRecentLicenseForType': True}} + +_DEFAULT_PUBLIC_SEARCH_SORT_FAMILY_NAME_ASC = [ + {'licenses.familyName.keyword': {'order': 'asc', 'nested': {'path': 'licenses'}}}, + {'licenses.givenName.keyword': {'order': 'asc', 'nested': {'path': 'licenses'}}}, + {'providerId': 'asc'}, + {'_id': 'asc'}, +] + +_DEFAULT_PUBLIC_SEARCH_SORT_FAMILY_NAME_DESC = [ + {'licenses.familyName.keyword': {'order': 'desc', 'nested': {'path': 'licenses'}}}, + {'licenses.givenName.keyword': {'order': 'desc', 'nested': {'path': 'licenses'}}}, + {'providerId': 'desc'}, + {'_id': 'asc'}, +] + +_PUBLIC_SEARCH_SORT_DATE_OF_UPDATE_ASC = [{'dateOfUpdate': 'asc'}, {'_id': 'asc'}] +_PUBLIC_SEARCH_SORT_DATE_OF_UPDATE_DESC = [{'dateOfUpdate': 'desc'}, {'_id': 'asc'}] + + +@mock_aws +class TestPublicSearchProviders(TstFunction): + """Test suite for public_search_api_handler - public license search via OpenSearch.""" + + def setUp(self): + super().setUp() + + @staticmethod + def _expected_public_search_request_body( + *, + licenses_nested_must: list, + page_size: int = 10, + sort: list | None = None, + compact: str = 'socw', + search_after: list | None = None, + ) -> dict: + """Full OpenSearch search body for public license query (must stay aligned with public_search handler).""" + body: dict = { + 'query': { + 'bool': { + 'must': [ + {'term': {'compact': compact}}, + { + 'nested': { + 'path': 'licenses', + 'query': {'bool': {'must': licenses_nested_must}}, + } + }, + ] + } + }, + 'size': page_size, + 'sort': sort or _DEFAULT_PUBLIC_SEARCH_SORT_FAMILY_NAME_ASC, + } + if search_after is not None: + body['search_after'] = search_after + return body + + def _ingest_style_sanitize_opensearch_source(self, source: dict) -> dict: + """ + Mirror production ingest behavior used by provider_update_ingest/populate_provider_documents. + + In prod we build raw docs then do: + ProviderOpenSearchDocumentSchema().load(raw_doc) -> json roundtrip via ResponseEncoder + (see lambdas/python/search/utils.py generate_provider_opensearch_documents). + """ + from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema + from cc_common.utils import ResponseEncoder + + # Only sanitize for supported compacts. Some tests intentionally construct + # mismatched compacts to verify handler filtering; those documents would + # never be produced by ingest and will fail schema validation. + if source.get('compact') != 'socw': + return source + + schema = ProviderOpenSearchDocumentSchema() + sanitized = schema.load(source) + return json.loads(json.dumps(sanitized, cls=ResponseEncoder)) + + def _create_public_api_event(self, compact: str, body: dict = None) -> dict: + """Create API Gateway event for public query providers (no auth).""" + return { + 'resource': '/v1/public/compacts/{compact}/providers/query', + 'path': f'/v1/public/compacts/{compact}/providers/query', + 'httpMethod': 'POST', + 'headers': {'accept': 'application/json', 'content-type': 'application/json'}, + 'multiValueHeaders': {}, + 'queryStringParameters': None, + 'pathParameters': {'compact': compact}, + 'requestContext': { + 'httpMethod': 'POST', + 'resourcePath': '/v1/public/compacts/{compact}/providers/query', + }, + 'body': json.dumps(body) if body else None, + 'isBase64Encoded': False, + } + + def _minimal_opensearch_license( + self, + *, + provider_id: str, + compact: str, + jurisdiction: str, + license_number: str, + license_type: str, + given_name: str, + family_name: str, + date_of_expiration: str, + license_status: str = 'active', + jurisdiction_uploaded_compact_eligibility: str = 'eligible', + adverse_actions: list | None = None, + ) -> dict: + """Nested license object sufficient for ProviderOpenSearchDocumentSchema / LicenseGeneralResponseSchema.""" + return { + 'providerId': provider_id, + 'type': 'license', + 'dateOfUpdate': DEFAULT_LICENSE_UPDATE_DATETIME, + 'compact': compact, + 'jurisdiction': jurisdiction, + 'licenseType': license_type, + 'licenseStatusName': 'OK', + 'licenseStatus': license_status, + 'jurisdictionUploadedLicenseStatus': 'active', + # for simplicity in the test setup, we set this field to whatever was passed + # in for the 'jurisdictionUploadedCompactEligibility' field. It will be recalculated + # to its actual value when run through the '_ingest_style_sanitize_opensearch_source' method + 'compactEligibility': jurisdiction_uploaded_compact_eligibility, + 'jurisdictionUploadedCompactEligibility': jurisdiction_uploaded_compact_eligibility, + 'licenseNumber': license_number, + 'givenName': given_name, + 'familyName': family_name, + 'dateOfIssuance': DEFAULT_LICENSE_ISSUANCE_DATE, + 'dateOfExpiration': date_of_expiration, + 'dateOfBirth': '1985-06-06', + 'homeAddressStreet1': '123 A St.', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'mostRecentLicenseForType': True, + 'adverseActions': adverse_actions if adverse_actions is not None else [], + 'investigations': [], + } + + def _minimal_opensearch_privilege( + self, + *, + provider_id: str, + compact: str, + license_jurisdiction: str, + license_type: str, + privilege_jurisdiction: str, + date_of_expiration: str, + adverse_actions: list | None = None, + ) -> dict: + """Privilege row sufficient for PrivilegeGeneralResponseSchema / ingest sanitize.""" + return { + 'type': 'privilege', + 'providerId': provider_id, + 'compact': compact, + 'jurisdiction': privilege_jurisdiction, + 'licenseJurisdiction': license_jurisdiction, + 'licenseType': license_type, + 'dateOfExpiration': date_of_expiration, + 'adverseActions': adverse_actions if adverse_actions is not None else [], + 'investigations': [], + 'administratorSetStatus': 'active', + 'status': 'active', + } + + def _generate_unlifted_license_adverse_action(self, *, provider_id: str) -> dict: + return { + 'type': 'adverseAction', + 'compact': 'socw', + 'providerId': provider_id, + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'actionAgainst': 'license', + 'effectiveStartDate': '2024-01-01', + 'creationDate': '2024-01-01T00:00:00+00:00', + 'adverseActionId': 'aa-license-unlifted', + 'dateOfUpdate': '2024-01-02T00:00:00+00:00', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['fraud'], + 'submittingUser': {'userId': 'staff-1'}, + } + + def _minimal_opensearch_provider_source( + self, + *, + provider_id: str, + compact: str, + given_name: str, + family_name: str, + license_nested: dict, + provider_adverse_actions: list | None = None, + privileges: list | None = None, + ) -> dict: + """Top-level OpenSearch provider document sufficient for ProviderOpenSearchDocumentSchema.""" + lic_exp = license_nested['dateOfExpiration'] + source = { + 'providerId': provider_id, + 'type': 'provider', + 'dateOfUpdate': DEFAULT_PROVIDER_UPDATE_DATETIME, + 'compact': compact, + 'licenseJurisdiction': license_nested['jurisdiction'], + 'licenseStatus': license_nested['licenseStatus'], + 'compactEligibility': 'eligible', + 'givenName': given_name, + 'familyName': family_name, + 'dateOfExpiration': lic_exp, + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'birthMonthDay': '06-06', + 'licenses': [license_nested], + 'privileges': privileges if privileges is not None else [], + 'adverseActions': provider_adverse_actions or [], + } + return self._ingest_style_sanitize_opensearch_source(source) + + def _create_mock_hit( + self, + provider_id: str = '00000000-0000-0000-0000-000000000001', + compact: str = 'socw', + jurisdiction: str = 'oh', + license_number: str = 'LN123', + family_name: str = 'Doe', + given_name: str = 'John', + sort_values: list = None, + license_type: str = 'cosmetologist', + license_nested: dict | None = None, + provider_adverse_actions: list | None = None, + privileges: list | None = None, + ) -> dict: + """Create a mock OpenSearch hit for one document per license.""" + doc_id = f'{provider_id}#{jurisdiction}#{license_type}' + nested = license_nested or self._minimal_opensearch_license( + provider_id=provider_id, + compact=compact, + jurisdiction=jurisdiction, + license_number=license_number, + license_type=license_type, + given_name=given_name, + family_name=family_name, + date_of_expiration='2035-01-01', + ) + source = self._minimal_opensearch_provider_source( + provider_id=provider_id, + compact=compact, + given_name=given_name, + family_name=family_name, + license_nested=nested, + provider_adverse_actions=provider_adverse_actions, + privileges=privileges, + ) + hit = { + '_index': f'compact_{compact}_providers', + '_id': doc_id, + '_source': source, + } + if sort_values is not None: + hit['sort'] = sort_values + return hit + + @patch('handlers.public_search.opensearch_client') + def test_license_number_search_builds_nested_query(self, mock_opensearch_client): + """Test that licenseNumber in query builds nested term query on licenses.licenseNumber.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN999'}, 'pagination': {'pageSize': 10}}, + ) + public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'term': {'licenses.licenseNumber': 'LN999'}}, + ], + ), + call_body, + ) + + @patch('handlers.public_search.opensearch_client') + def test_jurisdiction_and_name_search_builds_nested_query(self, mock_opensearch_client): + """Test that jurisdiction and familyName build correct nested query.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={ + 'query': {'jurisdiction': 'oh', 'familyName': 'Smith'}, + 'pagination': {'pageSize': 10}, + }, + ) + public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'term': {'licenses.jurisdiction': 'oh'}}, + {'match': {'licenses.familyName': 'Smith'}}, + ], + ), + call_body, + ) + + @patch('handlers.public_search.opensearch_client') + def test_name_only_search_builds_nested_query(self, mock_opensearch_client): + """Test that familyName only builds nested match on licenses.familyName.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'familyName': 'Jones'}, 'pagination': {'pageSize': 10}}, + ) + public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'match': {'licenses.familyName': 'Jones'}}, + ], + ), + call_body, + ) + + @patch('handlers.public_search.opensearch_client') + def test_sort_includes_id_tiebreaker(self, mock_opensearch_client): + """OpenSearch sort includes _id as the fourth tiebreaker for deterministic pagination.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'familyName': 'Doe'}, 'pagination': {'pageSize': 10}}, + ) + public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'match': {'licenses.familyName': 'Doe'}}, + ], + ), + call_body, + ) + sort = call_body['sort'] + self.assertEqual(4, len(sort)) + self.assertEqual({'_id': 'asc'}, sort[3]) + + @patch('handlers.public_search.opensearch_client') + def test_default_sort_is_family_name_ascending(self, mock_opensearch_client): + """Without sorting in request, default is familyName ascending; response echoes sorting.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'familyName': 'Doe'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'match': {'licenses.familyName': 'Doe'}}, + ], + ), + call_body, + ) + body = json.loads(response['body']) + self.assertEqual( + {'key': 'familyName', 'direction': 'ascending'}, + body['sorting'], + ) + + @patch('handlers.public_search.opensearch_client') + def test_family_name_sort_descending(self, mock_opensearch_client): + """sorting key familyName with descending direction maps to OpenSearch desc on name fields.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={ + 'query': {'familyName': 'Doe'}, + 'pagination': {'pageSize': 10}, + 'sorting': {'key': 'familyName', 'direction': 'descending'}, + }, + ) + response = public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'match': {'licenses.familyName': 'Doe'}}, + ], + sort=_DEFAULT_PUBLIC_SEARCH_SORT_FAMILY_NAME_DESC, + ), + call_body, + ) + body = json.loads(response['body']) + self.assertEqual( + {'key': 'familyName', 'direction': 'descending'}, + body['sorting'], + ) + + @patch('handlers.public_search.opensearch_client') + def test_date_of_update_sort_ascending(self, mock_opensearch_client): + """sorting by dateOfUpdate uses top-level date field and _id tiebreaker.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={ + 'query': {'licenseNumber': 'LN999'}, + 'pagination': {'pageSize': 10}, + 'sorting': {'key': 'dateOfUpdate', 'direction': 'ascending'}, + }, + ) + response = public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'term': {'licenses.licenseNumber': 'LN999'}}, + ], + sort=_PUBLIC_SEARCH_SORT_DATE_OF_UPDATE_ASC, + ), + call_body, + ) + body = json.loads(response['body']) + self.assertEqual( + {'key': 'dateOfUpdate', 'direction': 'ascending'}, + body['sorting'], + ) + + @patch('handlers.public_search.opensearch_client') + def test_date_of_update_sort_descending(self, mock_opensearch_client): + """dateOfUpdate descending keeps _id tiebreaker ascending.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={ + 'query': {'licenseNumber': 'LN999'}, + 'pagination': {'pageSize': 10}, + 'sorting': {'key': 'dateOfUpdate', 'direction': 'descending'}, + }, + ) + public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'term': {'licenses.licenseNumber': 'LN999'}}, + ], + sort=_PUBLIC_SEARCH_SORT_DATE_OF_UPDATE_DESC, + ), + call_body, + ) + + @patch('handlers.public_search.opensearch_client') + def test_response_always_contains_sorting_field(self, mock_opensearch_client): + """Successful public search responses include sorting with key and direction.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'jurisdiction': 'oh'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'term': {'licenses.jurisdiction': 'oh'}}, + ], + ), + call_body, + ) + body = json.loads(response['body']) + self.assertIn('sorting', body) + self.assertEqual({'key', 'direction'}, set(body['sorting'].keys())) + + @patch('handlers.public_search.opensearch_client') + def test_unsupported_compact_returns_400(self, mock_opensearch_client): + """Path compact not in config.compacts returns 400 and does not call OpenSearch.""" + from handlers.public_search import public_search_api_handler + + event = self._create_public_api_event( + 'not-a-compact', + body={'query': {'familyName': 'Doe'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('compact', body['message'].lower()) + mock_opensearch_client.search.assert_not_called() + + @patch('handlers.public_search.opensearch_client') + def test_invalid_sort_key_returns_400(self, mock_opensearch_client): + """Unknown sorting.key returns 400.""" + from handlers.public_search import public_search_api_handler + + event = self._create_public_api_event( + 'socw', + body={ + 'query': {'familyName': 'Doe'}, + 'pagination': {'pageSize': 10}, + 'sorting': {'key': 'invalidKey', 'direction': 'ascending'}, + }, + ) + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('Invalid sort key', body['message']) + mock_opensearch_client.search.assert_not_called() + + @patch('handlers.public_search.opensearch_client') + def test_no_search_criteria_returns_200(self, mock_opensearch_client): + """Test that caller can provide an empty query body and still get a successful response.""" + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + + event = self._create_public_api_event( + 'socw', + body={'query': {}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body(licenses_nested_must=[_MOST_RECENT_LICENSE_FOR_TYPE_TERM]), + call_body, + ) + body = json.loads(response['body']) + self.assertEqual( + { + 'pagination': {'lastKey': None, 'pageSize': 10, 'prevLastKey': None}, + 'providers': [], + 'query': {}, + 'sorting': {'direction': 'ascending', 'key': 'familyName'}, + }, + body, + ) + + def test_provider_id_in_query_returns_400(self): + """Public query must not accept query.providerId (blocked at schema validation).""" + from handlers.public_search import public_search_api_handler + + event = self._create_public_api_event( + 'socw', + body={ + 'query': { + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'familyName': 'Doe', + }, + 'pagination': {'pageSize': 10}, + }, + ) + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('providerId', body['message']) + + @patch('handlers.public_search.opensearch_client') + def test_pagination_page_size_maps_to_size_and_search_after_from_last_key(self, mock_opensearch_client): + """Test that pageSize maps to size and lastKey decodes to search_after.""" + from base64 import b64encode + + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + last_key_payload = json.dumps({'search_after': ['doe', 'jane', 'uuid-123', 'uuid-123#oh#cosmetologist']}) + last_key_str = b64encode(last_key_payload.encode('utf-8')).decode('utf-8') + event = self._create_public_api_event( + 'socw', + body={ + 'query': {'familyName': 'Doe'}, + 'pagination': {'pageSize': 25, 'lastKey': last_key_str}, + }, + ) + public_search_api_handler(event, self.mock_context) + call_body = mock_opensearch_client.search.call_args.kwargs['body'] + self.assertEqual( + self._expected_public_search_request_body( + licenses_nested_must=[ + _MOST_RECENT_LICENSE_FOR_TYPE_TERM, + {'match': {'licenses.familyName': 'Doe'}}, + ], + page_size=25, + search_after=['doe', 'jane', 'uuid-123', 'uuid-123#oh#cosmetologist'], + ), + call_body, + ) + + @patch('handlers.public_search.opensearch_client') + def test_response_last_key_encodes_last_hit_sort_when_full_page(self, mock_opensearch_client): + """When OpenSearch returns a full page of hits, lastKey encodes search_after from the last hit.""" + from base64 import b64decode + + from handlers.public_search import public_search_api_handler + + mock_hits_full_page = [] + for i in range(5): + sort_i = [ + 'doe', + 'john', + f'00000000-0000-0000-0000-00000000000{i}', + f'00000000-0000-0000-0000-00000000000{i}#oh#cosmetologist', + ] + mock_hits_full_page.append( + self._create_mock_hit( + provider_id=f'00000000-0000-0000-0000-00000000000{i}', + sort_values=sort_i, + ) + ) + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 10, 'relation': 'eq'}, 'hits': mock_hits_full_page}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'familyName': 'Doe'}, 'pagination': {'pageSize': 5}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertIn('lastKey', body['pagination']) + self.assertIsNotNone(body['pagination']['lastKey']) + decoded = json.loads(b64decode(body['pagination']['lastKey']).decode('utf-8')) + self.assertEqual(decoded['search_after'], mock_hits_full_page[-1]['sort']) + + @patch('handlers.public_search.opensearch_client') + def test_response_last_key_null_when_fewer_hits_than_page_size(self, mock_opensearch_client): + """When hit count is below pageSize, there are no more pages and lastKey is null.""" + from handlers.public_search import public_search_api_handler + + single_hit = self._create_mock_hit() + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [single_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'familyName': 'Doe'}, 'pagination': {'pageSize': 100}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertIsNone(body['pagination']['lastKey']) + + @patch('handlers.public_search.opensearch_client') + def test_response_contains_only_allowed_license_fields(self, mock_opensearch_client): + """Test that each item in providers has only expected fields.""" + from handlers.public_search import public_search_api_handler + + mock_hit = self._create_mock_hit() + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN123'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual(len(body['providers']), 1) + provider = body['providers'][0] + allowed = { + 'providerId', + 'givenName', + 'familyName', + 'licenseJurisdiction', + 'compact', + 'licenseType', + 'licenseNumber', + 'licenseEligibility', + } + self.assertEqual(set(provider.keys()), allowed) + self.assertEqual(provider['licenseJurisdiction'], 'oh') + self.assertEqual(provider['licenseType'], 'cosmetologist') + self.assertEqual(provider['licenseNumber'], 'LN123') + self.assertEqual(provider['licenseEligibility'], 'eligible') + + @patch('handlers.public_search.opensearch_client') + @patch('cc_common.config._Config.expiration_resolution_date', date(2030, 1, 1)) + def test_license_eligibility_ineligible_when_license_expired(self, mock_opensearch_client): + """Expired license (inactive after schema correction) yields licenseEligibility ineligible.""" + from handlers.public_search import public_search_api_handler + + pid = '00000000-0000-0000-0000-0000000000aa' + nested = self._minimal_opensearch_license( + provider_id=pid, + compact='socw', + jurisdiction='oh', + license_number='LN-EXP', + license_type='cosmetologist', + given_name='John', + family_name='Doe', + date_of_expiration='2020-01-01', + license_status='active', + ) + mock_hit = self._create_mock_hit( + provider_id=pid, + license_number='LN-EXP', + license_nested=nested, + ) + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN-EXP'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual('ineligible', body['providers'][0]['licenseEligibility']) + + @patch('handlers.public_search.opensearch_client') + @patch('cc_common.config._Config.expiration_resolution_date', date(2030, 1, 1)) + def test_license_eligibility_eligible_when_no_unlifted_adverse_action_on_license_or_privileges( + self, mock_opensearch_client + ): + """LicenseEligibility is eligible when there are no unlifted adverse actions on the license or privileges.""" + from handlers.public_search import public_search_api_handler + + pid = '00000000-0000-0000-0000-0000000000bb' + # create a unlifted adverse action for another license + # which should not be considered + unlifted = { + 'type': 'adverseAction', + 'compact': 'socw', + 'providerId': pid, + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'esthetician', + 'actionAgainst': 'license', + 'effectiveStartDate': '2024-01-01', + 'creationDate': '2024-01-01T00:00:00+00:00', + 'adverseActionId': 'aa-unlifted', + 'dateOfUpdate': '2024-01-02T00:00:00+00:00', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['fraud'], + 'submittingUser': {'userId': 'staff-1'}, + } + mock_hit = self._create_mock_hit( + provider_id=pid, + license_number='LN-AA', + provider_adverse_actions=[unlifted], + ) + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN-AA'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual(body['providers'][0]['licenseEligibility'], 'eligible') + + @patch('handlers.public_search.opensearch_client') + @patch('cc_common.config._Config.expiration_resolution_date', date(2030, 1, 1)) + def test_license_eligibility_set_to_ineligible_if_adverse_action_on_license(self, mock_opensearch_client): + """Unlifted adverse action on the indexed license row marks licenseEligibility ineligible.""" + from handlers.public_search import public_search_api_handler + + pid = '00000000-0000-0000-0000-0000000000ee' + unlifted = self._generate_unlifted_license_adverse_action(provider_id=pid) + nested = self._minimal_opensearch_license( + provider_id=pid, + compact='socw', + jurisdiction='oh', + license_number='LN-LIC-AA', + license_type='cosmetologist', + given_name='John', + family_name='Doe', + date_of_expiration='2035-01-01', + adverse_actions=[unlifted], + ) + mock_hit = self._create_mock_hit( + provider_id=pid, + license_number='LN-LIC-AA', + license_nested=nested, + provider_adverse_actions=[], + ) + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN-LIC-AA'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual('ineligible', body['providers'][0]['licenseEligibility']) + + @patch('handlers.public_search.opensearch_client') + @patch('cc_common.config._Config.expiration_resolution_date', date(2030, 1, 1)) + def test_license_eligibility_set_to_ineligible_if_unlifted_adverse_action_on_privilege_for_license( + self, mock_opensearch_client + ): + """Unlifted adverse action on a privilege bundled with the license doc marks licenseEligibility ineligible.""" + from handlers.public_search import public_search_api_handler + + pid = '00000000-0000-0000-0000-0000000000ff' + unlifted = { + 'type': 'adverseAction', + 'compact': 'socw', + 'providerId': pid, + 'jurisdiction': 'mi', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'actionAgainst': 'privilege', + 'effectiveStartDate': '2024-01-01', + 'creationDate': '2024-01-01T00:00:00+00:00', + 'adverseActionId': 'aa-priv-unlifted', + 'dateOfUpdate': '2024-01-02T00:00:00+00:00', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['fraud'], + 'submittingUser': {'userId': 'staff-1'}, + } + nested = self._minimal_opensearch_license( + provider_id=pid, + compact='socw', + jurisdiction='oh', + license_number='LN-PRIV-AA', + license_type='cosmetologist', + given_name='John', + family_name='Doe', + date_of_expiration='2035-01-01', + adverse_actions=[], + ) + privilege = self._minimal_opensearch_privilege( + provider_id=pid, + compact='socw', + license_jurisdiction='oh', + license_type='cosmetologist', + privilege_jurisdiction='mi', + date_of_expiration='2035-01-01', + adverse_actions=[unlifted], + ) + mock_hit = self._create_mock_hit( + provider_id=pid, + license_number='LN-PRIV-AA', + license_nested=nested, + provider_adverse_actions=[], + privileges=[privilege], + ) + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN-PRIV-AA'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual('ineligible', body['providers'][0]['licenseEligibility']) + + @patch('handlers.public_search.opensearch_client') + @patch('cc_common.config._Config.expiration_resolution_date', date(2030, 1, 1)) + def test_license_eligibility_ineligible_when_jurisdiction_uploaded_ineligible(self, mock_opensearch_client): + """jurisdictionUploadedCompactEligibility ineligible on the matched license yields ineligible.""" + from handlers.public_search import public_search_api_handler + + pid = '00000000-0000-0000-0000-0000000000cc' + nested = self._minimal_opensearch_license( + provider_id=pid, + compact='socw', + jurisdiction='oh', + license_number='LN-JUR', + license_type='cosmetologist', + given_name='John', + family_name='Doe', + date_of_expiration='2035-01-01', + jurisdiction_uploaded_compact_eligibility='ineligible', + ) + mock_hit = self._create_mock_hit( + provider_id=pid, + license_number='LN-JUR', + license_nested=nested, + ) + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN-JUR'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual('ineligible', body['providers'][0]['licenseEligibility']) + + @patch('handlers.public_search.opensearch_client') + @patch('cc_common.config._Config.expiration_resolution_date', date(2030, 1, 1)) + def test_license_eligibility_eligible_when_no_blocking_factors(self, mock_opensearch_client): + """Active license, eligible jurisdiction upload, lifted adverse only -> eligible.""" + from handlers.public_search import public_search_api_handler + + pid = '00000000-0000-0000-0000-0000000000dd' + lifted = { + 'type': 'adverseAction', + 'compact': 'socw', + 'providerId': pid, + 'jurisdiction': 'oh', + 'licenseTypeAbbreviation': 'cos', + 'licenseType': 'cosmetologist', + 'actionAgainst': 'license', + 'effectiveStartDate': '2024-01-01', + 'creationDate': '2024-01-01T00:00:00+00:00', + 'adverseActionId': 'aa-lifted', + 'dateOfUpdate': '2024-06-01T00:00:00+00:00', + 'effectiveLiftDate': '2024-06-01', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['fraud'], + 'submittingUser': {'userId': 'staff-1'}, + } + mock_hit = self._create_mock_hit( + provider_id=pid, + license_number='LN-OK', + provider_adverse_actions=[lifted], + ) + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'licenseNumber': 'LN-OK'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual(body['providers'][0]['licenseEligibility'], 'eligible') + + @patch('handlers.public_search.opensearch_client') + def test_compact_mismatch_filtered_out(self, mock_opensearch_client): + """Test that hits with compact != path compact are not included in results.""" + from handlers.public_search import public_search_api_handler + + mock_hit = self._create_mock_hit(compact='other') + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'hits': [mock_hit]}, + } + event = self._create_public_api_event( + 'socw', + body={'query': {'familyName': 'Doe'}, 'pagination': {'pageSize': 10}}, + ) + response = public_search_api_handler(event, self.mock_context) + body = json.loads(response['body']) + self.assertEqual(body['providers'], []) + + def test_invalid_request_body_returns_400(self): + """Test that invalid or missing body returns 400.""" + from handlers.public_search import public_search_api_handler + + event = self._create_public_api_event('socw', body=None) + event['body'] = 'not valid json' + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('Invalid request', body['message']) + + def test_unsupported_route_returns_400(self): + """Test that wrong method/path returns 400.""" + from handlers.public_search import public_search_api_handler + + event = self._create_public_api_event('socw', body={'query': {'familyName': 'x'}}) + event['resource'] = '/v1/public/compacts/{compact}/providers/other' + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(400, response['statusCode']) + self.assertIn('Unsupported method or resource', json.loads(response['body'])['message']) + + @patch('handlers.public_search.opensearch_client') + def test_invalid_last_key_format_returns_400(self, mock_opensearch_client): + """Malformed or invalid lastKey must return 400.""" + from base64 import b64encode + + from handlers.public_search import public_search_api_handler + + mock_opensearch_client.search.return_value = { + 'hits': {'total': {'value': 0, 'relation': 'eq'}, 'hits': []}, + } + bad_payload = json.dumps({}) + last_key = b64encode(bad_payload.encode('utf-8')).decode('utf-8') + event = self._create_public_api_event( + 'socw', + body={ + 'query': {'familyName': 'Doe'}, + 'pagination': {'pageSize': 10, 'lastKey': last_key}, + }, + ) + response = public_search_api_handler(event, self.mock_context) + self.assertEqual(response['statusCode'], 400) + body = json.loads(response['body']) + self.assertIn('lastkey', body['message'].lower()) + mock_opensearch_client.search.assert_not_called() diff --git a/backend/social-work-app/lambdas/python/search/tests/function/test_search_providers.py b/backend/social-work-app/lambdas/python/search/tests/function/test_search_providers.py new file mode 100644 index 0000000000..795ea74ec1 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/function/test_search_providers.py @@ -0,0 +1,690 @@ +import json +from unittest.mock import patch + +from cc_common.exceptions import CCInvalidRequestException +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestSearchProviders(TstFunction): + """Test suite for search_api_handler - provider search functionality.""" + + def setUp(self): + super().setUp() + + def _create_api_event( + self, + compact: str, + body: dict = None, + scopes_override: str = None, + ) -> dict: + """Create a standard API Gateway event for search_providers.""" + return { + 'resource': '/v1/compacts/{compact}/providers/search', + 'path': f'/v1/compacts/{compact}/providers/search', + 'httpMethod': 'POST', + 'headers': { + 'accept': 'application/json', + 'content-type': 'application/json', + 'Content-Type': 'application/json', + 'origin': 'https://example.org', + 'Host': 'api.test.example.com', + }, + 'multiValueHeaders': {}, + 'queryStringParameters': None, + 'pathParameters': {'compact': compact}, + 'requestContext': { + 'resourcePath': '/v1/compacts/{compact}/providers/search', + 'httpMethod': 'POST', + 'authorizer': { + 'claims': { + 'sub': 'test-user-id', + 'cognito:username': 'test-user', + 'scope': f'openid email {compact}/readGeneral' if not scopes_override else scopes_override, + } + }, + }, + 'body': json.dumps(body) if body else None, + 'isBase64Encoded': False, + } + + def _when_testing_mock_opensearch_client(self, mock_opensearch_client, search_response: dict = None): + """ + Configure the mock OpenSearchClient for testing. + + :param mock_opensearch_client: The patched opensearch_client instance + :param search_response: The response to return from the search method + :return: The mock client instance + """ + if not search_response: + search_response = { + 'hits': { + 'total': {'value': 0, 'relation': 'eq'}, + 'hits': [], + } + } + + # mock_opensearch_client is the patched instance, not the class + mock_opensearch_client.search.return_value = search_response + return mock_opensearch_client + + def _create_mock_provider_hit( + self, + provider_id: str = '00000000-0000-0000-0000-000000000001', + compact: str = 'socw', + sort_values: list = None, + ) -> dict: + """Create a mock OpenSearch hit for a one-doc-per-license provider document.""" + document_id = f'{provider_id}#oh#cosmetologist' + hit = { + '_index': f'compact_{compact}_providers', + '_id': document_id, + '_score': 1.0, + '_source': { + 'providerId': provider_id, + 'type': 'provider', + 'dateOfUpdate': '2024-01-15T10:30:00+00:00', + 'compact': compact, + 'licenseJurisdiction': 'oh', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfExpiration': '2025-12-31', + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'birthMonthDay': '06-15', + 'documentId': document_id, + 'licenses': [ + { + 'providerId': provider_id, + 'type': 'license', + 'compact': compact, + 'jurisdiction': 'oh', + 'licenseType': 'cosmetologist', + 'licenseNumber': 'A0608337260', + 'givenName': 'John', + 'familyName': 'Doe', + 'dateOfIssuance': '2024-01-01', + 'dateOfExpiration': '2025-12-31', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'jurisdictionUploadedLicenseStatus': 'active', + 'jurisdictionUploadedCompactEligibility': 'eligible', + 'dateOfUpdate': '2024-01-15T10:30:00+00:00', + 'dateOfBirth': '1984-12-11', + 'homeAddressStreet1': '123 Main St', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + } + ], + 'privileges': [], + # Fields that should be stripped by ForgivingSchema + 'someNewField': 'somePrivateValue', + 'ssnLastFour': '1234', + 'emailAddress': 'someemail@address.com', + }, + } + if sort_values: + hit['sort'] = sort_values + return hit + + @patch('handlers.search.opensearch_client') + def test_basic_search_with_match_all_query(self, mock_opensearch_client): + """Test that a basic search with no query uses match_all.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Create event with minimal body - just the required query field + event = self._create_api_event(compact='socw', body={'query': {'match_all': {}}}) + + response = search_api_handler(event, self.mock_context) + + # Verify search was called + mock_opensearch_client.search.assert_called_once() + + # Verify the search was called with correct parameters + mock_opensearch_client.search.assert_called_once_with( + index_name='compact_socw_providers', body={'query': {'match_all': {}}, 'size': 100} + ) + + # Verify response structure + self.assertEqual(200, response['statusCode']) + body = json.loads(response['body']) + self.assertEqual({'providers': [], 'total': {'relation': 'eq', 'value': 0}}, body) + + @patch('handlers.search.opensearch_client') + def test_search_with_custom_query(self, mock_opensearch_client): + """Test that a custom OpenSearch query is passed through correctly.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Create a custom bool query + custom_query = { + 'bool': { + 'must': [ + {'match': {'givenName': 'John'}}, + {'term': {'licenseStatus': 'active'}}, + ] + } + } + event = self._create_api_event('socw', body={'query': custom_query, 'from': 20}) + + search_api_handler(event, self.mock_context) + + # Verify the custom query was passed through + mock_opensearch_client.search.assert_called_once_with( + index_name='compact_socw_providers', + body={ + 'query': {'bool': {'must': [{'match': {'givenName': 'John'}}, {'term': {'licenseStatus': 'active'}}]}}, + 'size': 100, + 'from': 20, + }, + ) + + @patch('handlers.search.opensearch_client') + def test_search_size_capped_at_max(self, mock_opensearch_client): + """Test that size parameter is capped at MAX_SIZE (100).""" + from handlers.search import search_api_handler + + # Request size larger than MAX_SIZE + event = self._create_api_event('socw', body={'query': {'match_all': {}}, 'size': 500}) + + result = search_api_handler(event, self.mock_context) + self.assertEqual(400, result['statusCode']) + self.assertEqual( + { + 'message': 'Invalid request: ' + "{'size': ['Must be greater than or equal to 1 and less than or equal to 100.']}" + }, + json.loads(result['body']), + ) + mock_opensearch_client.search.assert_not_called() + + @patch('handlers.search.opensearch_client') + def test_search_with_sort_parameter(self, mock_opensearch_client): + """Test that sort parameter is included in the search body.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + sort_config = [{'providerId': 'asc'}, {'dateOfUpdate': 'desc'}] + search_after_values = ['provider-uuid-123'] + event = self._create_api_event( + 'socw', + body={ + 'query': {'match_all': {}}, + 'sort': sort_config, + 'search_after': search_after_values, + }, + ) + + search_api_handler(event, self.mock_context) + + mock_opensearch_client.search.assert_called_once_with( + index_name='compact_socw_providers', + body={ + 'query': {'match_all': {}}, + 'size': 100, + 'sort': sort_config, + 'search_after': search_after_values, + }, + ) + + @patch('handlers.search.opensearch_client') + def test_search_after_without_sort_returns_400(self, mock_opensearch_client): + """Test that search_after without sort raises an error.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # search_after without sort should fail + event = self._create_api_event( + 'socw', + body={ + 'query': {'match_all': {}}, + 'search_after': ['provider-uuid-123'], + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('sort is required when using search_after pagination', body['message']) + + def test_invalid_request_body_returns_400(self): + """Test that an invalid request body returns a 400 error.""" + from handlers.search import search_api_handler + + # Create event with missing required 'query' field + event = self._create_api_event('socw', body={'size': 10}) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('Invalid request', body['message']) + + @patch('handlers.search.opensearch_client') + def test_search_returns_sanitized_providers(self, mock_opensearch_client): + """Test that provider records are sanitized through ProviderGeneralResponseSchema.""" + from handlers.search import search_api_handler + + # Create a mock response with provider hits + mock_hit = self._create_mock_provider_hit() + search_response = { + 'hits': { + 'total': {'value': 1, 'relation': 'eq'}, + 'hits': [mock_hit], + } + } + self._when_testing_mock_opensearch_client(mock_opensearch_client, search_response=search_response) + + event = self._create_api_event('socw', body={'query': {'match_all': {}}}) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + body = json.loads(response['body']) + providers = body['providers'] + self.assertEqual(1, len(providers)) + provider = providers[0] + # Verify provider-level fields are present and sanitized + self.assertEqual('socw', provider['compact']) + self.assertEqual('John', provider['givenName']) + self.assertEqual('Doe', provider['familyName']) + self.assertEqual('oh', provider['licenseJurisdiction']) + self.assertEqual('active', provider['licenseStatus']) + self.assertEqual('eligible', provider['compactEligibility']) + self.assertEqual('06-15', provider['birthMonthDay']) + self.assertEqual('00000000-0000-0000-0000-000000000001', provider['providerId']) + # Verify licenses array with one license is present + self.assertEqual(1, len(provider['licenses'])) + self.assertEqual('oh', provider['licenses'][0]['jurisdiction']) + self.assertEqual('cosmetologist', provider['licenses'][0]['licenseType']) + # Verify private fields were stripped (list/general view must not expose full DOB) + self.assertNotIn('dateOfBirth', provider) + self.assertNotIn('dateOfBirth', provider['licenses'][0]) + self.assertNotIn('ssnLastFour', provider) + self.assertNotIn('someNewField', provider) + self.assertNotIn('emailAddress', provider) + # Verify documentId was stripped by ForgivingSchema + self.assertNotIn('documentId', provider) + # Verify total + self.assertEqual({'relation': 'eq', 'value': 1}, body['total']) + + @patch('handlers.search.opensearch_client') + def test_search_response_includes_last_sort_for_pagination(self, mock_opensearch_client): + """Test that lastSort is included in response for search_after pagination.""" + from handlers.search import search_api_handler + + # Create hits with sort values + mock_hit = self._create_mock_provider_hit(sort_values=['provider-uuid-123', '2024-01-15T10:30:00+00:00']) + search_response = { + 'hits': { + 'total': {'value': 1, 'relation': 'eq'}, + 'hits': [mock_hit], + } + } + self._when_testing_mock_opensearch_client(mock_opensearch_client, search_response=search_response) + + event = self._create_api_event( + 'socw', + body={ + 'query': {'match_all': {}}, + 'sort': [{'providerId': 'asc'}, {'dateOfUpdate': 'asc'}], + }, + ) + + response = search_api_handler(event, self.mock_context) + + body = json.loads(response['body']) + self.assertIn('lastSort', body) + self.assertEqual(['provider-uuid-123', '2024-01-15T10:30:00+00:00'], body['lastSort']) + + @patch('handlers.search.opensearch_client') + def test_search_uses_correct_index_for_compact(self, mock_opensearch_client): + """Test that the correct index name is used based on the compact parameter.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Test with different compacts + for compact in ['socw']: + mock_opensearch_client.reset_mock() + + event = self._create_api_event(compact, body={'query': {'match_all': {}}}) + search_api_handler(event, self.mock_context) + + call_args = mock_opensearch_client.search.call_args + self.assertEqual(f'compact_{compact}_providers', call_args.kwargs['index_name']) + + def test_missing_scopes_returns_403(self): + """Test that missing auth scope returns a 403 error.""" + from handlers.search import search_api_handler + + # Create event with unsupported route + event = self._create_api_event(compact='socw', scopes_override='openid email') + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(403, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('Access denied', body['message']) + + def test_query_with_index_key_returns_400(self): + """Test that queries containing 'index' key are rejected with 400 error.""" + from handlers.search import search_api_handler + + # Test with 'index' key (terms lookup attack pattern) + event = self._create_api_event( + 'socw', + body={ + 'query': { + 'terms': { + 'providerId': { + 'index': 'compact_socw_providers', + 'id': 'some-uuid', + 'path': 'providerId', + } + } + } + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('Cross-index queries are not allowed', body['message']) + self.assertIn("'index'", body['message']) + + def test_query_with_underscore_index_key_returns_400(self): + """Test that queries containing '_index' key are rejected with 400 error.""" + from handlers.search import search_api_handler + + # Test with '_index' key (more_like_this attack pattern) + event = self._create_api_event( + 'socw', + body={ + 'query': { + 'more_like_this': { + 'fields': ['familyName', 'givenName'], + 'like': [ + { + '_index': 'compact_socw_providers', + '_id': 'target-provider-uuid', + } + ], + } + } + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('Cross-index queries are not allowed', body['message']) + self.assertIn("'_index'", body['message']) + + def test_query_with_nested_index_key_returns_400(self): + """Test that queries with nested 'index' key at any level are rejected.""" + from handlers.search import search_api_handler + + # Test with 'index' key nested deep in the query structure + event = self._create_api_event( + 'socw', + body={ + 'query': { + 'bool': { + 'should': [ + { + 'terms': { + 'familyName.keyword': { + 'index': 'compact_socw_providers', + 'id': 'target-uuid', + 'path': 'familyName.keyword', + } + } + } + ] + } + } + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('Cross-index queries are not allowed', body['message']) + self.assertIn("'index'", body['message']) + + @patch('handlers.search.opensearch_client') + def test_opensearch_request_error_returns_400_with_error_message(self, mock_opensearch_client): + """Test that OpenSearch RequestError with status 400 returns error message to caller.""" + from handlers.search import search_api_handler + + # Create a RequestError with realistic OpenSearch error structure + error_reason = ( + 'Invalid search query: Text fields are not optimised for operations that require per-document field data ' + 'like aggregations and sorting, so these operations are disabled by default. ' + 'Please use a keyword field instead.' + ) + mock_opensearch_client.search.side_effect = CCInvalidRequestException(error_reason) + + event = self._create_api_event( + 'socw', + body={ + 'query': {'match_all': {}}, + 'sort': [{'familyName': 'asc'}], # Sorting on text field causes this error + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertEqual(error_reason, body['message']) + + @patch('handlers.search.opensearch_client') + def test_search_with_date_of_birth_query_allowed_for_compact_level_read_private_scope(self, mock_opensearch_client): + """Test that a query containing dateOfBirth succeeds when the caller has compact-level readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + query = { + 'nested': { + 'path': 'licenses', + 'query': {'term': {'licenses.dateOfBirth': '1985-06-06'}}, + } + } + event = self._create_api_event( + 'socw', + body={'query': query}, + scopes_override='openid email socw/readGeneral socw/readPrivate', + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + mock_opensearch_client.search.assert_called_once() + + @patch('handlers.search.opensearch_client') + def test_search_with_date_of_birth_query_allowed_for_jurisdiction_level_read_private_scope( + self, mock_opensearch_client + ): + """Test that a query containing dateOfBirth succeeds if the caller has jurisdiction-level readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + query = { + 'nested': { + 'path': 'licenses', + 'query': {'term': {'licenses.dateOfBirth': '1985-06-06'}}, + } + } + event = self._create_api_event( + 'socw', + body={'query': query}, + scopes_override='openid email socw/readGeneral oh/socw.readPrivate', + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + mock_opensearch_client.search.assert_called_once() + + def test_search_with_date_of_birth_query_rejected_without_read_private_scope(self): + """Test that a query containing dateOfBirth returns 400 when the caller only has readGeneral scope.""" + from handlers.search import search_api_handler + + query = { + 'nested': { + 'path': 'licenses', + 'query': {'term': {'licenses.dateOfBirth': '1985-06-06'}}, + } + } + event = self._create_api_event('socw', body={'query': query}) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + + def test_search_with_nested_date_of_birth_query_rejected_without_read_private_scope(self): + """Test that deeply nested dateOfBirth references are caught and rejected.""" + from handlers.search import search_api_handler + + query = { + 'bool': { + 'must': [ + {'match': {'givenName': 'John'}}, + { + 'nested': { + 'path': 'licenses', + 'query': { + 'bool': { + 'must': [ + {'term': {'licenses.jurisdiction': 'oh'}}, + {'range': {'licenses.dateOfBirth': {'gte': '1985-01-01'}}}, + ] + } + }, + } + }, + ] + } + } + event = self._create_api_event('socw', body={'query': query}) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + + @patch('handlers.search.opensearch_client') + def test_search_with_exists_field_date_of_birth_rejected_without_read_private_scope(self, mock_opensearch_client): + """Test that query with dateOfBirth as field value (e.g. exists) is rejected without readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # OpenSearch "exists" query references field by value: {"exists": {"field": "dateOfBirth"}} + event = self._create_api_event( + 'socw', + body={ + 'query': {'exists': {'field': 'dateOfBirth'}}, + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + self.assertIn('readPrivate', body['message']) + mock_opensearch_client.search.assert_not_called() + + @patch('handlers.search.opensearch_client') + def test_search_with_date_of_birth_string_in_list_rejected_without_read_private_scope(self, mock_opensearch_client): + """dateOfBirth as a list element (e.g. multi_match fields) must not bypass readPrivate checks.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + event = self._create_api_event( + 'socw', + body={ + 'query': { + 'multi_match': { + 'query': '1985', + 'fields': ['givenName', 'dateOfBirth'], + }, + }, + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + self.assertIn('readPrivate', body['message']) + mock_opensearch_client.search.assert_not_called() + + @patch('handlers.search.opensearch_client') + def test_search_with_sort_by_date_of_birth_rejected_without_read_private_scope(self, mock_opensearch_client): + """Test that sort clause referencing dateOfBirth is rejected when caller lacks readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + # Query does not reference dateOfBirth; only sort does + event = self._create_api_event( + 'socw', + body={ + 'query': {'match_all': {}}, + 'sort': [{'providerId': 'asc'}, {'licenses.dateOfBirth': 'desc'}], + }, + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(400, response['statusCode']) + body = json.loads(response['body']) + self.assertIn('dateOfBirth', body['message']) + self.assertIn('readPrivate', body['message']) + mock_opensearch_client.search.assert_not_called() + + @patch('handlers.search.opensearch_client') + def test_search_with_sort_by_date_of_birth_allowed_with_read_private_scope(self, mock_opensearch_client): + """Test that sort by dateOfBirth succeeds when caller has readPrivate scope.""" + from handlers.search import search_api_handler + + self._when_testing_mock_opensearch_client(mock_opensearch_client) + + event = self._create_api_event( + 'socw', + body={ + 'query': {'match_all': {}}, + 'sort': [{'licenses.dateOfBirth': 'desc'}, {'providerId': 'asc'}], + }, + scopes_override='openid email socw/readGeneral socw/readPrivate', + ) + + response = search_api_handler(event, self.mock_context) + + self.assertEqual(200, response['statusCode']) + mock_opensearch_client.search.assert_called_once() diff --git a/backend/social-work-app/lambdas/python/search/tests/unit/__init__.py b/backend/social-work-app/lambdas/python/search/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/search/tests/unit/test_opensearch_client.py b/backend/social-work-app/lambdas/python/search/tests/unit/test_opensearch_client.py new file mode 100644 index 0000000000..fd19769ebc --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/tests/unit/test_opensearch_client.py @@ -0,0 +1,575 @@ +# ruff: noqa ARG002 unused-argument +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from cc_common.exceptions import CCInternalException, CCInvalidRequestException +from opensearchpy.exceptions import ConnectionTimeout, RequestError, TransportError + + +class TestOpenSearchClient(TestCase): + """Test suite for OpenSearchClient to verify internal client calls.""" + + def _create_client_with_mock(self): + """Create an OpenSearchClient with a mocked internal client.""" + with ( + patch('opensearch_client.boto3'), + patch('opensearch_client.config'), + patch('opensearch_client.OpenSearch') as mock_opensearch_class, + ): + mock_internal_client = MagicMock() + mock_opensearch_class.return_value = mock_internal_client + + from opensearch_client import OpenSearchClient + + client = OpenSearchClient() + return client, mock_internal_client + + def test_create_index_calls_internal_client_with_expected_arguments(self): + """Test that create_index calls the internal client's indices.create method correctly.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + index_mapping = { + 'settings': {'number_of_shards': 1}, + 'mappings': {'properties': {'field1': {'type': 'text'}}}, + } + + client.create_index(index_name=index_name, index_mapping=index_mapping) + + mock_internal_client.indices.create.assert_called_once_with( + index=index_name, + body=index_mapping, + ) + + def test_index_exists_calls_internal_client_with_expected_arguments(self): + """Test that index_exists calls the internal client's indices.exists method correctly.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + mock_internal_client.indices.exists.return_value = True + + result = client.index_exists(index_name=index_name) + + mock_internal_client.indices.exists.assert_called_once_with(index=index_name) + self.assertTrue(result) + + def test_alias_exists_calls_internal_client_with_expected_arguments(self): + """Test that alias_exists calls the internal client's indices.exists_alias method correctly.""" + client, mock_internal_client = self._create_client_with_mock() + + alias_name = 'test_alias' + mock_internal_client.indices.exists_alias.return_value = True + + result = client.alias_exists(alias_name=alias_name) + + mock_internal_client.indices.exists_alias.assert_called_once_with(name=alias_name) + self.assertTrue(result) + + def test_alias_exists_returns_false_when_alias_does_not_exist(self): + """Test that alias_exists returns False when the alias does not exist.""" + client, mock_internal_client = self._create_client_with_mock() + + alias_name = 'nonexistent_alias' + mock_internal_client.indices.exists_alias.return_value = False + + result = client.alias_exists(alias_name=alias_name) + + mock_internal_client.indices.exists_alias.assert_called_once_with(name=alias_name) + self.assertFalse(result) + + def test_create_alias_calls_internal_client_with_expected_arguments(self): + """Test that create_alias calls the internal client's indices.put_alias method correctly.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index_v1' + alias_name = 'test_alias' + + client.create_alias(index_name=index_name, alias_name=alias_name) + + mock_internal_client.indices.put_alias.assert_called_once_with(index=index_name, name=alias_name) + + def test_search_calls_internal_client_with_expected_arguments(self): + """Test that search calls the internal client's search method correctly.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + query_body = { + 'query': { + 'match': {'givenName': 'John'}, + }, + } + expected_response = { + 'hits': { + 'total': {'value': 1}, + 'hits': [{'_source': {'givenName': 'John', 'familyName': 'Doe'}}], + }, + } + mock_internal_client.search.return_value = expected_response + + result = client.search(index_name=index_name, body=query_body) + + mock_internal_client.search.assert_called_once_with(index=index_name, body=query_body) + self.assertEqual(expected_response, result) + + def test_search_raises_cc_invalid_request_exception_on_400_request_error(self): + """Test that search raises CCInvalidRequestException when OpenSearch returns a 400 RequestError.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + query_body = {'query': {'match_all': {}}, 'sort': [{'familyName': 'asc'}]} + + # Simulate OpenSearch returning a 400 error with realistic error structure + error_reason = ( + 'Text fields are not optimised for operations that require per-document field data ' + 'like aggregations and sorting, so these operations are disabled by default.' + ) + error_info = { + 'error': { + 'root_cause': [ + { + 'type': 'illegal_argument_exception', + 'reason': error_reason, + } + ], + 'type': 'search_phase_execution_exception', + 'reason': 'all shards failed', + }, + 'status': 400, + } + mock_internal_client.search.side_effect = RequestError(400, 'search_phase_execution_exception', error_info) + + with self.assertRaises(CCInvalidRequestException) as context: + client.search(index_name=index_name, body=query_body) + + # Verify the exception message extracts the reason from root_cause + self.assertEqual( + f'Invalid search query: {error_reason}', + str(context.exception), + ) + + def test_search_raises_cc_invalid_request_exception_with_fallback_on_missing_root_cause(self): + """Test that search falls back to error type when root_cause is missing.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + query_body = {'query': {'match_all': {}}} + + # Simulate OpenSearch returning a 400 error without root_cause structure + mock_internal_client.search.side_effect = RequestError(400, 'parsing_exception', None) + + with self.assertRaises(CCInvalidRequestException) as context: + client.search(index_name=index_name, body=query_body) + + # Verify the exception falls back to the error type + self.assertEqual( + 'Invalid search query: parsing_exception', + str(context.exception), + ) + + def test_search_reraises_non_400_request_error(self): + """Test that search re-raises RequestError for non-400 status codes.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + query_body = {'query': {'match_all': {}}} + + # Simulate OpenSearch returning a 500 error + mock_internal_client.search.side_effect = RequestError(500, 'internal_error', 'Something went wrong') + + with self.assertRaises(RequestError) as context: + client.search(index_name=index_name, body=query_body) + + self.assertEqual(500, context.exception.status_code) + + def test_search_raises_cc_invalid_request_exception_on_timeout(self): + """Test that search raises CCInvalidRequestException when the request times out.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + query_body = {'query': {'match_all': {}}} + + # Simulate OpenSearch timing out + mock_internal_client.search.side_effect = ConnectionTimeout('Connection timed out', 503, 'Read timed out') + + with self.assertRaises(CCInvalidRequestException) as context: + client.search(index_name=index_name, body=query_body) + + # Verify the exception message tells the user to try again + self.assertEqual( + 'Search request timed out. Please try again or narrow your search criteria.', + str(context.exception), + ) + + def test_bulk_index_calls_internal_client_with_expected_arguments(self): + """Test that bulk_index calls the internal client's bulk method correctly.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + documents = [ + {'providerId': 'provider-1', 'givenName': 'John', 'familyName': 'Doe'}, + {'providerId': 'provider-2', 'givenName': 'Jane', 'familyName': 'Smith'}, + ] + expected_response = { + 'errors': False, + 'items': [{'index': {'_id': 'provider-1'}}, {'index': {'_id': 'provider-2'}}], + } + mock_internal_client.bulk.return_value = expected_response + + result = client.bulk_index(index_name=index_name, documents=documents) + + expected_actions = [ + {'index': {'_id': 'provider-1'}}, + {'providerId': 'provider-1', 'givenName': 'John', 'familyName': 'Doe'}, + {'index': {'_id': 'provider-2'}}, + {'providerId': 'provider-2', 'givenName': 'Jane', 'familyName': 'Smith'}, + ] + mock_internal_client.bulk.assert_called_once_with(body=expected_actions, index=index_name) + self.assertEqual(expected_response, result) + + def test_bulk_index_uses_custom_id_field(self): + """Test that bulk_index uses a custom id_field when specified.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + documents = [ + {'customId': 'custom-1', 'name': 'Document 1'}, + {'customId': 'custom-2', 'name': 'Document 2'}, + ] + mock_internal_client.bulk.return_value = {'errors': False, 'items': []} + + client.bulk_index(index_name=index_name, documents=documents, id_field='customId') + + expected_actions = [ + {'index': {'_id': 'custom-1'}}, + {'customId': 'custom-1', 'name': 'Document 1'}, + {'index': {'_id': 'custom-2'}}, + {'customId': 'custom-2', 'name': 'Document 2'}, + ] + mock_internal_client.bulk.assert_called_once_with(body=expected_actions, index=index_name) + + def test_bulk_index_returns_early_for_empty_documents(self): + """Test that bulk_index returns early without calling the internal client for empty documents.""" + client, mock_internal_client = self._create_client_with_mock() + + result = client.bulk_index(index_name='test_index', documents=[]) + + mock_internal_client.bulk.assert_not_called() + self.assertEqual({'items': [], 'errors': False}, result) + + @patch('opensearch_client.time.sleep') + def test_bulk_index_retries_on_connection_timeout_and_succeeds(self, mock_sleep): + """Test that bulk_index retries on ConnectionTimeout and eventually succeeds.""" + from opensearch_client import INITIAL_BACKOFF_SECONDS + + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + documents = [{'providerId': 'provider-1', 'givenName': 'John'}] + expected_response = {'errors': False, 'items': [{'index': {'_id': 'provider-1'}}]} + + # First two calls fail with ConnectionTimeout, third succeeds + mock_internal_client.bulk.side_effect = [ + ConnectionTimeout('Connection timed out', 503, 'some error'), + ConnectionTimeout('Connection timed out', 503, 'some error'), + expected_response, + ] + + result = client.bulk_index(index_name=index_name, documents=documents) + + # Verify bulk was called 3 times + self.assertEqual(3, mock_internal_client.bulk.call_count) + # Verify sleep was called with exponential backoff + self.assertEqual(2, mock_sleep.call_count) + mock_sleep.assert_any_call(INITIAL_BACKOFF_SECONDS) + mock_sleep.assert_any_call(INITIAL_BACKOFF_SECONDS * 2) + # Verify we got the successful response + self.assertEqual(expected_response, result) + + @patch('opensearch_client.time.sleep') + def test_bulk_index_retries_on_transport_error_and_succeeds(self, mock_sleep): + """Test that bulk_index retries on TransportError and eventually succeeds.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + documents = [{'providerId': 'provider-1', 'givenName': 'John'}] + expected_response = {'errors': False, 'items': [{'index': {'_id': 'provider-1'}}]} + + # First call fails with TransportError, second succeeds + mock_internal_client.bulk.side_effect = [ + TransportError(503, 'ReadTimeout'), + expected_response, + ] + + result = client.bulk_index(index_name=index_name, documents=documents) + + # Verify bulk was called 2 times + self.assertEqual(2, mock_internal_client.bulk.call_count) + # Verify sleep was called once + self.assertEqual(1, mock_sleep.call_count) + self.assertEqual(expected_response, result) + + @patch('opensearch_client.time.sleep') + def test_bulk_index_raises_cc_internal_exception_after_max_retries(self, mock_sleep): + """Test that bulk_index raises CCInternalException after all retry attempts fail.""" + from cc_common.exceptions import CCInternalException + from opensearch_client import MAX_RETRY_ATTEMPTS + + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + documents = [{'providerId': 'provider-1', 'givenName': 'John'}] + + # All calls fail with ConnectionTimeout + mock_internal_client.bulk.side_effect = ConnectionTimeout('Connection timed out', 503, 'some error') + + with self.assertRaises(CCInternalException) as context: + client.bulk_index(index_name=index_name, documents=documents) + + # Verify bulk was called MAX_RETRY_ATTEMPTS times + self.assertEqual(MAX_RETRY_ATTEMPTS, mock_internal_client.bulk.call_count) + # Verify sleep was called MAX_RETRY_ATTEMPTS - 1 times (no sleep after last failure) + self.assertEqual(MAX_RETRY_ATTEMPTS - 1, mock_sleep.call_count) + # Verify the exception message contains useful info + self.assertIn('Failed to bulk index', str(context.exception)) + self.assertIn(index_name, str(context.exception)) + self.assertIn(str(MAX_RETRY_ATTEMPTS), str(context.exception)) + + @patch('opensearch_client.time.sleep') + def test_bulk_index_exponential_backoff_caps_at_max(self, mock_sleep): + """Test that exponential backoff is capped at MAX_BACKOFF_SECONDS.""" + from opensearch_client import MAX_BACKOFF_SECONDS + + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'test_index' + documents = [{'providerId': 'provider-1', 'givenName': 'John'}] + + # All calls fail + mock_internal_client.bulk.side_effect = ConnectionTimeout('Connection timed out', 503, 'some error') + + with self.assertRaises(CCInternalException): + client.bulk_index(index_name=index_name, documents=documents) + + # Verify backoff values: 2, 4, 8, 16 (all should be <= MAX_BACKOFF_SECONDS) + # With MAX_RETRY_ATTEMPTS = 5, we have 4 sleeps + sleep_calls = [call[0][0] for call in mock_sleep.call_args_list] + for sleep_value in sleep_calls: + self.assertLessEqual(sleep_value, MAX_BACKOFF_SECONDS) + + +class TestOpenSearchClientIndexManagementRetry(TestCase): + """Test suite for OpenSearchClient index management operations with retry logic.""" + + def _create_client_with_mock(self): + """Create an OpenSearchClient with a mocked internal client.""" + with ( + patch('opensearch_client.boto3'), + patch('opensearch_client.config'), + patch('opensearch_client.OpenSearch') as mock_opensearch_class, + ): + mock_internal_client = MagicMock() + mock_opensearch_class.return_value = mock_internal_client + + from opensearch_client import OpenSearchClient + + client = OpenSearchClient() + return client, mock_internal_client + + @patch('opensearch_client.time.sleep') + def test_create_index_retries_on_connection_timeout_and_succeeds(self, mock_sleep): + """Test that create_index retries on ConnectionTimeout and eventually succeeds.""" + from opensearch_client import INITIAL_BACKOFF_SECONDS + + client, mock_internal_client = self._create_client_with_mock() + + # First call fails, second succeeds + mock_internal_client.indices.create.side_effect = [ + ConnectionTimeout('Connection timed out', 503, 'some error'), + {'acknowledged': True}, + ] + + # Should not raise + client.create_index(index_name='test_index', index_mapping={'settings': {}}) + + # Verify create was called 2 times + self.assertEqual(2, mock_internal_client.indices.create.call_count) + # Verify sleep was called once + self.assertEqual(1, mock_sleep.call_count) + mock_sleep.assert_called_with(INITIAL_BACKOFF_SECONDS) + + @patch('opensearch_client.time.sleep') + def test_create_index_raises_after_max_retries(self, mock_sleep): + """Test that create_index raises CCInternalException after max retries.""" + from opensearch_client import MAX_RETRY_ATTEMPTS + + client, mock_internal_client = self._create_client_with_mock() + + # All calls fail + mock_internal_client.indices.create.side_effect = ConnectionTimeout('Connection timed out', 503, 'some error') + + with self.assertRaises(CCInternalException) as context: + client.create_index(index_name='test_index', index_mapping={'settings': {}}) + + # Verify create was called MAX_RETRY_ATTEMPTS times + self.assertEqual(MAX_RETRY_ATTEMPTS, mock_internal_client.indices.create.call_count) + self.assertIn('create_index', str(context.exception)) + + @patch('opensearch_client.time.sleep') + def test_index_exists_retries_on_transport_error_and_succeeds(self, mock_sleep): + """Test that index_exists retries on TransportError and eventually succeeds.""" + client, mock_internal_client = self._create_client_with_mock() + + # First call fails, second succeeds + mock_internal_client.indices.exists.side_effect = [ + TransportError(503, 'ReadTimeout'), + True, + ] + + result = client.index_exists(index_name='test_index') + + self.assertTrue(result) + self.assertEqual(2, mock_internal_client.indices.exists.call_count) + + @patch('opensearch_client.time.sleep') + def test_alias_exists_retries_on_connection_timeout_and_succeeds(self, mock_sleep): + """Test that alias_exists retries on ConnectionTimeout and eventually succeeds.""" + client, mock_internal_client = self._create_client_with_mock() + + # First call fails, second succeeds + mock_internal_client.indices.exists_alias.side_effect = [ + ConnectionTimeout('Connection timed out', 503, 'some error'), + True, + ] + + result = client.alias_exists(alias_name='test_alias') + + self.assertTrue(result) + self.assertEqual(2, mock_internal_client.indices.exists_alias.call_count) + + @patch('opensearch_client.time.sleep') + def test_create_alias_retries_on_connection_timeout_and_succeeds(self, mock_sleep): + """Test that create_alias retries on ConnectionTimeout and eventually succeeds.""" + client, mock_internal_client = self._create_client_with_mock() + + # First call fails, second succeeds + mock_internal_client.indices.put_alias.side_effect = [ + ConnectionTimeout('Connection timed out', 503, 'some error'), + {'acknowledged': True}, + ] + + # Should not raise + client.create_alias(index_name='test_index', alias_name='test_alias') + + self.assertEqual(2, mock_internal_client.indices.put_alias.call_count) + + @patch('opensearch_client.time.sleep') + def test_cluster_health_retries_on_connection_timeout_and_succeeds(self, mock_sleep): + """Test that cluster_health retries on ConnectionTimeout and eventually succeeds.""" + client, mock_internal_client = self._create_client_with_mock() + + expected_response = {'status': 'green', 'number_of_nodes': 3} + + # First call fails, second succeeds + mock_internal_client.cluster.health.side_effect = [ + ConnectionTimeout('Connection timed out', 503, 'some error'), + expected_response, + ] + + result = client.cluster_health() + + self.assertEqual(expected_response, result) + self.assertEqual(2, mock_internal_client.cluster.health.call_count) + + @patch('opensearch_client.time.sleep') + def test_cluster_health_raises_after_max_retries(self, mock_sleep): + """Test that cluster_health raises CCInternalException after max retries.""" + from opensearch_client import MAX_RETRY_ATTEMPTS + + client, mock_internal_client = self._create_client_with_mock() + + # All calls fail + mock_internal_client.cluster.health.side_effect = ConnectionTimeout('Connection timed out', 503, 'some error') + + with self.assertRaises(CCInternalException) as context: + client.cluster_health() + + # Verify health was called MAX_RETRY_ATTEMPTS times + self.assertEqual(MAX_RETRY_ATTEMPTS, mock_internal_client.cluster.health.call_count) + self.assertIn('cluster_health', str(context.exception)) + + +class TestOpenSearchClientDeleteProviderDocuments(TestCase): + """Test suite for OpenSearchClient.delete_provider_documents().""" + + def _create_client_with_mock(self): + """Create an OpenSearchClient with a mocked internal client.""" + with ( + patch('opensearch_client.boto3'), + patch('opensearch_client.config'), + patch('opensearch_client.OpenSearch') as mock_opensearch_class, + ): + mock_internal_client = MagicMock() + mock_opensearch_class.return_value = mock_internal_client + + from opensearch_client import OpenSearchClient + + client = OpenSearchClient() + return client, mock_internal_client + + def test_delete_provider_documents_calls_internal_client_with_expected_arguments(self): + """Test that delete_provider_documents builds the provider query and calls the internal client.""" + client, mock_internal_client = self._create_client_with_mock() + + index_name = 'compact_socw_providers' + provider_id = 'provider-1' + expected_response = {'deleted': 3, 'failures': []} + mock_internal_client.delete_by_query.return_value = expected_response + + result = client.delete_provider_documents(index_name=index_name, provider_id=provider_id) + + mock_internal_client.delete_by_query.assert_called_once_with( + index=index_name, + body={'query': {'term': {'providerId': provider_id}}}, + ) + self.assertEqual(expected_response, result) + + @patch('opensearch_client.time.sleep') + def test_delete_provider_documents_retries_on_connection_timeout(self, mock_sleep): + """Test that delete_provider_documents retries on ConnectionTimeout.""" + from opensearch_client import INITIAL_BACKOFF_SECONDS + + client, mock_internal_client = self._create_client_with_mock() + + expected_response = {'deleted': 1, 'failures': []} + mock_internal_client.delete_by_query.side_effect = [ + ConnectionTimeout('Connection timed out', 503, 'some error'), + expected_response, + ] + + result = client.delete_provider_documents( + index_name='compact_socw_providers', + provider_id='provider-1', + ) + + self.assertEqual(2, mock_internal_client.delete_by_query.call_count) + self.assertEqual(1, mock_sleep.call_count) + mock_sleep.assert_called_with(INITIAL_BACKOFF_SECONDS) + self.assertEqual(expected_response, result) + + @patch('opensearch_client.time.sleep') + def test_delete_provider_documents_raises_after_max_retries(self, mock_sleep): + """Test that delete_provider_documents raises CCInternalException after max retries.""" + from opensearch_client import MAX_RETRY_ATTEMPTS + + client, mock_internal_client = self._create_client_with_mock() + + mock_internal_client.delete_by_query.side_effect = ConnectionTimeout('Connection timed out', 503, 'some error') + + with self.assertRaises(CCInternalException) as context: + client.delete_provider_documents( + index_name='compact_socw_providers', + provider_id='provider-1', + ) + + self.assertEqual(MAX_RETRY_ATTEMPTS, mock_internal_client.delete_by_query.call_count) + self.assertIn('delete_provider_documents', str(context.exception)) diff --git a/backend/social-work-app/lambdas/python/search/utils.py b/backend/social-work-app/lambdas/python/search/utils.py new file mode 100644 index 0000000000..9f78610d52 --- /dev/null +++ b/backend/social-work-app/lambdas/python/search/utils.py @@ -0,0 +1,54 @@ +""" +Utility functions for provider document processing and OpenSearch indexing. + +This module contains shared logic for processing provider records and preparing +them for OpenSearch indexing. It is used by both the populate_provider_documents +and provider_update_ingest handlers. +""" + +import json + +from cc_common.config import config +from cc_common.data_model.schema.provider.api import ProviderOpenSearchDocumentSchema +from cc_common.utils import ResponseEncoder + + +def generate_provider_opensearch_documents(compact: str, provider_id: str) -> list[dict]: + """ + Process a single provider and return a list of sanitized documents ready for indexing. + + Each document corresponds to one license. This is because theSocial Workcompact search returns results by license, + so we need to index one document per license to support native pagination. + + Because of this, rather than just using the provider_id as the documentId, + we add a composite documentId that includes the jurisdiction and license type. + This composite documentId is added after sanitization so that bulk_index can use it as the OpenSearch _id. + + :param compact: The compact abbreviation + :param provider_id: The provider ID to process + :return: List of sanitized documents, each with a composite documentId + :raises CCNotFoundException: If the provider is not found + :raises ValidationError: If the provider data fails schema validation + """ + provider_user_records = config.data_client.get_provider_user_records( + compact=compact, + provider_id=provider_id, + consistent_read=True, + ) + + raw_documents = provider_user_records.generate_opensearch_documents() + + schema = ProviderOpenSearchDocumentSchema() + result = [] + for raw_doc in raw_documents: + sanitized = schema.load(raw_doc) + serializable = json.loads(json.dumps(sanitized, cls=ResponseEncoder)) + + license_info = serializable['licenses'][0] + jurisdiction = license_info['jurisdiction'] + license_type = license_info['licenseType'] + serializable['documentId'] = f'{provider_id}#{jurisdiction}#{license_type}' + + result.append(serializable) + + return result diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py new file mode 100644 index 0000000000..07fa0ff5c1 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py @@ -0,0 +1,59 @@ +import logging +import os + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config +from cc_common.data_model.schema.common import StaffUserStatus +from user_data import UserData + +logger = Logger() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO) + + +@logger.inject_lambda_context() +def customize_scopes(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Customize the scopes in the access token before AWS generates and issues it""" + logger.info('Received event', event=event) + + try: + sub = event['request']['userAttributes']['sub'] + except KeyError as e: + # This logic will only ever trigger in the event of a misconfiguration. + # The Cognito Authorizer will validate all JWTs before ever calling this function. + logger.error('Unauthenticated user access attempted!', exc_info=e) + # Explicitly set this, to avoid future bugs + event['response']['claimsAndScopeOverrideDetails'] = None + return event + + try: + user_data = UserData(sub) + logger.debug('Adding scopes', scopes=user_data.scopes) + + # Get all the user's records and set their status to active + for record in user_data.records: + # Only update the status if it's not already active + if record['status'] != StaffUserStatus.ACTIVE.value: + config.users_table.update_item( + Key={'pk': record['pk'], 'sk': record['sk']}, + UpdateExpression='SET #status = :status', + ExpressionAttributeNames={'#status': 'status'}, + ExpressionAttributeValues={':status': StaffUserStatus.ACTIVE.value}, + ) + + # We want to catch almost any exception here, so we can gracefully return execution back to AWS + except Exception as e: # noqa: BLE001 broad-exception-caught + logger.error('Error while getting user scopes!', exc_info=e) + event['response']['claimsAndScopeOverrideDetails'] = None + return event + + event['response']['claimsAndScopeOverrideDetails'] = { + 'accessTokenGeneration': { + 'scopesToAdd': list(user_data.scopes), + # we explicitly suppress the cognito admin scope, + # so they cannot change their email directly with the Cognito API + 'scopesToSuppress': ['aws.cognito.signin.user.admin'], + } + } + + return event diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements-dev.in b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements-dev.in new file mode 100644 index 0000000000..5a61b7b0d2 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb, s3]>=5.0.12, <6 diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements-dev.txt b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements-dev.txt new file mode 100644 index 0000000000..8a90cd2f44 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements-dev.txt @@ -0,0 +1,64 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-user-pre-token/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +markupsafe==3.0.3 + # via werkzeug +moto[dynamodb,s3]==5.2.1 + # via -r lambdas/python/staff-user-pre-token/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements.in b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements.in new file mode 100644 index 0000000000..3d293fbf73 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common-python requirements.in file diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements.txt b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements.txt new file mode 100644 index 0000000000..e7cabe5cb9 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-user-pre-token/requirements.in +# diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/__init__.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/__init__.py new file mode 100644 index 0000000000..2fcbabd014 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/__init__.py @@ -0,0 +1,51 @@ +import os +from unittest import TestCase +from unittest.mock import MagicMock + +import boto3 +from aws_lambda_powertools.utilities.typing import LambdaContext +from moto import mock_aws + + +@mock_aws +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging in tests + 'DEBUG': 'true', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'USERS_TABLE_NAME': 'users-table', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': '["al", "co"]', + 'ENVIRONMENT_NAME': 'test', + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) + + def setUp(self): + super().setUp() + + self.build_resources() + self.addCleanup(self.delete_resources) + + def build_resources(self): + self._table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['USERS_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + + def delete_resources(self): + self._table.delete() diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/resources/pre-token-event.json b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/resources/pre-token-event.json new file mode 100644 index 0000000000..1abb74e932 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/resources/pre-token-event.json @@ -0,0 +1,33 @@ +{ + "version": "2", + "triggerSource": "TokenGeneration_HostedAuth", + "region": "us-east-1", + "userPoolId": "us-east-1_abcdefghi", + "userName": "justin", + "callerContext": { + "awsSdkVersion": "aws-sdk-unknown-unknown", + "clientId": "1234567890abcdefghijkl" + }, + "request": { + "userAttributes": { + "sub": "a4182428-d061-701c-82e5-a3d1d547d797", + "cognito:user_status": "CONFIRMED", + "email": "justin@example.com" + }, + "groupConfiguration": { + "groupsToOverride": [], + "iamRolesToOverride": [], + "preferredRole": null + }, + "scopes": [ + "aws.cognito.signin.user.admin", + "phone", + "openid", + "profile", + "email" + ] + }, + "response": { + "claimsAndScopeOverrideDetails": null + } +} diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py new file mode 100644 index 0000000000..5fcfe02e6a --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py @@ -0,0 +1,110 @@ +import json +from unittest.mock import patch + +from moto import mock_aws + +from tests import TstLambdas + + +@mock_aws +class TestCustomizeScopes(TstLambdas): + def test_happy_path(self): + from cc_common.data_model.schema.common import StaffUserStatus + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + # Create a DB record for this user's permissions + self._table.put_item( + Item={ + 'pk': f'USER#{sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'status': StaffUserStatus.INACTIVE.value, + 'permissions': { + 'jurisdictions': { + # should correspond to the 'al/socw.write' scope + 'al': {'write'} + }, + }, + } + ) + + resp = customize_scopes(event, self.mock_context) + + self.assertEqual( + sorted(['profile', 'socw/readGeneral', 'al/socw.write']), + sorted(resp['response']['claimsAndScopeOverrideDetails']['accessTokenGeneration']['scopesToAdd']), + ) + # Check that the user's status is updated in the DB + record = self._table.get_item(Key={'pk': f'USER#{sub}', 'sk': 'COMPACT#socw'}) + self.assertEqual(StaffUserStatus.ACTIVE.value, record['Item']['status']) + + def test_should_suppress_cognito_admin_scope(self): + """ + Ensure that no access token can be generated with the 'aws.cognito.signin.user.admin' scope. Which + Would allow them to change their email address directly through the Cognito API. + """ + from cc_common.data_model.schema.common import StaffUserStatus + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + # Create a DB record for this user's permissions + self._table.put_item( + Item={ + 'pk': f'USER#{sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'status': StaffUserStatus.INACTIVE.value, + 'permissions': { + 'jurisdictions': { + # should correspond to the 'al/socw.write' scope + 'al': {'write'} + }, + }, + } + ) + + resp = customize_scopes(event, self.mock_context) + + self.assertEqual( + sorted(['aws.cognito.signin.user.admin']), + sorted(resp['response']['claimsAndScopeOverrideDetails']['accessTokenGeneration']['scopesToSuppress']), + ) + + def test_unauthenticated(self): + """ + We should never actually receive an authenticated request, but if that happens somehow, + we'll not add any scopes. + """ + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + + del event['request']['userAttributes'] + + resp = customize_scopes(event, self.mock_context) + + self.assertEqual(None, resp['response']['claimsAndScopeOverrideDetails']) + + @patch('main.UserData', autospec=True) + def test_error_getting_scopes(self, mock_get_scopes): + """ + If something goes wrong calculating scopes, we will return none. + """ + mock_get_scopes.side_effect = RuntimeError('Oh noes!') + + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + + resp = customize_scopes(event, self.mock_context) + + self.assertEqual(None, resp['response']['claimsAndScopeOverrideDetails']) diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py new file mode 100644 index 0000000000..96850eab52 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py @@ -0,0 +1,177 @@ +from uuid import uuid4 + +from moto import mock_aws + +from tests import TstLambdas + + +@mock_aws +class TestGetUserScopesFromDB(TstLambdas): + def setUp(self): # pylint: disable=invalid-name + super().setUp() + self._user_sub = str(uuid4()) + + def test_compact_ed_user(self): + from user_data import UserData + + # Create a DB record for a typical compact executive director's permissions + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'permissions': {'actions': {'read', 'admin', 'readPrivate'}, 'jurisdictions': {}}, + } + ) + + user_data = UserData(self._user_sub) + + self.assertEqual( + { + 'profile', + 'socw/admin', + 'socw/readGeneral', + 'socw/readPrivate', + }, + user_data.scopes, + ) + + def test_board_ed_user(self): + from user_data import UserData + + # Create a DB record for a typical board executive director's permissions + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'permissions': {'jurisdictions': {'al': {'write', 'admin', 'readPrivate'}}}, + } + ) + + user_data = UserData(self._user_sub) + + self.assertEqual( + {'profile', 'socw/readGeneral', 'al/socw.admin', 'al/socw.write', 'al/socw.readPrivate'}, + user_data.scopes, + ) + + def test_board_staff(self): + from user_data import UserData + + # Create a DB record for a typical board staff user's permissions + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'permissions': { + 'jurisdictions': { + 'al': {'write'} # should correspond to the 'al/socw.write' scope + }, + }, + } + ) + + user_data = UserData(self._user_sub) + + self.assertEqual({'profile', 'socw/readGeneral', 'al/socw.write'}, user_data.scopes) + + def test_missing_user(self): + from user_data import UserData + + # We didn't specifically add a user for this test, so they will be missing + with self.assertRaises(RuntimeError): + UserData(self._user_sub) + + def test_disallowed_compact(self): + """ + If a user's permissions list an invalid compact, we will refuse to give them + any scopes at all. + """ + from user_data import UserData + + # Create a DB record with permissions for an unsupported compact + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, + } + ) + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'abc', + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, + } + ) + + with self.assertRaises(ValueError): + UserData(self._user_sub) + + def test_disallowed_compact_action(self): + """ + If a user's permissions list an invalid compact, we will refuse to give them + any scopes at all. + """ + from user_data import UserData + + # Create a DB record with permissions for an unsupported compact action + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'permissions': { + # Write is jurisdiction-specific + 'actions': {'write'}, + 'jurisdictions': {'al': {'write', 'admin'}}, + }, + } + ) + + with self.assertRaises(ValueError): + UserData(self._user_sub) + + def test_disallowed_jurisdiction(self): + """ + If a user's permissions list an invalid jurisdiction, we will refuse to give them + any scopes at all. + """ + from user_data import UserData + + # Create a DB record with permissions for an unsupported jurisdiction + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'permissions': {'jurisdictions': {'ab': {'write', 'admin'}}}, + } + ) + + with self.assertRaises(ValueError): + UserData(self._user_sub) + + def test_disallowed_action(self): + """ + If a user's permissions list an invalid action, we will refuse to give them + any scopes at all. + """ + from user_data import UserData + + # Create a DB record with permissions for an unsupported jurisdiction action + self._table.put_item( + Item={ + 'pk': f'USER#{self._user_sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'permissions': {'jurisdictions': {'al': {'write', 'hack'}}}, + } + ) + + with self.assertRaises(ValueError): + UserData(self._user_sub) diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/user_data.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/user_data.py new file mode 100644 index 0000000000..fd3a1eff44 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/user_data.py @@ -0,0 +1,93 @@ +from boto3.dynamodb.conditions import Key +from cc_common.config import config, logger +from cc_common.data_model.schema.common import CCPermissionsAction + + +class UserData: + """Class that will populate itself based on the user's database record contents""" + + def __init__(self, sub: str): + # Some auth flows (like Secure Remote Password) don't grant 'profile', so we'll make sure it's included by + # default + super().__init__() + + self.scopes = set(('profile',)) + self._get_scopes_from_db(sub) + + def _get_scopes_from_db(self, sub: str): + """Parse the user's database record to calculate scopes. + + Note: See the accompanying unit tests for expected db record shape. + :param sub: The `sub` field value from the Cognito Authorizer (which gets it from the JWT) + """ + self._get_user_records(sub) + permissions = { + compact_record['compact']: { + 'actions': set(compact_record['permissions'].get('actions', [])), + 'jurisdictions': compact_record['permissions']['jurisdictions'], + } + for compact_record in self.records + } + + # Ensure included compacts are limited to supported values + disallowed_compacts = permissions.keys() - config.compacts + if disallowed_compacts: + raise ValueError(f'User permissions include disallowed compacts: {disallowed_compacts}') + + for compact_abbr, compact_permissions in permissions.items(): + self._process_compact_permissions(compact_abbr, compact_permissions) + + def _get_user_records(self, sub: str): + self.records = config.users_table.query(KeyConditionExpression=Key('pk').eq(f'USER#{sub}')).get('Items', []) + if not self.records: + logger.error('Authenticated user not found!', sub=sub) + raise RuntimeError('Authenticated user not found!') + + def _process_compact_permissions(self, compact_abbr, compact_permissions): + # Compact-level permissions + compact_actions = compact_permissions.get('actions', set()) + + # Ensure included actions are limited to supported values + # Note we are keeping in the 'read' permission for backwards compatibility + # Though we are not using it in the codebase + disallowed_actions = compact_actions - { + CCPermissionsAction.READ, + CCPermissionsAction.ADMIN, + CCPermissionsAction.READ_PRIVATE, + } + if disallowed_actions: + raise ValueError(f'User {compact_abbr} permissions include disallowed actions: {disallowed_actions}') + + # readGeneral is always added an implicit permission granted to all staff users at the compact level + self.scopes.add(f'{compact_abbr}/{CCPermissionsAction.READ_GENERAL}') + + if CCPermissionsAction.READ_PRIVATE in compact_actions: + self.scopes.add(f'{compact_abbr}/{CCPermissionsAction.READ_PRIVATE}') + + if CCPermissionsAction.ADMIN in compact_actions: + self.scopes.add(f'{compact_abbr}/{CCPermissionsAction.ADMIN}') + + # Ensure included jurisdictions are limited to supported values + jurisdictions = compact_permissions['jurisdictions'] + disallowed_jurisdictions = jurisdictions.keys() - config.jurisdictions + if disallowed_jurisdictions: + raise ValueError( + f'User {compact_abbr} permissions include disallowed jurisdictions: {disallowed_jurisdictions}', + ) + + for jurisdiction_name, jurisdiction_permissions in compact_permissions['jurisdictions'].items(): + self._process_jurisdiction_permissions(compact_abbr, jurisdiction_name, jurisdiction_permissions) + + def _process_jurisdiction_permissions(self, compact_abbr, jurisdiction_name, jurisdiction_actions): + # Ensure included actions are limited to supported values + disallowed_actions = jurisdiction_actions - { + CCPermissionsAction.WRITE, + CCPermissionsAction.ADMIN, + CCPermissionsAction.READ_PRIVATE, + } + if disallowed_actions: + raise ValueError( + f'User {jurisdiction_name}/{compact_abbr} permissions include disallowed actions: {disallowed_actions}', + ) + for action in jurisdiction_actions: + self.scopes.add(f'{jurisdiction_name}/{compact_abbr}.{action}') diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/__init__.py b/backend/social-work-app/lambdas/python/staff-users/handlers/__init__.py new file mode 100644 index 0000000000..6c8217b3e6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/__init__.py @@ -0,0 +1,5 @@ +from aws_lambda_powertools import Logger +from cc_common.data_model.schema.user.api import UserAPISchema + +logger = Logger() +user_api_schema = UserAPISchema() diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/me.py b/backend/social-work-app/lambdas/python/staff-users/handlers/me.py new file mode 100644 index 0000000000..4c8db15528 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/me.py @@ -0,0 +1,52 @@ +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.user.api import UserMergedResponseSchema +from cc_common.exceptions import CCInternalException +from cc_common.utils import api_handler + +from handlers import user_api_schema + + +@api_handler +def get_me(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Return a user by the sub in their token""" + user_id = event['requestContext']['authorizer']['claims']['sub'] + + resp = config.user_client.get_user(user_id=user_id) + # This is really unlikely, but will check anyway + last_key = resp['pagination'].get('lastKey') + if last_key is not None: + logger.error('A provider had so many records, they paginated!') + raise CCInternalException('Unexpected provider data') + + return _merge_user_records(user_id, resp['items']) + + +@api_handler +def patch_me(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Edit a user's own attributes""" + user_id = event['requestContext']['authorizer']['claims']['sub'] + + body = json.loads(event['body']) + user_records = config.user_client.update_user_attributes(user_id=user_id, attributes=body['attributes']) + return _merge_user_records(user_id, user_records) + + +def _merge_user_records(user_id: str, records: list) -> dict: + users_iter = iter(records) + merged_user = user_api_schema.load(next(users_iter)) + for record in users_iter: + compact = record['compact'] + next_user = user_api_schema.load(record) + if next_user['attributes'] != merged_user['attributes']: + logger.warning('Inconsistent user attributes', user_id=user_id, compact=compact) + # Keep the last date of update + merged_user['dateOfUpdate'] = max(next_user['dateOfUpdate'], merged_user['dateOfUpdate']) + # Merge compact fields in permissions + merged_user['permissions'].update(next_user['permissions']) + + # Validate the merged user data through the response schema + response_schema = UserMergedResponseSchema() + return response_schema.load(merged_user) diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/users.py b/backend/social-work-app/lambdas/python/staff-users/handlers/users.py new file mode 100644 index 0000000000..142ff54449 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/users.py @@ -0,0 +1,224 @@ +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError +from cc_common.config import config, logger, metrics +from cc_common.exceptions import CCAccessDeniedException, CCInternalException, CCNotFoundException +from cc_common.utils import ( + api_handler, + authorize_compact, + collect_and_authorize_changes, + get_allowed_jurisdictions, + get_event_scopes, +) + +from handlers import user_api_schema + + +@api_handler +@authorize_compact(action='admin') +def get_one_user(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Return a user by userId""" + compact = event['pathParameters']['compact'] + user_id = event['pathParameters']['userId'] + scopes = get_event_scopes(event) + allowed_jurisdictions = get_allowed_jurisdictions(compact=compact, scopes=scopes) + + user = config.user_client.get_user_in_compact(compact=compact, user_id=user_id) + + # For jurisdiction-admins, don't return users if they have no permissions in the admin's jurisdiction + if allowed_jurisdictions is not None: + allowed_jurisdictions = set(allowed_jurisdictions) + if not allowed_jurisdictions.intersection(user['permissions']['jurisdictions'].keys()): + # The user has no permissions in the jurisdictions the admin is allowed to see, so we'll return a 404 + raise CCNotFoundException('User not found') + + # Transform record schema to API schema + # If the user has permissions that intersect the admin's jurisdiction, we will return the full user's permissions + # for the requested compact + return user_api_schema.load(user) + + +@api_handler +@authorize_compact(action='admin') +def get_users(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Return users""" + compact = event['pathParameters']['compact'] + # If no query string parameters are provided, APIGW will set the value to None, which we need to handle here + query_string_params = event.get('queryStringParameters') if event.get('queryStringParameters') is not None else {} + pagination = {} + if 'pageSize' in query_string_params.keys(): + pagination['pageSize'] = int(query_string_params['pageSize']) + if 'lastKey' in query_string_params.keys(): + pagination['lastKey'] = query_string_params['lastKey'] + + scopes = get_event_scopes(event) + allowed_jurisdictions = get_allowed_jurisdictions(compact=compact, scopes=scopes) + + resp = config.user_client.get_users_sorted_by_family_name( + compact=compact, + jurisdictions=allowed_jurisdictions, + pagination=pagination, + ) + # Convert to API-specific format + users = resp.pop('items', []) + resp['users'] = [user_api_schema.load(user) for user in users] + return resp + + +@api_handler +@authorize_compact(action='admin') +def patch_user(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Admins update a user's data + + Example: This body would be requesting to: + - add socw/admin permission + - add oh/cosm admin permission + - remove oh/cosm write permission + ```json + { + "permissions": { + "socw": { + "actions": { + "admin": true + } + "jurisdictions": { + "oh": { + "actions": { + "admin": true, + "write": false + } + } + } + } + } + ``` + """ + compact = event['pathParameters']['compact'] + user_id = event['pathParameters']['userId'] + scopes = get_event_scopes(event) + path_compact = event['pathParameters']['compact'] + + # this will raise an exception if the caller was disabled + _verify_caller_is_active(event) + + permission_changes = json.loads(event['body']).get('permissions', {}).get(compact, {}) + logger.debug('Requested changes', permission_changes=permission_changes) + changes = collect_and_authorize_changes( + path_compact=path_compact, + scopes=scopes, + compact_changes=permission_changes, + ) + user = config.user_client.update_user_permissions(compact=compact, user_id=user_id, **changes) + return user_api_schema.load(user) + + +@api_handler +@authorize_compact(action='admin') +def post_user(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + scopes = get_event_scopes(event) + compact = event['pathParameters']['compact'] + body = json.loads(event['body']) + + # this will raise an exception if the caller was disabled + _verify_caller_is_active(event) + + # Verify that the client has permission to create a user with the requested permissions + for compact, compact_permissions in body['permissions'].items(): + # This method will raise an exception if they request an inappropriate permission for the new user + collect_and_authorize_changes(path_compact=compact, scopes=scopes, compact_changes=compact_permissions) + + # Use the UserClient to create a new user + user = user_api_schema.dump(body) + created_user = user_api_schema.load( + config.user_client.create_user(compact=compact, attributes=user['attributes'], permissions=user['permissions']), + ) + # add metric so we can monitor suspicious activity of too many staff users being created + metrics.add_metric(name='staff-user-created', value=1, unit='Count') + return created_user + + +@api_handler +@authorize_compact(action='admin') +def delete_user(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + compact = event['pathParameters']['compact'] + user_id = event['pathParameters']['userId'] + # If the caller is a jurisdiction admin, they can only delete the user if the user only has permissions in the + # caller's jurisdiction. If the user has permissions in another jurisdiction, an admin in that other jurisdiction + # must remove those permissions first before this operation will succeed. A compact admin can delete any user in + # their compact, regardless of where they have permissions. + allowed_jurisdictions = get_allowed_jurisdictions(compact=compact, scopes=get_event_scopes(event)) + # this will raise an exception if the caller was disabled + _verify_caller_is_active(event) + + # Get user information (we need it for permission checks and for sign out) + user = config.user_client.get_user_in_compact(compact=compact, user_id=user_id) + + # None means they are a compact admin - no jurisdiction restrictions at all + if allowed_jurisdictions is not None: + allowed_jurisdictions = set(allowed_jurisdictions) + user_jurisdictions = user['permissions']['jurisdictions'].keys() + disallowed_jurisdictions = user_jurisdictions - allowed_jurisdictions + common_jurisdictions = allowed_jurisdictions.intersection(user_jurisdictions) + + # We won't show that the user even exists, if they have no common jurisdictions + if not common_jurisdictions: + raise CCNotFoundException('User not found') + + # If they have permissions elsewhere, we return an error + if disallowed_jurisdictions: + raise CCAccessDeniedException( + f'User has permissions in other jurisdictions ({", ".join(disallowed_jurisdictions)}). Those must be' + 'removed first or a compact admin must perform this operation.' + ) + + # At this time, 'delete' really means just deleting their compact permission record but doing nothing to the actual + # Cognito user. + config.user_client.delete_user(compact=compact, user_id=user_id) + + # Disable the user in Cognito + # This is a security measure to ensure deactivated users cannot continue generating access tokens + try: + config.cognito_client.admin_disable_user(UserPoolId=config.user_pool_id, Username=user_id) + logger.info('Successfully disabled user in Cognito', user_id=user_id) + except ClientError as e: + logger.error('Failed to disable user in Cognito', user_id=user_id, error=str(e), exc_info=e) + raise CCInternalException('Failed to disable user in Cognito') from e + + return {'message': 'User deleted'} + + +@api_handler +@authorize_compact(action='admin') +def reinvite_user(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + compact = event['pathParameters']['compact'] + user_id = event['pathParameters']['userId'] + # this will raise an exception if the caller was disabled + _verify_caller_is_active(event) + + allowed_jurisdictions = get_allowed_jurisdictions(compact=compact, scopes=get_event_scopes(event)) + + # None means they are a compact admin - no jurisdiction restrictions at all + user = config.user_client.get_user_in_compact(compact=compact, user_id=user_id) + if allowed_jurisdictions is not None: + allowed_jurisdictions = set(allowed_jurisdictions) + user = config.user_client.get_user_in_compact(compact=compact, user_id=user_id) + user_jurisdictions = user['permissions']['jurisdictions'].keys() + common_jurisdictions = allowed_jurisdictions.intersection(user_jurisdictions) + + # We won't show that the user even exists, if they have no common jurisdictions + if not common_jurisdictions: + raise CCNotFoundException('User not found') + + config.user_client.reinvite_user(email=user['attributes']['email']) + return {'message': 'User reinvited'} + + +def _verify_caller_is_active(event): + caller_sub = event['requestContext']['authorizer']['claims']['sub'] + # Ensure the caller has not been previously deleted + try: + config.user_client.get_user(user_id=caller_sub) + except CCNotFoundException as e: + logger.warning('Unable to find user account', caller_user_id=caller_sub) + raise CCAccessDeniedException('Access denied') from e diff --git a/backend/social-work-app/lambdas/python/staff-users/requirements-dev.in b/backend/social-work-app/lambdas/python/staff-users/requirements-dev.in new file mode 100644 index 0000000000..522802d767 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/requirements-dev.in @@ -0,0 +1,2 @@ +moto[dynamodb, s3, cognitoidp]>=5.0.15, <6 +Faker>=40, <41 diff --git a/backend/social-work-app/lambdas/python/staff-users/requirements-dev.txt b/backend/social-work-app/lambdas/python/staff-users/requirements-dev.txt new file mode 100644 index 0000000000..abc674ddf3 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/requirements-dev.txt @@ -0,0 +1,70 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-users/requirements-dev.in +# +boto3==1.43.7 + # via moto +botocore==1.43.7 + # via + # boto3 + # moto + # s3transfer +certifi==2026.4.22 + # via requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +cryptography==48.0.0 + # via + # joserfc + # moto +docker==7.1.0 + # via moto +faker==40.15.0 + # via -r lambdas/python/staff-users/requirements-dev.in +idna==3.15 + # via requests +jmespath==1.1.0 + # via + # boto3 + # botocore +joserfc==1.6.5 + # via moto +markupsafe==3.0.3 + # via werkzeug +moto[cognitoidp,dynamodb,s3]==5.2.1 + # via -r lambdas/python/staff-users/requirements-dev.in +py-partiql-parser==0.6.3 + # via moto +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via botocore +pyyaml==6.0.3 + # via + # moto + # responses +requests==2.34.1 + # via + # docker + # moto + # responses +responses==0.26.0 + # via moto +s3transfer==0.17.0 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.7.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.8 + # via moto +xmltodict==1.0.4 + # via moto diff --git a/backend/social-work-app/lambdas/python/staff-users/requirements.in b/backend/social-work-app/lambdas/python/staff-users/requirements.in new file mode 100644 index 0000000000..3d293fbf73 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common-python requirements.in file diff --git a/backend/social-work-app/lambdas/python/staff-users/requirements.txt b/backend/social-work-app/lambdas/python/staff-users/requirements.txt new file mode 100644 index 0000000000..d32dda42ac --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-users/requirements.in +# diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py new file mode 100644 index 0000000000..88710db094 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py @@ -0,0 +1,32 @@ +import os +from unittest import TestCase +from unittest.mock import MagicMock + +from aws_lambda_powertools.utilities.typing import LambdaContext + + +class TstLambdas(TestCase): + @classmethod + def setUpClass(cls): + os.environ.update( + { + # Set to 'true' to enable debug logging + 'DEBUG': 'false', + 'ALLOWED_ORIGINS': '["https://example.org"]', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'USER_POOL_ID': 'us-east-1-12345', + 'USERS_TABLE_NAME': 'provider-table', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'FAM_GIV_INDEX_NAME': 'famGiv', + 'COMPACTS': '["socw"]', + 'JURISDICTIONS': '["ne", "oh", "ky"]', + 'ENVIRONMENT_NAME': 'test', + }, + ) + # Monkey-patch config object to be sure we have it based + # on the env vars we set above + import cc_common.config + + cls.config = cc_common.config._Config() # noqa: SLF001 protected-access + cc_common.config.config = cls.config + cls.mock_context = MagicMock(name='MockLambdaContext', spec=LambdaContext) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py new file mode 100644 index 0000000000..ec40e13d67 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py @@ -0,0 +1,221 @@ +import json +import logging +import os + +import boto3 +from boto3.dynamodb.types import TypeDeserializer +from common_test.test_constants import DEFAULT_FAMILY_NAME, DEFAULT_GIVEN_NAME +from faker import Faker +from moto import mock_aws + +from tests import TstLambdas + +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false') == 'true' else logging.INFO) + + +@mock_aws +class TstFunction(TstLambdas): + """Base class to set up Moto mocking and create mock AWS resources for functional testing""" + + def setUp(self): # noqa: N801 invalid-name + super().setUp() + + self.faker = Faker(['en_US', 'ja_JP', 'es_MX']) + self.build_resources() + + self.addCleanup(self.delete_resources) + + def build_resources(self): + self._table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + {'AttributeName': 'famGiv', 'AttributeType': 'S'}, + ], + TableName=self.config.users_table_name, + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + GlobalSecondaryIndexes=[ + { + 'IndexName': os.environ['FAM_GIV_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'famGiv', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + self.create_compact_configuration_table() + # Adding a waiter allows for testing against an actual AWS account, if needed + waiter = self._table.meta.client.get_waiter('table_exists') + waiter.wait(TableName=self._table.name) + + # Create a new Cognito user pool + cognito_client = boto3.client('cognito-idp') + user_pool_name = 'TestUserPool' + user_pool_response = cognito_client.create_user_pool( + PoolName=user_pool_name, + AliasAttributes=['email'], + UsernameAttributes=['email'], + ) + os.environ['USER_POOL_ID'] = user_pool_response['UserPool']['Id'] + self._user_pool_id = user_pool_response['UserPool']['Id'] + + def create_compact_configuration_table(self): + """Create the compact configuration table for testing.""" + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + ) + + def delete_resources(self): + self._table.delete() + self._compact_configuration_table.delete() + waiter = self._table.meta.client.get_waiter('table_not_exists') + waiter.wait(TableName=self._table.name) + # Delete the Cognito user pool + cognito_client = boto3.client('cognito-idp') + cognito_client.delete_user_pool(UserPoolId=self._user_pool_id) + + def _load_compact_active_member_jurisdictions(self, compact: str = 'socw'): + """Load active member jurisdictions using the TestDataGenerator.""" + from common_test.test_data_generator import TestDataGenerator + + TestDataGenerator.put_compact_active_member_jurisdictions(compact) + + def _load_user_data(self, second_jurisdiction: str = None) -> str: + with open('../common/tests/resources/dynamo/user.json') as f: + # This item is saved in its serialized form, so we have to deserialize it first + item = TypeDeserializer().deserialize({'M': json.load(f)}) + + # Add write permissions to a second jurisdiction + if second_jurisdiction: + item['permissions']['jurisdictions'][second_jurisdiction] = {'write'} + + logger.info('Loading user: %s', item) + self._table.put_item(Item=item) + return item['userId'] + + def _create_compact_board_user(self, compact: str, jurisdiction: str): + """Create a board-staff style user for the provided compact and jurisdiction.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + schema = UserRecordSchema() + + email = self.faker.unique.email() + sub = self._create_cognito_user(email=email) + + logger.info('Writing compact %s/%s permissions for %s', compact, jurisdiction, email) + self._table.put_item( + Item=schema.dump( + { + 'userId': sub, + 'compact': compact, + 'status': StaffUserStatus.INACTIVE.value, + 'attributes': { + 'email': email, + 'familyName': self.faker.unique.last_name(), + 'givenName': self.faker.unique.first_name(), + }, + 'permissions': self._create_write_permissions(jurisdiction), + }, + ), + ) + return sub + + def _create_compact_staff_user(self, compacts: list[str]): + """Create a compact-staff style user for each jurisdiction in the provided compact.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + schema = UserRecordSchema() + + email = self.faker.unique.email() + sub = self._create_cognito_user(email=email) + for compact in compacts: + logger.info('Writing compact %s permissions for %s', compact, email) + self._table.put_item( + Item=schema.dump( + { + 'userId': sub, + 'compact': compact, + 'status': StaffUserStatus.INACTIVE.value, + 'attributes': { + 'email': email, + 'familyName': self.faker.unique.last_name(), + 'givenName': self.faker.unique.first_name(), + }, + 'permissions': {'actions': {'read'}, 'jurisdictions': {}}, + }, + ), + ) + return sub + + def _create_board_staff_users(self, compacts: list[str]): + """Create a board-staff style user for each jurisdiction in the provided compact.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + schema = UserRecordSchema() + + for jurisdiction in self.config.jurisdictions: + email = self.faker.unique.email() + sub = self._create_cognito_user(email=email) + for compact in compacts: + logger.info('Writing board %s/%s permissions for %s', compact, jurisdiction, email) + self._table.put_item( + Item=schema.dump( + { + 'userId': sub, + 'compact': compact, + 'status': StaffUserStatus.INACTIVE.value, + 'attributes': { + 'email': email, + 'familyName': self.faker.unique.last_name(), + 'givenName': self.faker.unique.first_name(), + }, + 'permissions': self._create_write_permissions(jurisdiction), + }, + ), + ) + + def _create_cognito_user(self, *, email: str): + from cc_common.utils import get_sub_from_user_attributes + + user_data = self.config.cognito_client.admin_create_user( + UserPoolId=self.config.user_pool_id, + Username=email, + UserAttributes=[{'Name': 'email', 'Value': email}], + DesiredDeliveryMediums=['EMAIL'], + ) + return get_sub_from_user_attributes(user_data['User']['Attributes']) + + def _when_testing_with_valid_caller(self, compact: str = 'socw'): + # creates a user for endpoints that require the caller to have an active database record + user = self.config.user_client.create_user( + compact=compact, + attributes={ + 'email': 'someEmail@test.com', + 'familyName': DEFAULT_FAMILY_NAME, + 'givenName': DEFAULT_GIVEN_NAME, + }, + permissions={'actions': set(), 'jurisdictions': {}}, + ) + + return user['userId'] + + @staticmethod + def _create_write_permissions(jurisdiction: str): + return {'actions': {'readPrivate'}, 'jurisdictions': {jurisdiction: {'write'}}} diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_delete_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_delete_user.py new file mode 100644 index 0000000000..bf64dad707 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_delete_user.py @@ -0,0 +1,181 @@ +import json +from unittest.mock import MagicMock, patch + +from moto import mock_aws + +from .. import TstFunction + +MOCK_CALLER_SUB = 'a4182428-d061-701c-82e5-a3d1d5471234' +mock_cognito_client = MagicMock() +mock_cognito_client.admin_create_user.return_value = { + 'Enabled': True, + 'User': { + 'Attributes': [ + {'Name': 'sub', 'Value': MOCK_CALLER_SUB}, + {'Name': 'email', 'Value': 'test@example.com'}, + ], + }, +} + + +@mock_aws +@patch('handlers.users.config.cognito_client', mock_cognito_client) +class TestDeleteUser(TstFunction): + def _assert_user_gone(self): + user = self._table.get_item( + Key={'pk': 'USER#a4182428-d061-701c-82e5-a3d1d547d797', 'sk': 'COMPACT#socw'}, + ).get('Item') + self.assertEqual(None, user) + + def _assert_user_not_gone(self): + user = self._table.get_item( + Key={'pk': 'USER#a4182428-d061-701c-82e5-a3d1d547d797', 'sk': 'COMPACT#socw'}, + ).get('Item') + self.assertNotEqual(None, user) + + def test_delete_user_not_found_compact_admin(self): + from handlers.users import delete_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + # We haven't loaded any users, so this won't find a user + resp = delete_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + + def test_delete_user_not_found_jurisdiction_admin(self): + from handlers.users import delete_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + # We haven't loaded any users, so this won't find a user + resp = delete_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + + def test_delete_user_compact_admin(self): + self._load_user_data(second_jurisdiction='ne') + + from handlers.users import delete_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = delete_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual({'message': 'User deleted'}, body) + self._assert_user_gone() + + def test_delete_user_disables_cognito_user(self): + mock_cognito_client.reset_mock() + user_id = self._load_user_data(second_jurisdiction='ne') + + from handlers.users import delete_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = delete_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + mock_cognito_client.admin_disable_user.assert_called_once_with( + UserPoolId=self.config.user_pool_id, Username=user_id + ) + + def test_delete_user_jurisdiction_admin(self): + # This user has permissions in oh + self._load_user_data() + + from handlers.users import delete_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for oh/cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = delete_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual({'message': 'User deleted'}, body) + self._assert_user_gone() + + def test_delete_user_outside_jurisdiction(self): + self._load_user_data() + + from handlers.users import delete_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for socw/ne, user does not have socw/oh permissions + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email ne/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = delete_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + self._assert_user_not_gone() + + def test_delete_user_second_jurisdiction(self): + self._load_user_data(second_jurisdiction='ne') + + from handlers.users import delete_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for socw/ne, user does not have socw/oh permissions + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email ne/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = delete_user(event, self.mock_context) + + self.assertEqual(403, resp['statusCode']) + self._assert_user_not_gone() diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_me.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_me.py new file mode 100644 index 0000000000..35b45bed86 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_me.py @@ -0,0 +1,54 @@ +import json + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestGetMe(TstFunction): + def test_get_me_access_denied(self): + from handlers.me import get_me + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {} + event['body'] = None + + # We haven't loaded any users, so this won't find a user + resp = get_me(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + + def test_get_me(self): + # Using a compact staff user method because it creates a single user that spans multiple compacts + user_id = self._create_compact_staff_user(compacts=['socw']) + + from handlers.me import get_me + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['requestContext']['authorizer']['claims']['sub'] = user_id + event['pathParameters'] = {} + event['body'] = None + + resp = get_me(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + + self.assertEqual({'type', 'dateOfUpdate', 'userId', 'attributes', 'permissions', 'status'}, body.keys()) + # Verify we've successfully merged permissions from two compacts + self.assertEqual( + { + 'socw': {'actions': {'read': True}, 'jurisdictions': {}}, + }, + body['permissions'], + ) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_user.py new file mode 100644 index 0000000000..77c66ba20e --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_user.py @@ -0,0 +1,90 @@ +import json + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestGetUser(TstFunction): + def test_get_user_not_found(self): + from handlers.users import get_one_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + # We haven't loaded any users, so this won't find a user + resp = get_one_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + + def test_get_user_compact_admin(self): + self._load_user_data() + + from handlers.users import get_one_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = get_one_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + with open('tests/resources/api/user-response.json') as f: + expected_user = json.load(f) + + body = json.loads(resp['body']) + + self.assertEqual(expected_user, body) + + def test_get_user_jurisdiction_admin(self): + # This user has permissions in oh + self._load_user_data() + + from handlers.users import get_one_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for socw/oh + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = get_one_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + with open('tests/resources/api/user-response.json') as f: + expected_user = json.load(f) + + body = json.loads(resp['body']) + + self.assertEqual(expected_user, body) + + def test_get_user_outside_jurisdiction(self): + self._load_user_data() + + from handlers.users import get_one_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for socw/ne, user does not have socw/oh permissions + event['requestContext']['authorizer']['claims']['scope'] = 'openid email ne/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + resp = get_one_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py new file mode 100644 index 0000000000..54397e4fa6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py @@ -0,0 +1,89 @@ +import json + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestGetUsers(TstFunction): + def test_get_users_empty(self): + from handlers.users import get_users + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = None + + # We haven't loaded any users, so this won't find a user + resp = get_users(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual([], json.loads(resp['body'])['users']) + + def test_get_users_compact_admin(self): + from cc_common.data_model.schema.common import StaffUserStatus + + # One user who is a compact admin in cosm + self._create_compact_staff_user(compacts=['socw']) + # One board user in each test jurisdiction (oh, ne, ky) with permissions in socw. + self._create_board_staff_users(compacts=['socw']) + + from handlers.users import get_users + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = None + + resp = get_users(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + + self.assertEqual(4, len(body['users'])) + for user in body['users']: + # These are brand-new users, so they should all be inactive + self.assertEqual(StaffUserStatus.INACTIVE.value, user['status']) + + def test_get_users_paginated(self): + self._create_compact_staff_user(compacts=['socw']) + # Nine users: Three board users in each test jurisdiction (oh, ne, ky) with permissions in socw. + self._create_board_staff_users(compacts=['socw']) + self._create_board_staff_users(compacts=['socw']) + self._create_board_staff_users(compacts=['socw']) + + from handlers.users import get_users + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['queryStringParameters'] = {'pageSize': '5'} + event['pathParameters'] = {'compact': 'socw'} + event['body'] = None + + first_resp = get_users(event, self.mock_context) + + body = json.loads(first_resp['body']) + pagination = body['pagination'] + first_users = body['users'] + + self.assertEqual(200, first_resp['statusCode']) + self.assertEqual(5, len(first_users)) + + event['queryStringParameters'] = {'pageSize': '5', 'lastKey': pagination['lastKey']} + second_resp = get_users(event, self.mock_context) + self.assertEqual(200, second_resp['statusCode']) + body = json.loads(second_resp['body']) + second_users = body['users'] + self.assertEqual(5, len(second_users)) + self.assertIsNone(body['pagination']['lastKey']) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py new file mode 100644 index 0000000000..fdcf5935fd --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py @@ -0,0 +1,50 @@ +import json + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestPatchMe(TstFunction): + def test_patch_me_not_found(self): + from handlers.me import patch_me + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {} + event['body'] = json.dumps({'attributes': {'givenName': 'George'}}) + + # We haven't loaded any users, so this won't find a user + resp = patch_me(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + + def test_patch_me(self): + user_id = self._load_user_data() + + from handlers.me import patch_me + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['requestContext']['authorizer']['claims']['sub'] = user_id + event['pathParameters'] = {} + event['body'] = json.dumps({'attributes': {'givenName': 'George'}}) + + resp = patch_me(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + with open('tests/resources/api/user-response.json') as f: + expected_user = json.load(f) + expected_user['attributes']['givenName'] = 'George' + + body = json.loads(resp['body']) + + self.assertEqual(expected_user, body) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py new file mode 100644 index 0000000000..6d8340d32b --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py @@ -0,0 +1,324 @@ +import json + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestPatchUser(TstFunction): + def _when_testing_with_valid_jurisdiction(self, compact: str): + # load oh jurisdiction for provided compact to pass the jurisdiction validation + self._load_compact_active_member_jurisdictions(compact) + + def test_patch_user(self): + self._load_user_data() + self._when_testing_with_valid_jurisdiction(compact='socw') + + from cc_common.data_model.schema.common import StaffUserStatus + from handlers.users import patch_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for socw/oh + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = json.dumps({'permissions': {'socw': {'jurisdictions': {'oh': {'actions': {'admin': True}}}}}}) + + resp = patch_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + self.assertEqual( + { + 'attributes': {'email': 'justin@example.org', 'familyName': 'Williams', 'givenName': 'Justin'}, + 'dateOfUpdate': '2024-09-12T23:59:59+00:00', + 'status': StaffUserStatus.INACTIVE.value, + 'permissions': { + 'socw': { + 'actions': {'readPrivate': True}, + 'jurisdictions': {'oh': {'actions': {'admin': True, 'write': True}}}, + }, + }, + 'type': 'user', + 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797', + }, + user, + ) + + def test_patch_user_document_path_overlap(self): + from cc_common.data_model.schema.common import StaffUserStatus + from handlers.users import patch_user + + self._when_testing_with_valid_jurisdiction(compact='socw') + + user = { + 'pk': 'USER#648864e8-10f1-702f-e666-2e0ff3482502', + 'sk': 'COMPACT#socw', + 'attributes': { + 'email': 'test@example.com', + 'familyName': 'User', + 'givenName': 'Test', + }, + 'status': StaffUserStatus.INACTIVE.value, + 'compact': 'socw', + 'dateOfUpdate': '2024-09-12T12:34:56+00:00', + 'famGiv': 'User#Test', + 'permissions': {'actions': {'read'}, 'jurisdictions': {'oh': {'admin', 'write'}}}, + 'type': 'user', + 'userId': '648864e8-10f1-702f-e666-2e0ff3482502', + } + self._table.put_item(Item=user) + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': '648864e8-10f1-702f-e666-2e0ff3482502'} + event['body'] = json.dumps( + { + 'permissions': { + 'socw': { + 'actions': {'read': True, 'admin': False}, + 'jurisdictions': {'oh': {'actions': {'write': True, 'admin': False}}}, + } + } + } + ) + + resp = patch_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + + # Don't compare the dateOfUpdate in comparison, since its value is dynamic + del user['dateOfUpdate'] + + self.assertEqual( + { + 'attributes': { + 'email': 'test@example.com', + 'familyName': 'User', + 'givenName': 'Test', + }, + 'permissions': { + 'socw': { + 'actions': {'read': True}, + 'jurisdictions': {'oh': {'actions': {'write': True}}}, + }, + }, + 'type': 'user', + 'userId': '648864e8-10f1-702f-e666-2e0ff3482502', + 'status': StaffUserStatus.INACTIVE.value, + }, + user, + ) + + def test_patch_user_add_to_empty_actions(self): + from cc_common.data_model.schema.common import StaffUserStatus + from handlers.users import patch_user, post_user + + self._when_testing_with_valid_jurisdiction(compact='socw') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with open('tests/resources/api/user-post.json') as f: + api_user = json.load(f) + # Create a user with no compact read or admin, no actions in a jurisdiction + api_user['permissions'] = {'socw': {'jurisdictions': {}}} + event['body'] = json.dumps(api_user) + + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + + resp = post_user(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + user_id = user.pop('userId') + + # Add compact read and oh admin permissions to the user + event['pathParameters'] = {'compact': 'socw', 'userId': user_id} + api_user['permissions'] = { + 'socw': {'actions': {'readPrivate': True}, 'jurisdictions': {'oh': {'actions': {'admin': True}}}} + } + event['body'] = json.dumps(api_user) + + resp = patch_user(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + + # Drop backend-generated fields from comparison + del user['userId'] + del user['dateOfUpdate'] + + # Add status to the comparison + api_user['status'] = StaffUserStatus.INACTIVE.value + + self.assertEqual(api_user, user) + + def test_patch_user_remove_all_actions(self): + from cc_common.data_model.schema.common import StaffUserStatus + from handlers.users import patch_user, post_user + + self._when_testing_with_valid_jurisdiction(compact='socw') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with open('tests/resources/api/user-post.json') as f: + api_user = json.load(f) + + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = json.dumps(api_user) + + resp = post_user(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + user_id = user.pop('userId') + + # Remove all the permissions from the user + event['pathParameters'] = {'compact': 'socw', 'userId': user_id} + api_user['permissions'] = { + 'socw': {'actions': {'readPrivate': False}, 'jurisdictions': {'oh': {'actions': {'write': False}}}} + } + event['body'] = json.dumps(api_user) + + resp = patch_user(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + + # Drop backend-generated fields from comparison + del user['userId'] + del user['dateOfUpdate'] + + # Add status to the comparison + api_user['status'] = StaffUserStatus.INACTIVE.value + + api_user['permissions'] = {'socw': {'jurisdictions': {}}} + self.assertEqual(api_user, user) + + def test_patch_user_forbidden(self): + self._load_user_data() + self._when_testing_with_valid_jurisdiction(compact='socw') + + from handlers.users import patch_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for oh/cosm not ne/cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = json.dumps({'permissions': {'socw': {'jurisdictions': {'ne': {'actions': {'admin': True}}}}}}) + + resp = patch_user(event, self.mock_context) + + self.assertEqual(403, resp['statusCode']) + + def test_patch_user_not_found(self): + from handlers.users import patch_user + + self._when_testing_with_valid_jurisdiction(compact='socw') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The caller has admin permission for oh/cosm not ne/cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + # The staff user does not exist + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = json.dumps({'permissions': {'socw': {'jurisdictions': {'oh': {'actions': {'admin': True}}}}}}) + + resp = patch_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + self.assertEqual({'message': 'User not found'}, json.loads(resp['body'])) + + def test_patch_user_allows_adding_read_private_permission(self): + self._load_user_data() + self._when_testing_with_valid_jurisdiction(compact='socw') + + from cc_common.data_model.schema.common import StaffUserStatus + from handlers.users import patch_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for compact and oh + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = json.dumps( + { + 'permissions': { + 'socw': { + 'actions': { + 'readPrivate': True, + }, + 'jurisdictions': {'oh': {'actions': {'readPrivate': True}}}, + } + } + } + ) + + resp = patch_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + self.assertEqual( + { + 'attributes': {'email': 'justin@example.org', 'familyName': 'Williams', 'givenName': 'Justin'}, + 'dateOfUpdate': '2024-09-12T23:59:59+00:00', + 'status': StaffUserStatus.INACTIVE.value, + 'permissions': { + 'socw': { + 'actions': {'readPrivate': True}, + # test user starts with the write permission, so it should still be there + 'jurisdictions': {'oh': {'actions': {'write': True, 'readPrivate': True}}}, + }, + }, + 'type': 'user', + 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797', + }, + user, + ) + + def test_patch_user_returns_400_if_invalid_jurisdiction(self): + self._load_user_data() + self._load_compact_active_member_jurisdictions(compact='socw') + + from handlers.users import patch_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + # in this case, the user is attempting to add permission for inactive compact, which is not valid + event['body'] = json.dumps({'permissions': {'socw': {'jurisdictions': {'fl': {'actions': {'admin': True}}}}}}) + + resp = patch_user(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + body = json.loads(resp['body']) + + self.assertEqual({'message': "'FL' is not a valid jurisdiction for 'SOCW' compact"}, body) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py new file mode 100644 index 0000000000..11c72acc3c --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py @@ -0,0 +1,142 @@ +import json + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestPostUser(TstFunction): + def _when_testing_with_valid_jurisdiction(self): + # load list of active jurisdiction for socw compact to pass the jurisdiction validation + self._load_compact_active_member_jurisdictions() + + def test_post_user(self): + from cc_common.data_model.schema.common import StaffUserStatus + from handlers.users import post_user + + self._when_testing_with_valid_jurisdiction() + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with open('tests/resources/api/user-post.json') as f: + event['body'] = f.read() + f.seek(0) + api_user = json.load(f) + + # The user has admin permission for socw/oh + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin oh/socw.admin' + event['pathParameters'] = {'compact': 'socw'} + + resp = post_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + user = json.loads(resp['body']) + + # Drop backend-generated fields from comparison + del user['userId'] + del user['dateOfUpdate'] + + # Add status to the comparison + api_user['status'] = StaffUserStatus.INACTIVE.value + + self.assertEqual(api_user, user) + + def test_post_user_no_compact_perms_round_trip(self): + from cc_common.data_model.schema.common import StaffUserStatus + from handlers.users import get_one_user, post_user + + self._when_testing_with_valid_jurisdiction() + caller_id = self._when_testing_with_valid_caller() + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with open('tests/resources/api/user-post.json') as f: + api_user = json.load(f) + # A user with no compact read or admin, no actions in a jurisdiction + api_user['permissions'] = {'socw': {'actions': {}, 'jurisdictions': {'oh': {'actions': {}}}}} + event['body'] = json.dumps(api_user) + + # The user has admin permission for socw admin + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin oh/socw.admin' + event['pathParameters'] = {'compact': 'socw'} + + resp = post_user(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + + # Drop backend-generated fields from comparison + user_id = user.pop('userId') + del user['dateOfUpdate'] + # The socw.actions and socw.jurisdictions.oh fields should be removed, since they are empty + api_user['permissions'] = {'socw': {'jurisdictions': {}}} + + # Add status to the comparison + api_user['status'] = StaffUserStatus.INACTIVE.value + + self.assertEqual(api_user, user) + + # Get the user back out via the API to check GET vs POST consistency + del event['body'] + event['pathParameters'] = {'compact': 'socw', 'userId': user_id} + resp = get_one_user(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + user = json.loads(resp['body']) + + # Drop backend-generated fields from comparison + del user['userId'] + del user['dateOfUpdate'] + + self.assertEqual(api_user, user) + + def test_post_user_unauthorized(self): + from handlers.users import post_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with open('tests/resources/api/user-post.json') as f: + event['body'] = f.read() + + # The user has admin permission for nebraska, not oh, which is where the user they are trying to create + # has permission. + event['requestContext']['authorizer']['claims']['scope'] = 'openid email ne/socw.admin' + event['pathParameters'] = {'compact': 'socw'} + + resp = post_user(event, self.mock_context) + + self.assertEqual(403, resp['statusCode']) + + def test_post_user_returns_400_if_invalid_jurisdiction_permission_set(self): + from handlers.users import post_user + + self._load_compact_active_member_jurisdictions() + caller_id = self._when_testing_with_valid_caller() + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with open('tests/resources/api/user-post.json') as f: + api_user = json.load(f) + + # A user with an invalid jurisdiction + api_user['permissions'] = {'socw': {'actions': {}, 'jurisdictions': {'fl': {'actions': {'readPrivate': True}}}}} + event['body'] = json.dumps(api_user) + + # The user has admin permission for cosm + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin oh/socw.admin' + event['pathParameters'] = {'compact': 'socw'} + + resp = post_user(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + body = json.loads(resp['body']) + + self.assertEqual({'message': "'FL' is not a valid jurisdiction for 'SOCW' compact"}, body) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_reinvite_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_reinvite_user.py new file mode 100644 index 0000000000..a5da4772d3 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_reinvite_user.py @@ -0,0 +1,106 @@ +import json + +from moto import mock_aws + +from .. import TstFunction + + +@mock_aws +class TestReinviteUser(TstFunction): + def test_reinvite_user_not_found_compact_admin(self): + from handlers.users import reinvite_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + # We haven't loaded any users, so this won't find a user + resp = reinvite_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + + def test_reinvite_user_not_found_jurisdiction_admin(self): + from handlers.users import reinvite_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = None + + # We haven't loaded any users, so this won't find a user + resp = reinvite_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) + + def test_reinvite_user_compact_admin(self): + user_id = self._create_compact_board_user(compact='socw', jurisdiction='oh') + + from handlers.users import reinvite_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for all of cosm + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['pathParameters'] = {'compact': 'socw', 'userId': user_id} + event['body'] = None + + resp = reinvite_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual({'message': 'User reinvited'}, body) + + def test_reinvite_user_jurisdiction_admin(self): + user_id = self._create_compact_board_user(compact='socw', jurisdiction='oh') + + from handlers.users import reinvite_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for socw/oh + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email oh/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': user_id} + event['body'] = None + + resp = reinvite_user(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + + body = json.loads(resp['body']) + self.assertEqual({'message': 'User reinvited'}, body) + + def test_reinvite_user_outside_jurisdiction(self): + user_id = self._create_compact_board_user(compact='socw', jurisdiction='oh') + + from handlers.users import reinvite_user + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + # The user has admin permission for socw/ne, user does not have socw/oh permissions + caller_id = self._when_testing_with_valid_caller() + event['requestContext']['authorizer']['claims']['sub'] = caller_id + event['requestContext']['authorizer']['claims']['scope'] = 'openid email ne/socw.admin' + event['pathParameters'] = {'compact': 'socw', 'userId': user_id} + event['body'] = None + + resp = reinvite_user(event, self.mock_context) + + self.assertEqual(404, resp['statusCode']) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/resources/api-event.json b/backend/social-work-app/lambdas/python/staff-users/tests/resources/api-event.json new file mode 100644 index 0000000000..00367c4951 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/resources/api-event.json @@ -0,0 +1,141 @@ +{ + "resource": "/v1/compacts/{compact}/staff-users", + "path": "/v1/compacts/{compact}/staff-users", + "httpMethod": "GET", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Authorization": "Bearer eyfoofoo", + "Cache-Control": "no-cache", + "Content-Type": "application/json", + "CloudFront-Forwarded-Proto": "https", + "CloudFront-Is-Desktop-Viewer": "true", + "CloudFront-Is-Mobile-Viewer": "false", + "CloudFront-Is-SmartTV-Viewer": "false", + "CloudFront-Is-Tablet-Viewer": "false", + "CloudFront-Viewer-ASN": "14618", + "CloudFront-Viewer-Country": "US", + "Host": "xib7bpau43.execute-api.us-east-1.amazonaws.com", + "Postman-Token": "1eedab23-5dd2-40db-93e2-5c8007bd1f89", + "User-Agent": "PostmanRuntime/7.36.1", + "Via": "1.1 f0f1092b2ad1f0e573a4fcbefe4fb620.cloudfront.net (CloudFront)", + "X-Amz-Cf-Id": "QHDmaz0LJQgmM6R7BcPxdn_AOzrvklxaAQWMFIp6vU5iC00sOenZeA==", + "X-Amzn-Trace-Id": "Root=1-65bd6123-0212598f4d09694c1f4bb8fe", + "X-Forwarded-For": "192.0.2.1, 198.51.100.1", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https" + }, + "multiValueHeaders": { + "Accept": [ + "*/*" + ], + "Accept-Encoding": [ + "gzip, deflate, br" + ], + "Authorization": [ + "Bearer eyfoofoo" + ], + "Cache-Control": [ + "no-cache" + ], + "CloudFront-Forwarded-Proto": [ + "https" + ], + "CloudFront-Is-Desktop-Viewer": [ + "true" + ], + "CloudFront-Is-Mobile-Viewer": [ + "false" + ], + "CloudFront-Is-SmartTV-Viewer": [ + "false" + ], + "CloudFront-Is-Tablet-Viewer": [ + "false" + ], + "CloudFront-Viewer-ASN": [ + "14618" + ], + "CloudFront-Viewer-Country": [ + "US" + ], + "Host": [ + "xib7bpau43.execute-api.us-east-1.amazonaws.com" + ], + "Postman-Token": [ + "1eedab23-5dd2-40db-93e2-5c8007bd1f89" + ], + "User-Agent": [ + "PostmanRuntime/7.36.1" + ], + "Via": [ + "1.1 f0f1092b2ad1f0e573a4fcbefe4fb620.cloudfront.net (CloudFront)" + ], + "X-Amz-Cf-Id": [ + "QHDmaz0LJQgmM6R7BcPxdn_AOzrvklxaAQWMFIp6vU5iC00sOenZeA==" + ], + "X-Amzn-Trace-Id": [ + "Root=1-65bd6123-0212598f4d09694c1f4bb8fe" + ], + "X-Forwarded-For": [ + "192.0.2.1, 198.51.100.1" + ], + "X-Forwarded-Port": [ + "443" + ], + "X-Forwarded-Proto": [ + "https" + ] + }, + "queryStringParameters": null, + "multiValueQueryStringParameters": null, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "resourceId": "niuigd", + "authorizer": { + "claims": { + "sub": "4kq74be0isgt7shnhhfgi5dk9k", + "token_use": "access", + "scope": "openid email", + "auth_time": "1717782706", + "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_xS47lbscb", + "exp": "Fri Jun 07 18:51:46 UTC 2024", + "iat": "Fri Jun 07 17:51:46 UTC 2024", + "version": "2", + "jti": "b4490af3-478f-49a9-90f5-373886ebe5c2", + "client_id": "4kq74be0isgt7shnhhfgi5dk9k" + } + }, + "resourcePath": "/v0/boards/{jurisdiction}/licenses/bulk-upload", + "httpMethod": "GET", + "extendedRequestId": "ZAmXuH9bIAMEjZA=", + "requestTime": "07/Jun/2024:18:27:09 +0000", + "path": "/sandbox/v0/boards/al/licenses/bulk-upload", + "accountId": "058264452476", + "protocol": "HTTP/1.1", + "stage": "sandbox", + "domainPrefix": "j9hs0bq9i9", + "requestTimeEpoch": 1717784829944, + "requestId": "6003a0c9-c61c-4d83-a2df-963e51f1a82f", + "identity": { + "cognitoIdentityPoolId": null, + "accountId": null, + "cognitoIdentityId": null, + "caller": null, + "sourceIp": "192.0.2.1", + "principalOrgId": null, + "accessKey": null, + "cognitoAuthenticationType": null, + "cognitoAuthenticationProvider": null, + "userArn": null, + "userAgent": "PostmanRuntime/7.39.0", + "user": null + }, + "domainName": "j9hs0bq9i9.execute-api.us-east-1.amazonaws.com", + "deploymentId": "okxjb3", + "apiId": "j9hs0bq9i9" + }, + "body": null, + "isBase64Encoded": false +} diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/resources/api/user-post.json b/backend/social-work-app/lambdas/python/staff-users/tests/resources/api/user-post.json new file mode 100644 index 0000000000..bbcc348c4b --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/resources/api/user-post.json @@ -0,0 +1,22 @@ +{ + "type": "user", + "attributes": { + "email": "justin@example.org", + "givenName": "Justin", + "familyName": "Williams" + }, + "permissions": { + "socw": { + "actions": { + "readPrivate": true + }, + "jurisdictions": { + "oh": { + "actions": { + "write": true + } + } + } + } + } +} diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/resources/api/user-response.json b/backend/social-work-app/lambdas/python/staff-users/tests/resources/api/user-response.json new file mode 100644 index 0000000000..ae70cc73a1 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/resources/api/user-response.json @@ -0,0 +1,25 @@ +{ + "type": "user", + "dateOfUpdate": "2024-09-12T23:59:59+00:00", + "userId": "a4182428-d061-701c-82e5-a3d1d547d797", + "attributes": { + "email": "justin@example.org", + "givenName": "Justin", + "familyName": "Williams" + }, + "status": "inactive", + "permissions": { + "socw": { + "actions": { + "readPrivate": true + }, + "jurisdictions": { + "oh": { + "actions": { + "write": true + } + } + } + } + } +} diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_api_handler.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_api_handler.py new file mode 100644 index 0000000000..112e99bc1b --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_api_handler.py @@ -0,0 +1,129 @@ +# ruff: noqa: ARG001 +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext +from botocore.exceptions import ClientError + +from tests import TstLambdas + + +class TestApiHandler(TstLambdas): + """Testing that the api_handler decorator is working as expected.""" + + def test_happy_path(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual('{"message": "OK"}', resp['body']) + + def test_unauthorized(self): + from cc_common.exceptions import CCUnauthorizedException + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise CCUnauthorizedException("You can't do that") + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(401, resp['statusCode']) + + def test_access_denied(self): + from cc_common.exceptions import CCAccessDeniedException + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise CCAccessDeniedException("You can't do that") + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(403, resp['statusCode']) + + def test_not_found(self): + from cc_common.exceptions import CCNotFoundException + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise CCNotFoundException("I don't see it.") + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(404, resp['statusCode']) + self.assertEqual({'message': "I don't see it."}, json.loads(resp['body'])) + + def test_invalid_request(self): + from cc_common.exceptions import CCInvalidRequestException + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise CCInvalidRequestException('Your request is wrong.') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + resp = lambda_handler(event, self.mock_context) + self.assertEqual(400, resp['statusCode']) + self.assertEqual({'message': 'Your request is wrong.'}, json.loads(resp['body'])) + + def test_client_error(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise ClientError(error_response={'Error': {'Code': 'CantDoThatException'}}, operation_name='DoAWSThing') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with self.assertRaises(ClientError): + lambda_handler(event, self.mock_context) + + def test_runtime_error(self): + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): + raise RuntimeError('Egads!') + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + with self.assertRaises(RuntimeError): + lambda_handler(event, self.mock_context) + + def test_null_headers(self): + """API Gateway will send a null object in the case that a field that is usually a dict is empty. This test + verifies that the api_handler decorator can handle this case. + """ + from cc_common.utils import api_handler + + @api_handler + def lambda_handler(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'message': 'OK'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['headers'] = None + + resp = lambda_handler(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + self.assertEqual('{"message": "OK"}', resp['body']) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_authorize_compact.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_authorize_compact.py new file mode 100644 index 0000000000..ff060aca3e --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_authorize_compact.py @@ -0,0 +1,76 @@ +import json + +from aws_lambda_powertools.utilities.typing import LambdaContext + +from tests import TstLambdas + + +class TestAuthorizeCompact(TstLambdas): + def test_authorize_compact(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff socw/readGeneral' + event['pathParameters'] = { + 'compact': 'socw', + } + + self.assertEqual({'body': 'Hurray!'}, example_entrypoint(event, self.mock_context)) + + def test_no_path_param(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.exceptions import CCInvalidRequestException + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff socw/readGeneral' + event['pathParameters'] = {} + + with self.assertRaises(CCInvalidRequestException): + example_entrypoint(event, self.mock_context) + + def test_no_authorizer(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.exceptions import CCUnauthorizedException + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + del event['requestContext']['authorizer'] + event['pathParameters'] = {'compact': 'socw'} + + with self.assertRaises(CCUnauthorizedException): + example_entrypoint(event, self.mock_context) + + def test_missing_scope(self): + from cc_common.data_model.schema.common import CCPermissionsAction + from cc_common.exceptions import CCAccessDeniedException + from cc_common.utils import authorize_compact + + @authorize_compact(action=CCPermissionsAction.READ_GENERAL) + def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + return {'body': 'Hurray!'} + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff' + event['pathParameters'] = {'compact': 'socw'} + + with self.assertRaises(CCAccessDeniedException): + example_entrypoint(event, self.mock_context) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_collect_changes.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_collect_changes.py new file mode 100644 index 0000000000..6de934da49 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_collect_changes.py @@ -0,0 +1,116 @@ +from unittest.mock import patch + +from tests import TstLambdas + + +class TestCollectChanges(TstLambdas): + """Testing that permissions changes are parsed correctly from the API""" + + def _when_testing_collect_and_authorize_changes_with_valid_jurisdiction(self, mock_compact_configuration_client): + mock_compact_configuration_client.get_active_compact_jurisdictions.return_value = [{'postalAbbreviation': 'OH'}] + + def test_compact_changes(self): + from cc_common.utils import collect_and_authorize_changes + + resp = collect_and_authorize_changes( + path_compact='socw', + scopes={'openid', 'email', 'socw/admin'}, + compact_changes={'actions': {'admin': True, 'readPrivate': False}, 'jurisdictions': {}}, + ) + self.assertEqual( + { + 'compact_action_additions': {'admin'}, + 'compact_action_removals': {'readPrivate'}, + 'jurisdiction_action_additions': {}, + 'jurisdiction_action_removals': {}, + }, + resp, + ) + + @patch('cc_common.utils.config.compact_configuration_client') + def test_jurisdiction_changes(self, mock_compact_configuration_client): + from cc_common.utils import collect_and_authorize_changes + + self._when_testing_collect_and_authorize_changes_with_valid_jurisdiction(mock_compact_configuration_client) + + resp = collect_and_authorize_changes( + path_compact='socw', + scopes={'openid', 'email', 'oh/socw.admin'}, + compact_changes={'jurisdictions': {'oh': {'actions': {'admin': True, 'write': False}}}}, + ) + self.assertEqual( + { + 'compact_action_additions': set(), + 'compact_action_removals': set(), + 'jurisdiction_action_additions': {'oh': {'admin'}}, + 'jurisdiction_action_removals': {'oh': {'write'}}, + }, + resp, + ) + + def test_disallowed_jurisdiction_changes(self): + from cc_common.exceptions import CCAccessDeniedException + from cc_common.utils import collect_and_authorize_changes + + with self.assertRaises(CCAccessDeniedException): + collect_and_authorize_changes( + path_compact='socw', + scopes={'openid', 'email', 'oh/socw.admin'}, + compact_changes={'jurisdictions': {'ne': {'actions': {'admin': True, 'write': False}}}}, + ) + + def test_jurisdiction_admin_disallowed_compact_changes(self): + from cc_common.exceptions import CCAccessDeniedException + from cc_common.utils import collect_and_authorize_changes + + with self.assertRaises(CCAccessDeniedException): + collect_and_authorize_changes( + path_compact='socw', + scopes={'openid', 'email', 'oh/socw.admin'}, + compact_changes={'actions': {'admin': True}, 'jurisdictions': {}}, + ) + + @patch('cc_common.utils.config.compact_configuration_client') + def test_compact_and_jurisdiction_changes(self, mock_compact_configuration_client): + from cc_common.utils import collect_and_authorize_changes + + self._when_testing_collect_and_authorize_changes_with_valid_jurisdiction(mock_compact_configuration_client) + + resp = collect_and_authorize_changes( + path_compact='socw', + scopes={'openid', 'email', 'socw/admin'}, + compact_changes={ + 'actions': {'admin': True, 'readPrivate': False}, + 'jurisdictions': {'oh': {'actions': {'admin': True, 'write': False}}}, + }, + ) + self.assertEqual( + { + 'compact_action_additions': {'admin'}, + 'compact_action_removals': {'readPrivate'}, + 'jurisdiction_action_additions': {'oh': {'admin'}}, + 'jurisdiction_action_removals': {'oh': {'write'}}, + }, + resp, + ) + + @patch('cc_common.utils.config.compact_configuration_client') + def test_jurisdiction_add_only(self, mock_compact_configuration_client): + from cc_common.utils import collect_and_authorize_changes + + self._when_testing_collect_and_authorize_changes_with_valid_jurisdiction(mock_compact_configuration_client) + + resp = collect_and_authorize_changes( + path_compact='socw', + scopes={'openid', 'email', 'oh/socw.admin'}, + compact_changes={'jurisdictions': {'oh': {'actions': {'admin': True}}}}, + ) + self.assertEqual( + { + 'compact_action_additions': set(), + 'compact_action_removals': set(), + 'jurisdiction_action_additions': {'oh': {'admin'}}, + 'jurisdiction_action_removals': {}, + }, + resp, + ) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py new file mode 100644 index 0000000000..875bd2a9a4 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py @@ -0,0 +1,69 @@ +import json + +from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import TypeDeserializer +from marshmallow import ValidationError + +from tests import TstLambdas + + +class TestUserRecordSchema(TstLambdas): + def test_transform_api_to_dynamo_permissions(self): + from cc_common.data_model.schema.user.api import UserAPISchema + + with open('tests/resources/api/user-post.json') as f: + api_user = json.load(f) + + with open('../common/tests/resources/dynamo/user.json') as f: + dynamo_user = TypeDeserializer().deserialize({'M': json.load(f)}) + + schema = UserAPISchema() + + # Check that we can transform the user to the DynamoDB format + dumped_user = schema.dump(api_user) + + # We're really only interested in the permissions field, where the transformation happens + self.assertEqual(dynamo_user['permissions'], dumped_user['permissions']) + + def test_transform_dynamo_to_api_permissions(self): + from cc_common.data_model.schema.user.api import UserAPISchema + from cc_common.data_model.schema.user.record import UserRecordSchema + + with open('tests/resources/api/user-post.json') as f: + api_user = json.load(f) + + with open('../common/tests/resources/dynamo/user.json') as f: + dynamo_user = UserRecordSchema().load(TypeDeserializer().deserialize({'M': json.load(f)})) + + schema = UserAPISchema() + + # Check that we can transform the user to the API format + loaded_user = schema.load(dynamo_user) + + # We're really only interested in the permissions field, where the transformation happens + self.assertEqual(api_user['permissions'], loaded_user['permissions']) + + def test_serde_record(self): + """Test round-trip serialization/deserialization of user records""" + from cc_common.data_model.schema.user.record import UserRecordSchema + + with open('../common/tests/resources/dynamo/user.json') as f: + expected_user = TypeDeserializer().deserialize({'M': json.load(f)}) + + schema = UserRecordSchema() + user_data = schema.dump(schema.load(expected_user)) + + # Drop dynamic fields that won't match + del expected_user['dateOfUpdate'] + del user_data['dateOfUpdate'] + + self.assertEqual(expected_user, user_data) + + def test_invalid_record(self): + from cc_common.data_model.schema.user.record import UserRecordSchema + + with open('../common/tests/resources/dynamo/user.json') as f: + user_data = TypeDeserializer().deserialize({'M': json.load(f)}) + user_data.pop('attributes') + + with self.assertRaises(ValidationError): + UserRecordSchema().load(user_data) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_get_allowed_jurisdictions.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_get_allowed_jurisdictions.py new file mode 100644 index 0000000000..1262c6db06 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_get_allowed_jurisdictions.py @@ -0,0 +1,32 @@ +from tests import TstLambdas + + +class TestGetAllowedJurisdictions(TstLambdas): + """Testing compact jurisdictions are identified correctly from request scopes""" + + def test_board_admin(self): + from cc_common.utils import get_allowed_jurisdictions + + resp = get_allowed_jurisdictions( + compact='socw', + scopes={'openid', 'email', 'oh/socw.admin'}, + ) + self.assertEqual(['oh'], resp) + + def test_compact_admin(self): + from cc_common.utils import get_allowed_jurisdictions + + resp = get_allowed_jurisdictions( + compact='socw', + scopes={'openid', 'email', 'socw/admin', 'oh/socw.admin'}, + ) + self.assertEqual(None, resp) + + def test_multi_jurisdiction_board_admin(self): + from cc_common.utils import get_allowed_jurisdictions + + resp = get_allowed_jurisdictions( + compact='socw', + scopes={'openid', 'email', 'oh/socw.admin', 'ky/socw.admin'}, + ) + self.assertEqual(sorted(['oh', 'ky']), sorted(resp)) diff --git a/backend/social-work-app/pipeline/__init__.py b/backend/social-work-app/pipeline/__init__.py new file mode 100644 index 0000000000..a036ff9f08 --- /dev/null +++ b/backend/social-work-app/pipeline/__init__.py @@ -0,0 +1,262 @@ +from aws_cdk import Environment, RemovalPolicy +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_s3 import IBucket +from aws_cdk.aws_sns import ITopic +from common_constructs.base_pipeline_stack import ( + ALLOWED_ENVIRONMENT_NAMES, + BETA_ENVIRONMENT_NAME, + PROD_ENVIRONMENT_NAME, + TEST_ENVIRONMENT_NAME, + BasePipelineStack, +) +from constructs import Construct + +from pipeline.backend_pipeline import BackendPipeline +from pipeline.backend_stage import BackendStage +from pipeline.synth_substitute_stage import SynthSubstituteStage + +# Action constants +ACTION_CONTEXT_KEY = 'action' +PIPELINE_STACK_CONTEXT_KEY = 'pipelineStack' +PIPELINE_SYNTH_ACTION = 'pipelineSynth' +BOOTSTRAP_DEPLOY_ACTION = 'bootstrapDeploy' + + +class BaseBackendPipelineStack(BasePipelineStack): + """ + Base class for backend pipeline stacks. + Implements common functionality for all backend pipeline stacks. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + environment_name: str, + env: Environment, + removal_policy: RemovalPolicy, + pipeline_access_logs_bucket: IBucket, + **kwargs, + ): + super().__init__( + scope, + construct_id, + environment_name=environment_name, + env=env, + pipeline_context_parameter_name=f'{environment_name}-socialwork-context', + removal_policy=removal_policy, + pipeline_access_logs_bucket=pipeline_access_logs_bucket, + **kwargs, + ) + + def _get_backend_pipeline_name(self): + if self.environment_name not in ALLOWED_ENVIRONMENT_NAMES: + raise ValueError(f'Environment name must be one of {ALLOWED_ENVIRONMENT_NAMES}') + + return f'{self.environment_name}-social-work-backendPipeline' + + def _determine_backend_stage(self, construct_id, app_name, environment_name, environment_context): + """ + Return either a real BackendStage or a SynthSubstituteStage depending on pipeline synthesis context. + + This method centralizes the stage creation logic to conditionally create a lightweight substitute + stage during pipeline synthesis when the stage is not part of the pipeline being deployed. + """ + # Check if we're in pipeline synthesis mode and if we're synthesizing this specific pipeline + action = self.node.try_get_context('action') + pipeline_stack_name = self.node.try_get_context('pipelineStack') + + # If we're in pipeline synthesis mode and this is not the pipeline being synthesized, + # use a lightweight substitute stage. Likewise, during a bootstrap deployment of the pipeline, we don't need + # to synth the application stack resources, since that will be performed when the pipeline self-mutates on the + # first deployment. + if ( + action == PIPELINE_SYNTH_ACTION and pipeline_stack_name != self.stack_name + ) or action == BOOTSTRAP_DEPLOY_ACTION: + return SynthSubstituteStage( + self, + 'SubstituteBackendStage', + environment_context=environment_context, + ) + + # Otherwise, use the real stage for deployment + return BackendStage( + self, + construct_id, + app_name=app_name, + environment_name=environment_name, + environment_context=environment_context, + backup_config=self.backup_config, + ) + + +class TestBackendPipelineStack(BaseBackendPipelineStack): + """Pipeline stack for the test backend environment""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + pipeline_shared_encryption_key: IKey, + pipeline_alarm_topic: ITopic, + pipeline_access_logs_bucket: IBucket, + cdk_path: str, + **kwargs, + ): + super().__init__( + scope, + construct_id, + environment_name=TEST_ENVIRONMENT_NAME, + removal_policy=RemovalPolicy.DESTROY, + pipeline_access_logs_bucket=pipeline_access_logs_bucket, + **kwargs, + ) + + self.pre_prod_pipeline = BackendPipeline( + self, + 'TestBackendPipeline', + pipeline_name=self._get_backend_pipeline_name(), + github_repo_string=self.github_repo_string, + cdk_path=cdk_path, + connection_arn=self.connection_arn, + git_tag_trigger_pattern='sw-test-*', + encryption_key=pipeline_shared_encryption_key, + alarm_topic=pipeline_alarm_topic, + access_logs_bucket=self.access_logs_bucket, + ssm_parameter=self.parameter, + pipeline_stack_name=self.stack_name, + environment_context=self.pipeline_environment_context, + self_mutation=True, + removal_policy=self.removal_policy, + ) + + self.test_stage = self._determine_backend_stage( + # NOTE: it is critical that the construct_id stays the same, as all the underlying stacks + # are named based on this construct_id + construct_id='Test', + app_name=self.app_name, + environment_name=TEST_ENVIRONMENT_NAME, + environment_context=self.ssm_context['environments'][TEST_ENVIRONMENT_NAME], + ) + + self.pre_prod_pipeline.add_stage(self.test_stage) + self.pre_prod_pipeline.build_pipeline() + self._add_pipeline_cdk_assume_role_policy(self.pre_prod_pipeline) + + +class BetaBackendPipelineStack(BaseBackendPipelineStack): + """Pipeline stack for the beta backend environment, triggered by the main branch.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + pipeline_shared_encryption_key: IKey, + pipeline_alarm_topic: ITopic, + pipeline_access_logs_bucket: IBucket, + cdk_path: str, + **kwargs, + ): + super().__init__( + scope, + construct_id, + environment_name=BETA_ENVIRONMENT_NAME, + removal_policy=RemovalPolicy.RETAIN, + pipeline_access_logs_bucket=pipeline_access_logs_bucket, + **kwargs, + ) + + self.beta_backend_pipeline = BackendPipeline( + self, + 'BetaBackendPipeline', + pipeline_name=self._get_backend_pipeline_name(), + github_repo_string=self.github_repo_string, + cdk_path=cdk_path, + connection_arn=self.connection_arn, + # We will explicitly tie beta deploys to the production tag, because we always want the + # beta environment code to mirror production. + git_tag_trigger_pattern='sw-prod-*', + encryption_key=pipeline_shared_encryption_key, + alarm_topic=pipeline_alarm_topic, + access_logs_bucket=self.access_logs_bucket, + ssm_parameter=self.parameter, + pipeline_stack_name=self.stack_name, + environment_context=self.pipeline_environment_context, + self_mutation=True, + removal_policy=self.removal_policy, + ) + + self.beta_backend_stage = self._determine_backend_stage( + # NOTE: it is critical that the construct_id stays the same, as all the underlying stacks + # are named based on this construct_id + construct_id='Beta', + app_name=self.app_name, + environment_name=BETA_ENVIRONMENT_NAME, + environment_context=self.ssm_context['environments'][BETA_ENVIRONMENT_NAME], + ) + + self.beta_backend_pipeline.add_stage(self.beta_backend_stage) + self.beta_backend_pipeline.build_pipeline() + # the following must be called after the pipeline is built + self._add_pipeline_cdk_assume_role_policy(self.beta_backend_pipeline) + + +class ProdBackendPipelineStack(BaseBackendPipelineStack): + """Pipeline stack for the production backend environment, triggered by the main branch.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + pipeline_shared_encryption_key: IKey, + pipeline_alarm_topic: ITopic, + pipeline_access_logs_bucket: IBucket, + cdk_path: str, + **kwargs, + ): + super().__init__( + scope, + construct_id, + environment_name=PROD_ENVIRONMENT_NAME, + removal_policy=RemovalPolicy.RETAIN, + pipeline_access_logs_bucket=pipeline_access_logs_bucket, + **kwargs, + ) + + if not self.backup_config or not self.ssm_context['environments'][PROD_ENVIRONMENT_NAME].get('backup_enabled'): + raise ValueError('Backups must be enabled for production environment.') + + self.prod_pipeline = BackendPipeline( + self, + 'ProdBackendPipeline', + pipeline_name=self._get_backend_pipeline_name(), + github_repo_string=self.github_repo_string, + cdk_path=cdk_path, + connection_arn=self.connection_arn, + git_tag_trigger_pattern='sw-prod-*', + encryption_key=pipeline_shared_encryption_key, + alarm_topic=pipeline_alarm_topic, + access_logs_bucket=self.access_logs_bucket, + ssm_parameter=self.parameter, + pipeline_stack_name=self.stack_name, + environment_context=self.pipeline_environment_context, + self_mutation=True, + removal_policy=self.removal_policy, + ) + + self.prod_stage = self._determine_backend_stage( + # NOTE: it is critical that the construct_id stays the same, as all the underlying stacks + # are named based on this construct_id + construct_id='Prod', + app_name=self.app_name, + environment_name=PROD_ENVIRONMENT_NAME, + environment_context=self.ssm_context['environments'][PROD_ENVIRONMENT_NAME], + ) + + self.prod_pipeline.add_stage(self.prod_stage) + self.prod_pipeline.build_pipeline() + # the following must be called after the pipeline is built + self._add_pipeline_cdk_assume_role_policy(self.prod_pipeline) diff --git a/backend/social-work-app/pipeline/backend_pipeline.py b/backend/social-work-app/pipeline/backend_pipeline.py new file mode 100644 index 0000000000..f2b4891460 --- /dev/null +++ b/backend/social-work-app/pipeline/backend_pipeline.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import os + +import common_constructs.base_pipeline_stack +from aws_cdk import ArnFormat, Fn, RemovalPolicy, Stack +from aws_cdk.aws_codebuild import BuildSpec, CfnProject +from aws_cdk.aws_codepipeline import PipelineType +from aws_cdk.aws_codestarnotifications import NotificationRule +from aws_cdk.aws_iam import Effect, PolicyStatement, Role, ServicePrincipal +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_s3 import BucketEncryption, IBucket +from aws_cdk.aws_sns import ITopic +from aws_cdk.aws_ssm import IParameter +from aws_cdk.pipelines import CodeBuildOptions, CodeBuildStep, CodePipelineSource +from cdk_nag import NagSuppressions +from common_constructs.base_pipeline import BasePipeline +from common_constructs.base_pipeline_stack import CCPipelineType +from common_constructs.bucket import Bucket + + +class BackendPipeline(BasePipeline): + """ + Stack for creating the Backend CodePipeline resources. + + This pipeline is part of a two-pipeline architecture where: + 1. This Backend Pipeline deploys infrastructure and creates required resources + 2. The Frontend Pipeline then deploys the frontend application using those resources + + Deployment Flow: + - Automatically triggered by git tags matching the specified pattern (e.g., 'cc-prod-*') + + The pipeline is configured with an invalid branch name to ensure it can only be executed + with explicit git tag/commit ID specifications, enforcing tag-based deployments only. + """ + + def __init__( + self, + scope: common_constructs.base_pipeline_stack.BasePipelineStack, + construct_id: str, + *, + pipeline_name: str, + github_repo_string: str, + cdk_path: str, + connection_arn: str, + git_tag_trigger_pattern: str, + access_logs_bucket: IBucket, + encryption_key: IKey, + alarm_topic: ITopic, + ssm_parameter: IParameter, + pipeline_stack_name: str, + environment_context: dict, + removal_policy: RemovalPolicy, + **kwargs, + ): + """ + Initialize the BackendPipeline. + + :param scope: The parent construct (BasePipelineStack instance). + :param construct_id: The construct ID for this pipeline, used for naming resources. + :param pipeline_name: The name of the CodePipeline pipeline. + :param github_repo_string: The GitHub repository string in the format 'owner/repo', + used for the source connection. + :param cdk_path: The path to the CDK directory where synthesis commands will be executed. + :param connection_arn: The ARN of the AWS CodeStar connection for GitHub integration. + Format: arn:aws:codeconnections:us-east-1:111122223333:connection/ + :param git_tag_trigger_pattern: The git tag pattern (glob format) that will automatically + trigger the pipeline (e.g., 'cc-prod-*', 'cc-test-*'). + :param access_logs_bucket: The S3 bucket used for storing server access logs for the + artifact bucket. + :param encryption_key: The KMS key used to encrypt the artifact bucket and alarm topic. + :param alarm_topic: The SNS topic used for pipeline notifications and alarms. + :param ssm_parameter: The SSM parameter that the synth project needs read access to. + :param pipeline_stack_name: The name of the CloudFormation stack that contains this pipeline. + Used by the self-mutation step to deploy pipeline changes. + :param environment_context: Dictionary containing environment context with 'account_id' and + 'region' keys for the pipeline environment. + :param removal_policy: The removal policy for the artifact bucket (e.g., DESTROY or RETAIN). + :param **kwargs: Additional keyword arguments passed to the BasePipeline constructor. + """ + artifact_bucket = Bucket( + scope, + f'{construct_id}ArtifactsBucket', + encryption_key=encryption_key, + encryption=BucketEncryption.KMS, + versioned=True, + server_access_logs_bucket=access_logs_bucket, + removal_policy=removal_policy, + auto_delete_objects=removal_policy == RemovalPolicy.DESTROY, + ) + NagSuppressions.add_resource_suppressions( + artifact_bucket, + suppressions=[ + { + 'id': 'HIPAA.Security-S3BucketReplicationEnabled', + 'reason': 'These artifacts are reproduced on deploy, so the resilience from replication is not' + ' necessary', + }, + ], + ) + + # Create predictable pipeline role before initializing the pipeline + pipeline_role = scope.create_predictable_pipeline_role(CCPipelineType.SOCIAL_WORK) + artifact_bucket.grant_read(pipeline_role) + + super().__init__( + scope, + construct_id, + pipeline_name=pipeline_name, + pipeline_stack_name=pipeline_stack_name, + github_repo_string=github_repo_string, + git_tag_trigger_pattern=git_tag_trigger_pattern, + pipeline_type=PipelineType.V2, + artifact_bucket=artifact_bucket, + role=pipeline_role, + use_pipeline_role_for_actions=True, + synth=CodeBuildStep( + 'Synth', + input=CodePipelineSource.connection( + repo_string=github_repo_string, + branch=self._INVALID_BRANCH_NAME, + trigger_on_push=False, + # Arn format: + # arn:aws:codeconnections:us-east-1:111122223333:connection/ + connection_arn=connection_arn, + ), + env={ + 'CDK_DEFAULT_ACCOUNT': environment_context['account_id'], + 'CDK_DEFAULT_REGION': environment_context['region'], + }, + primary_output_directory=os.path.join(cdk_path, 'cdk.out'), + commands=[ + f'cd {cdk_path}', + 'npm install -g aws-cdk', + 'python -m pip install -r requirements.txt', + '( cd lambdas/nodejs; yarn install --frozen-lockfile )', + # Only synthesize the specific pipeline stack needed + f'cdk synth --context pipelineStack={pipeline_stack_name} --context action=pipelineSynth', + ], + role=pipeline_role, + ), + synth_code_build_defaults=CodeBuildOptions( + partial_build_spec=BuildSpec.from_object( + { + 'phases': { + 'install': { + 'runtime-versions': {'python': '3.13', 'nodejs': '22.x'}, + } + } + } + ), + ), + cross_account_keys=True, + enable_key_rotation=True, + publish_assets_in_parallel=False, + **kwargs, + ) + self._ssm_parameter = ssm_parameter + + self._encryption_key = encryption_key + self._alarm_topic = alarm_topic + + def build_pipeline(self) -> None: + super().build_pipeline() + + self._ssm_parameter.grant_read(self.synth_project) + + stack = Stack.of(self) + + # Add NAG suppressions for the cross-account role's default policy + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{stack.node.path}/SocialWorkCrossAccountRole/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': ( + 'Pipeline role requires wildcard permissions for CodePipeline service operations ' + 'including S3 artifact access and cross-account role assumptions.' + ), + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + self.node.path, + suppressions=[ + { + 'id': 'HIPAA.Security-CodeBuildProjectSourceRepoUrl', + 'reason': 'This resource uses a secure integration by virtue of the CodeStar connection', + }, + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The wildcarded actions and resources are still scoped to the specific actions, bucket,' + ' key, and codebuild resources it specifically needs access to.', + }, + ], + apply_to_children=True, + ) + + self._add_alarms() + self._add_codebuild_pipeline_role_override() + + def _add_alarms(self): + NotificationRule( + self, + 'NotificationRule', + source=self.pipeline, + events=[ + 'codepipeline-pipeline-pipeline-execution-started', + 'codepipeline-pipeline-pipeline-execution-failed', + 'codepipeline-pipeline-pipeline-execution-succeeded', + 'codepipeline-pipeline-manual-approval-needed', + ], + targets=[self._alarm_topic], + ) + + # Grant CodeStar permission to use the key that encrypts the alarm topic + code_star_principal = ServicePrincipal('codestar-notifications.amazonaws.com') + self._encryption_key.grant_encrypt_decrypt(code_star_principal) + + def _add_codebuild_pipeline_role_override(self): + """ + CodePipeline does not support automatically using the pipeline role for the CodeBuild steps it generates. + To allow the Assets step to assume roles into the environment accounts, we need to force it to use the + CodePipeline role for the Assets step. + + This is done by overriding the CodeBuild role with the pipeline role for the Assets step. + """ + assets_node = self.node.try_find_child('Assets') + # The pipeline won't _always_ build an Assets step (like for the substitution stack), so we need to handle + # it not existing + if assets_node is not None: + # Override the role used + stack = Stack.of(self) + pipeline_role: Role = self.pipeline.role + file_asset_node: CfnProject = assets_node.node.try_find_child('FileAsset').node.default_child + file_asset_node.add_property_override('ServiceRole', pipeline_role.role_arn) + + # Add the permissions this role will need for the Assets step + # Note: many of the permissions needed for this step are already granted by virtue of being + # passed into the Synth CodeBuildStep, which automatically configures it with permissions. + # We don't duplicate those here. + pipeline_role.add_to_principal_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + ], + resources=[ + stack.format_arn( + partition=stack.partition, + service='logs', + region=stack.region, + account=stack.account, + resource='log-group', + resource_name=Fn.join( + '', ['/aws/codebuild/', Fn.ref(stack.get_logical_id(file_asset_node)), ':*'] + ), + arn_format=ArnFormat.COLON_RESOURCE_NAME, + ), + ], + ) + ) + pipeline_role.add_to_principal_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'sts:AssumeRole', + ], + resources=[ + stack.format_arn( + partition=stack.partition, + service='iam', + region='', + account='*', + resource='role', + resource_name=f'cdk-hnb659fds-file-publishing-role-*-{stack.region}', + ), + ], + ) + ) + pipeline_role.add_to_principal_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'codebuild:BatchPutCodeCoverages', + 'codebuild:BatchPutTestCases', + 'codebuild:CreateReport', + 'codebuild:CreateReportGroup', + 'codebuild:UpdateReport', + ], + resources=[ + Fn.join( + '', + [ + stack.format_arn( + partition=stack.partition, + service='codebuild', + region=stack.region, + account=stack.account, + resource='report-group', + resource_name='', + ), + Fn.ref(stack.get_logical_id(file_asset_node)), + '-*', + ], + ), + ], + ) + ) + + # Now, remove the unused role and default policy + assets_node.node.try_remove_child('FileRole') diff --git a/backend/social-work-app/pipeline/backend_stage.py b/backend/social-work-app/pipeline/backend_stage.py new file mode 100644 index 0000000000..2819220250 --- /dev/null +++ b/backend/social-work-app/pipeline/backend_stage.py @@ -0,0 +1,206 @@ +from aws_cdk import Environment, Stage +from common_constructs.stack import StandardTags +from constructs import Construct + +from stacks.api_lambda_stack import ApiLambdaStack +from stacks.api_stack import ApiStack +from stacks.disaster_recovery_stack import DisasterRecoveryStack +from stacks.event_state_stack import EventStateStack + +# from stacks.feature_flag_stack import FeatureFlagStack +from stacks.ingest_stack import IngestStack +from stacks.managed_login_stack import ManagedLoginStack +from stacks.notification_stack import NotificationStack +from stacks.persistent_stack import PersistentStack +from stacks.reporting_stack import ReportingStack +from stacks.search_api_stack import SearchApiStack +from stacks.search_persistent_stack import SearchPersistentStack +from stacks.state_api_stack import StateApiStack +from stacks.state_auth import StateAuthStack +from stacks.vpc_stack import VpcStack + + +class BackendStage(Stage): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + app_name: str, + environment_name: str, + environment_context: dict, + backup_config: dict, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + standard_tags = StandardTags(**self.node.get_context('tags'), environment=environment_name) + + environment = Environment(account=environment_context['account_id'], region=environment_context['region']) + + # VPC Stack - provides networking infrastructure for OpenSearch and Lambda functions + self.vpc_stack = VpcStack( + self, + 'VpcStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + ) + + self.persistent_stack = PersistentStack( + self, + 'PersistentStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + app_name=app_name, + environment_name=environment_name, + backup_config=backup_config, + ) + + # Backup infrastructure is now created as a nested stack within PersistentStack + # if backups are enabled for this environment + self.backup_infrastructure_stack = self.persistent_stack.backup_infrastructure_stack + + self.event_state_stack = EventStateStack( + self, + 'EventStateStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + persistent_stack=self.persistent_stack, + ) + + self.state_auth_stack = StateAuthStack( + self, + 'StateAuthStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + app_name=app_name, + environment_name=environment_name, + persistent_stack=self.persistent_stack, + ) + + self.managed_login_stack = ManagedLoginStack( + self, + 'ManagedLoginStack', + env=environment, + environment_context=environment_context, + environment_name=environment_name, + standard_tags=standard_tags, + persistent_stack=self.persistent_stack, + ) + + self.ingest_stack = IngestStack( + self, + 'IngestStack', + env=environment, + environment_context=environment_context, + environment_name=environment_name, + standard_tags=standard_tags, + persistent_stack=self.persistent_stack, + ) + + self.api_lambda_stack = ApiLambdaStack( + self, + 'ApiLambdaStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + persistent_stack=self.persistent_stack, + ) + + # Search Persistent Stack - OpenSearch Domain (created before ApiStack for public search wiring) + self.search_persistent_stack = SearchPersistentStack( + self, + 'SearchPersistentStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + vpc_stack=self.vpc_stack, + persistent_stack=self.persistent_stack, + ) + + self.api_stack = ApiStack( + self, + 'APIStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + persistent_stack=self.persistent_stack, + api_lambda_stack=self.api_lambda_stack, + search_persistent_stack=self.search_persistent_stack, + ) + + self.state_api_stack = StateApiStack( + self, + 'StateAPIStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + persistent_stack=self.persistent_stack, + state_auth_stack=self.state_auth_stack, + ) + + # Reporting and notifications depend on emails, which depend on having a domain name. If we don't configure + # a HostedZone we won't bother with these whole stacks. + if self.persistent_stack.hosted_zone: + self.notification_stack = NotificationStack( + self, + 'NotificationStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + persistent_stack=self.persistent_stack, + event_state_stack=self.event_state_stack, + ) + + self.reporting_stack = ReportingStack( + self, + 'ReportingStack', + env=environment, + environment_context=environment_context, + environment_name=environment_name, + standard_tags=standard_tags, + persistent_stack=self.persistent_stack, + ) + + # Disaster recovery workflows for DynamoDB tables + self.disaster_recovery_stack = DisasterRecoveryStack( + self, + 'DisasterRecoveryStack', + env=environment, + environment_name=environment_name, + environment_context=environment_context, + standard_tags=standard_tags, + persistent_stack=self.persistent_stack, + ) + + # Stack to create and manage feature flags + # self.feature_flag_stack = FeatureFlagStack( + # self, + # 'FeatureFlagStack', + # env=environment, + # environment_name=environment_name, + # environment_context=environment_context, + # standard_tags=standard_tags, + # ) + + self.search_api_stack = SearchApiStack( + self, + 'SearchAPIStack', + env=environment, + environment_context=environment_context, + standard_tags=standard_tags, + environment_name=environment_name, + persistent_stack=self.persistent_stack, + search_persistent_stack=self.search_persistent_stack, + ) diff --git a/backend/social-work-app/pipeline/synth_substitute_stack.py b/backend/social-work-app/pipeline/synth_substitute_stack.py new file mode 100644 index 0000000000..c5dda27394 --- /dev/null +++ b/backend/social-work-app/pipeline/synth_substitute_stack.py @@ -0,0 +1,30 @@ +from aws_cdk import Stack, aws_ssm +from constructs import Construct + + +class SynthSubstituteStack(Stack): + """ + A lightweight stack used as a substitute during pipeline synthesis. + + This stack is used to optimize CDK pipeline synthesis by replacing + heavyweight stacks with a minimal stack that contains just a dummy + SSM parameter. This dramatically reduces synthesis time when only + a specific pipeline's stacks need to be synthesized. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + # Create a simple SSM parameter as a lightweight substitute + self.dummy_parameter = aws_ssm.StringParameter( + self, + 'DummyParameter', + parameter_name=f'/compact-connect/{construct_id}/dummy-parameter', + string_value='dummy parameter value', + description='Dummy parameter used for CDK synthesis optimization', + ) diff --git a/backend/social-work-app/pipeline/synth_substitute_stage.py b/backend/social-work-app/pipeline/synth_substitute_stage.py new file mode 100644 index 0000000000..d7aca239c9 --- /dev/null +++ b/backend/social-work-app/pipeline/synth_substitute_stage.py @@ -0,0 +1,38 @@ +from aws_cdk import Environment, Stage +from constructs import Construct + +from pipeline.synth_substitute_stack import SynthSubstituteStack + + +class SynthSubstituteStage(Stage): + """ + A lightweight stage used as a substitute during pipeline synthesis. + + This stage is used to optimize CDK pipeline synthesis by replacing + heavyweight stages with a minimal stage that contains just a single + SynthSubstituteStack. This dramatically reduces synthesis time when + only a specific pipeline's stages need to be synthesized. + + Using a separate stage rather than conditional logic within existing + stages provides an additional safety layer - preventing accidental + deletion of production resources due to typos in pipeline names. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_context: dict, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + environment = Environment(account=environment_context['account_id'], region=environment_context['region']) + + # Create a simple substitute stack + self.substitute_stack = SynthSubstituteStack( + self, + 'SubstituteStack', + env=environment, + ) diff --git a/backend/social-work-app/requirements-dev.in b/backend/social-work-app/requirements-dev.in new file mode 100644 index 0000000000..9d4ec0c0a3 --- /dev/null +++ b/backend/social-work-app/requirements-dev.in @@ -0,0 +1,7 @@ +pytest>=6.2.5 +pytest-cov +coverage +ruff +pip-tools +pip-audit +Faker>=40, <41 diff --git a/backend/social-work-app/requirements-dev.txt b/backend/social-work-app/requirements-dev.txt new file mode 100644 index 0000000000..601d86237e --- /dev/null +++ b/backend/social-work-app/requirements-dev.txt @@ -0,0 +1,107 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras requirements-dev.in +# +boolean-py==5.0 + # via license-expression +build==1.5.0 + # via pip-tools +cachecontrol[filecache]==0.14.4 + # via + # cachecontrol + # pip-audit +certifi==2026.4.22 + # via requests +charset-normalizer==3.4.7 + # via requests +click==8.3.3 + # via pip-tools +coverage[toml]==7.14.0 + # via + # -r requirements-dev.in + # pytest-cov +cyclonedx-python-lib==11.7.0 + # via pip-audit +defusedxml==0.7.1 + # via py-serializable +faker==40.15.0 + # via -r requirements-dev.in +filelock==3.29.0 + # via cachecontrol +idna==3.15 + # via requests +iniconfig==2.3.0 + # via pytest +license-expression==30.4.4 + # via cyclonedx-python-lib +markdown-it-py==4.2.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +msgpack==1.1.2 + # via cachecontrol +packageurl-python==0.17.6 + # via cyclonedx-python-lib +packaging==26.2 + # via + # build + # pip-audit + # pip-requirements-parser + # pytest + # wheel +pip-api==0.0.34 + # via pip-audit +pip-audit==2.10.0 + # via -r requirements-dev.in +pip-requirements-parser==32.0.1 + # via pip-audit +pip-tools==7.5.3 + # via -r requirements-dev.in +platformdirs==4.9.6 + # via pip-audit +pluggy==1.6.0 + # via + # pytest + # pytest-cov +py-serializable==2.1.0 + # via cyclonedx-python-lib +pygments==2.20.0 + # via + # pytest + # rich +pyparsing==3.3.2 + # via pip-requirements-parser +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pytest==9.0.3 + # via + # -r requirements-dev.in + # pytest-cov +pytest-cov==7.1.0 + # via -r requirements-dev.in +requests==2.34.1 + # via + # cachecontrol + # pip-audit +rich==15.0.0 + # via pip-audit +ruff==0.15.13 + # via -r requirements-dev.in +sortedcontainers==2.4.0 + # via cyclonedx-python-lib +tomli==2.4.1 + # via pip-audit +tomli-w==1.2.0 + # via pip-audit +urllib3==2.7.0 + # via requests +wheel==0.47.0 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/backend/social-work-app/requirements.in b/backend/social-work-app/requirements.in new file mode 100644 index 0000000000..66c7ec3b47 --- /dev/null +++ b/backend/social-work-app/requirements.in @@ -0,0 +1,6 @@ +aws-cdk-lib>=2.256.1 +aws-cdk-aws-lambda-python-alpha>=2.256.1a0 +constructs>=10.6.0,<11.0.0 +cdk-nag>=2.37.55,<3 +# pyyaml required for compact configuration uploader +pyyaml>=6.0.2, <7 diff --git a/backend/social-work-app/requirements.txt b/backend/social-work-app/requirements.txt new file mode 100644 index 0000000000..2b5f638c25 --- /dev/null +++ b/backend/social-work-app/requirements.txt @@ -0,0 +1,74 @@ +# +# This file is autogenerated by pip-compile with Python 3.14 +# by the following command: +# +# pip-compile --no-emit-index-url --no-strip-extras requirements.in +# +attrs==25.4.0 + # via + # cattrs + # jsii +aws-cdk-asset-awscli-v1==2.2.273 + # via aws-cdk-lib +aws-cdk-asset-node-proxy-agent-v6==2.1.1 + # via aws-cdk-lib +aws-cdk-aws-lambda-python-alpha==2.254.0a0 + # via -r requirements.in +aws-cdk-cloud-assembly-schema==53.23.0 + # via aws-cdk-lib +aws-cdk-lib==2.254.0 + # via + # -r requirements.in + # aws-cdk-aws-lambda-python-alpha + # cdk-nag +cattrs==25.3.0 + # via jsii +cdk-nag==2.38.2 + # via -r requirements.in +constructs==10.6.0 + # via + # -r requirements.in + # aws-cdk-aws-lambda-python-alpha + # aws-cdk-lib + # cdk-nag +importlib-resources==7.1.0 + # via jsii +jsii==1.129.0 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-aws-lambda-python-alpha + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # cdk-nag + # constructs +publication==0.0.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-aws-lambda-python-alpha + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # cdk-nag + # constructs + # jsii +python-dateutil==2.9.0.post0 + # via jsii +pyyaml==6.0.3 + # via -r requirements.in +six==1.17.0 + # via python-dateutil +typeguard==2.13.3 + # via + # aws-cdk-asset-awscli-v1 + # aws-cdk-asset-node-proxy-agent-v6 + # aws-cdk-aws-lambda-python-alpha + # aws-cdk-cloud-assembly-schema + # aws-cdk-lib + # cdk-nag + # constructs + # jsii +typing-extensions==4.15.0 + # via + # cattrs + # jsii diff --git a/backend/social-work-app/resources/assets/compact-connect-logo.png b/backend/social-work-app/resources/assets/compact-connect-logo.png new file mode 100644 index 0000000000..4303d21505 Binary files /dev/null and b/backend/social-work-app/resources/assets/compact-connect-logo.png differ diff --git a/backend/social-work-app/resources/assets/favicon.ico b/backend/social-work-app/resources/assets/favicon.ico new file mode 100644 index 0000000000..d5097c99cb Binary files /dev/null and b/backend/social-work-app/resources/assets/favicon.ico differ diff --git a/backend/social-work-app/resources/assets/staff-background.png b/backend/social-work-app/resources/assets/staff-background.png new file mode 100644 index 0000000000..0b9906e1f2 Binary files /dev/null and b/backend/social-work-app/resources/assets/staff-background.png differ diff --git a/backend/social-work-app/resources/bootstrap-stack-beta.yaml b/backend/social-work-app/resources/bootstrap-stack-beta.yaml new file mode 100644 index 0000000000..f839e4dc2e --- /dev/null +++ b/backend/social-work-app/resources/bootstrap-stack-beta.yaml @@ -0,0 +1,824 @@ +Description: This stack includes resources needed to deploy AWS CDK apps into this environment +Parameters: + TrustedAccounts: + Description: List of AWS accounts that are trusted to publish assets and deploy stacks to this environment + Default: "" + Type: CommaDelimitedList + TrustedAccountsForLookup: + Description: List of AWS accounts that are trusted to look up values in this environment + Default: "" + Type: CommaDelimitedList + CloudFormationExecutionPolicies: + Description: List of the ManagedPolicy ARN(s) to attach to the CloudFormation deployment role + Default: "" + Type: CommaDelimitedList + FileAssetsBucketName: + Description: The name of the S3 bucket used for file assets + Default: "" + Type: String + FileAssetsBucketKmsKeyId: + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed S3 key, or the ID/ARN of an existing key. + Default: "" + Type: String + ContainerAssetsRepositoryName: + Description: A user-provided custom name to use for the container assets ECR repository + Default: "" + Type: String + Qualifier: + Description: An identifier to distinguish multiple bootstrap stacks in the same environment + Default: hnb659fds + Type: String + AllowedPattern: "[A-Za-z0-9_-]{1,10}" + ConstraintDescription: Qualifier must be an alphanumeric identifier of at most 10 characters + PublicAccessBlockConfiguration: + Description: Whether or not to enable S3 Staging Bucket Public Access Block Configuration + Default: "true" + Type: String + AllowedValues: + - "true" + - "false" + InputPermissionsBoundary: + Description: Whether or not to use either the CDK supplied or custom permissions boundary + Default: "" + Type: String + UseExamplePermissionsBoundary: + Default: "false" + AllowedValues: + - "true" + - "false" + Type: String + BootstrapVariant: + Type: String + Default: "CompactConnect: Secure Bootstrap - Beta Environment" + Description: Secure bootstrap template for CompactConnect beta environment with specific role trust policies + +Conditions: + HasTrustedAccounts: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccounts + HasTrustedAccountsForLookup: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccountsForLookup + HasCloudFormationExecutionPolicies: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: CloudFormationExecutionPolicies + HasCustomFileAssetsBucketName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: FileAssetsBucketName + CreateNewKey: + Fn::Equals: + - "" + - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - AWS_MANAGED_KEY + - Ref: FileAssetsBucketKmsKeyId + ShouldCreatePermissionsBoundary: + Fn::Equals: + - "true" + - Ref: UseExamplePermissionsBoundary + PermissionsBoundarySet: + Fn::Not: + - Fn::Equals: + - "" + - Ref: InputPermissionsBoundary + HasCustomContainerAssetsRepositoryName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: ContainerAssetsRepositoryName + UsePublicAccessBlockConfiguration: + Fn::Equals: + - "true" + - Ref: PublicAccessBlockConfiguration + +Resources: + FileAssetsBucketEncryptionKey: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + - kms:TagResource + - kms:UntagResource + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + Resource: "*" + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: + Ref: AWS::AccountId + kms:ViaService: + - Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: + Fn::Sub: ${FilePublishingRole.Arn} + Resource: "*" + Condition: CreateNewKey + FileAssetsBucketEncryptionKeyAlias: + Condition: CreateNewKey + Type: AWS::KMS::Alias + Properties: + AliasName: + Fn::Sub: alias/cdk-${Qualifier}-assets-key + TargetKeyId: + Ref: FileAssetsBucketEncryptionKey + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::If: + - HasCustomFileAssetsBucketName + - Fn::Sub: ${FileAssetsBucketName} + - Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: aws:kms + KMSMasterKeyID: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + PublicAccessBlockConfiguration: + Fn::If: + - UsePublicAccessBlockConfiguration + - BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + - Ref: AWS::NoValue + VersioningConfiguration: + Status: Enabled + LifecycleConfiguration: + Rules: + - Id: CleanupOldVersions + Status: Enabled + NoncurrentVersionExpiration: + NoncurrentDays: 365 + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + StagingBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: StagingBucket + PolicyDocument: + Id: AccessControl + Version: "2012-10-17" + Statement: + - Sid: AllowSSLRequestsOnly + Action: s3:* + Effect: Deny + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + Bool: + aws:SecureTransport: "false" + Principal: "*" + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + ImageTagMutability: IMMUTABLE + LifecyclePolicy: + LifecyclePolicyText: | + { + "rules": [ + { + "rulePriority": 1, + "description": "Untagged images should not exist, but expire any older than one year", + "selection": { + "tagStatus": "untagged", + "countType": "sinceImagePushed", + "countUnit": "days", + "countNumber": 365 + }, + "action": { "type": "expire" } + } + ] + } + RepositoryName: + Fn::If: + - HasCustomContainerAssetsRepositoryName + - Fn::Sub: ${ContainerAssetsRepositoryName} + - Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + RepositoryPolicyText: + Version: "2012-10-17" + Statement: + - Sid: LambdaECRImageRetrievalPolicy + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Condition: + StringLike: + aws:sourceArn: + Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:* + FilePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-beta-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: file-publishing + ImagePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-beta-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: image-publishing + LookupRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-beta-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-lookup-role-${AWS::AccountId}-${AWS::Region} + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess + Policies: + - PolicyDocument: + Statement: + - Sid: DontReadSecrets + Effect: Deny + Action: + - kms:Decrypt + Resource: "*" + Version: "2012-10-17" + PolicyName: LookupRolePolicy + Tags: + - Key: aws-cdk:bootstrap-role + Value: lookup + FilePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:GetEncryptionConfiguration + - s3:List* + - s3:DeleteObject* + - s3:PutObject* + - s3:Abort* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + StringEquals: + aws:ResourceAccount: + - Fn::Sub: ${AWS::AccountId} + Effect: Allow + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Resource: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/${FileAssetsBucketKmsKeyId} + Version: "2012-10-17" + Roles: + - Ref: FilePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + ImagePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - ecr:BatchCheckLayerAvailability + - ecr:DescribeRepositories + - ecr:DescribeImages + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: ${ContainerAssetsRepository.Arn} + Effect: Allow + - Action: + - ecr:GetAuthorizationToken + Resource: "*" + Effect: Allow + Version: "2012-10-17" + Roles: + - Ref: ImagePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + DeploymentActionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-beta-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + Policies: + - PolicyDocument: + Statement: + - Sid: CloudFormationPermissions + Effect: Allow + Action: + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:DescribeStacks + - cloudformation:ExecuteChangeSet + - cloudformation:CreateStack + - cloudformation:UpdateStack + - cloudformation:RollbackStack + - cloudformation:ContinueUpdateRollback + Resource: "*" + - Sid: PipelineCrossAccountArtifactsBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:Abort* + - s3:DeleteObject* + - s3:PutObject* + Resource: "*" + Condition: + StringNotEquals: + s3:ResourceAccount: + Ref: AWS::AccountId + - Sid: PipelineCrossAccountArtifactsKey + Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + kms:ViaService: + Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: iam:PassRole + Resource: + Fn::Sub: ${CloudFormationExecutionRole.Arn} + Effect: Allow + - Sid: CliPermissions + Action: + - cloudformation:DescribeStackEvents + - cloudformation:GetTemplate + - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection + - sts:GetCallerIdentity + - cloudformation:GetTemplateSummary + Resource: "*" + Effect: Allow + - Sid: CliStagingBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion} + Version: "2012-10-17" + PolicyName: default + RoleName: + Fn::Sub: cdk-${Qualifier}-deploy-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: deploy + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + Fn::If: + - HasCloudFormationExecutionPolicies + - Ref: CloudFormationExecutionPolicies + - Fn::If: + - HasTrustedAccounts + - Ref: AWS::NoValue + - - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + PermissionsBoundary: + Fn::If: + - PermissionsBoundarySet + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/${InputPermissionsBoundary} + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + CloudFormationExecutionBoundaryPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Statement: + - Sid: AllowCompactConnectServices + Effect: Allow + Action: + # API Gateway + - apigateway:* + # AWS Backup + - backup:* + # AWS Backup Storage + - backup-storage:* + # AWS Certificate Manager + - acm:* + # AWS Chatbot + - chatbot:* + # AWS CloudFormation + - cloudformation:* + # Amazon CloudFront + - cloudfront:* + # Amazon CloudWatch + - cloudwatch:* + - logs:* + # Amazon Cognito + - cognito-idp:* + - cognito-identity:* + # Amazon DynamoDB + - dynamodb:* + # Amazon EventBridge + - events:* + # AWS IAM (limited) + - iam:* + # AWS Key Management Service + - kms:* + # AWS Lambda + - lambda:* + # Amazon OpenSearch Service + - es:* + # Amazon EventBridge Pipes + - pipes:* + # Amazon Route 53 + - route53:* + # Amazon S3 + - s3:* + # AWS Secrets Manager + - secretsmanager:* + # Amazon SES + - ses:* + # Amazon SNS + - sns:* + # Amazon SQS + - sqs:* + # AWS Systems Manager + - ssm:* + # AWS Step Functions + - states:* + # AWS WAF V2 + - wafv2:* + # Support for CDK operations + - sts:AssumeRole + - sts:GetCallerIdentity + - sts:TagSession + Resource: "*" + # VPC Resources - Restricted EC2 permissions for VPC networking only + - Sid: AllowVpcNetworkingResources + Effect: Allow + Action: + # VPC management + - ec2:CreateVpc + - ec2:DeleteVpc + - ec2:DescribeVpcs + - ec2:ModifyVpcAttribute + - ec2:DescribeVpcAttribute + # Subnet management + - ec2:CreateSubnet + - ec2:DeleteSubnet + - ec2:DescribeSubnets + - ec2:ModifySubnetAttribute + # Route table management + - ec2:CreateRouteTable + - ec2:DeleteRouteTable + - ec2:DescribeRouteTables + - ec2:AssociateRouteTable + - ec2:DisassociateRouteTable + - ec2:CreateRoute + - ec2:DeleteRoute + - ec2:ReplaceRoute + # Security group management + - ec2:CreateSecurityGroup + - ec2:DeleteSecurityGroup + - ec2:DescribeSecurityGroups + - ec2:DescribeSecurityGroupRules + - ec2:AuthorizeSecurityGroupIngress + - ec2:AuthorizeSecurityGroupEgress + - ec2:RevokeSecurityGroupIngress + - ec2:RevokeSecurityGroupEgress + - ec2:UpdateSecurityGroupRuleDescriptionsIngress + - ec2:UpdateSecurityGroupRuleDescriptionsEgress + # VPC Endpoint management + - ec2:CreateVpcEndpoint + - ec2:DeleteVpcEndpoints + - ec2:DescribeVpcEndpoints + - ec2:ModifyVpcEndpoint + - ec2:DescribeVpcEndpointServices + - ec2:DescribePrefixLists + # VPC Flow Logs + - ec2:CreateFlowLogs + - ec2:DeleteFlowLogs + - ec2:DescribeFlowLogs + # Tagging + - ec2:CreateTags + - ec2:DeleteTags + # General describe operations needed by CDK + - ec2:DescribeAvailabilityZones + - ec2:DescribeNetworkInterfaces + Resource: "*" + # Explicitly deny EC2 instance operations + - Sid: DenyEc2InstanceOperations + Effect: Deny + Action: + - ec2:RunInstances + - ec2:StartInstances + - ec2:StopInstances + - ec2:TerminateInstances + - ec2:RebootInstances + - ec2:CreateImage + - ec2:RegisterImage + - ec2:ImportInstance + - ec2:ImportImage + - ec2:RequestSpotInstances + - ec2:RequestSpotFleet + - ec2:ModifyInstanceAttribute + - ec2:ModifySpotFleetRequest + - ec2:CreateLaunchTemplate + - ec2:CreateLaunchTemplateVersion + - ec2:ModifyLaunchTemplate + Resource: "*" + - Sid: DenyDangerousActions + Effect: Deny + Action: + # Prevent account-level changes + - organizations:* + - account:* + # Prevent billing changes + - aws-portal:* + - budgets:* + - ce:* + - cur:* + # Prevent support case changes + - support:* + # Prevent marketplace changes + - aws-marketplace:* + Resource: "*" + - Sid: AllowPassSelf + Effect: Allow + Action: + - iam:PassRole + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + - Sid: DenySelfTampering + Effect: Deny + NotAction: + - iam:PassRole + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + - Sid: DenyBoundaryPolicyTampering + Effect: Deny + Action: + - iam:* + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Version: "2012-10-17" + Description: Permissions boundary for CloudFormation execution role - limits to CompactConnect required services + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + Path: / + CdkBoostrapPermissionsBoundaryPolicy: + Condition: ShouldCreatePermissionsBoundary + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Statement: + - Sid: ExplicitAllowAll + Action: + - "*" + Effect: Allow + Resource: "*" + - Sid: DenyAccessIfRequiredPermBoundaryIsNotBeingApplied + Action: + - iam:CreateUser + - iam:CreateRole + - iam:PutRolePermissionsBoundary + - iam:PutUserPermissionsBoundary + Condition: + StringNotEquals: + iam:PermissionsBoundary: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Effect: Deny + Resource: "*" + - Sid: DenyPermBoundaryIAMPolicyAlteration + Action: + - iam:CreatePolicyVersion + - iam:DeletePolicy + - iam:DeletePolicyVersion + - iam:SetDefaultPolicyVersion + Effect: Deny + Resource: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + - Sid: DenyRemovalOfPermBoundaryFromAnyUserOrRole + Action: + - iam:DeleteUserPermissionsBoundary + - iam:DeleteRolePermissionsBoundary + Effect: Deny + Resource: "*" + Version: "2012-10-17" + Description: Bootstrap Permission Boundary + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Path: / + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: "23" +Outputs: + BucketName: + Description: The name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket} + BucketDomainName: + Description: The domain name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket.RegionalDomainName} + FileAssetKeyArn: + Description: The ARN of the KMS key used to encrypt the asset bucket (deprecated) + Value: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + Export: + Name: + Fn::Sub: CdkBootstrap-${Qualifier}-FileAssetKeyArn + ImageRepositoryName: + Description: The name of the ECR repository which hosts docker image assets + Value: + Fn::Sub: ${ContainerAssetsRepository} + BootstrapVersion: + Description: The version of the bootstrap resources that are currently mastered in this stack + Value: + Fn::GetAtt: + - CdkBootstrapVersion + - Value diff --git a/backend/social-work-app/resources/bootstrap-stack-prod.yaml b/backend/social-work-app/resources/bootstrap-stack-prod.yaml new file mode 100644 index 0000000000..41a374d1a2 --- /dev/null +++ b/backend/social-work-app/resources/bootstrap-stack-prod.yaml @@ -0,0 +1,824 @@ +Description: This stack includes resources needed to deploy AWS CDK apps into this environment +Parameters: + TrustedAccounts: + Description: List of AWS accounts that are trusted to publish assets and deploy stacks to this environment + Default: "" + Type: CommaDelimitedList + TrustedAccountsForLookup: + Description: List of AWS accounts that are trusted to look up values in this environment + Default: "" + Type: CommaDelimitedList + CloudFormationExecutionPolicies: + Description: List of the ManagedPolicy ARN(s) to attach to the CloudFormation deployment role + Default: "" + Type: CommaDelimitedList + FileAssetsBucketName: + Description: The name of the S3 bucket used for file assets + Default: "" + Type: String + FileAssetsBucketKmsKeyId: + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed S3 key, or the ID/ARN of an existing key. + Default: "" + Type: String + ContainerAssetsRepositoryName: + Description: A user-provided custom name to use for the container assets ECR repository + Default: "" + Type: String + Qualifier: + Description: An identifier to distinguish multiple bootstrap stacks in the same environment + Default: hnb659fds + Type: String + AllowedPattern: "[A-Za-z0-9_-]{1,10}" + ConstraintDescription: Qualifier must be an alphanumeric identifier of at most 10 characters + PublicAccessBlockConfiguration: + Description: Whether or not to enable S3 Staging Bucket Public Access Block Configuration + Default: "true" + Type: String + AllowedValues: + - "true" + - "false" + InputPermissionsBoundary: + Description: Whether or not to use either the CDK supplied or custom permissions boundary + Default: "" + Type: String + UseExamplePermissionsBoundary: + Default: "false" + AllowedValues: + - "true" + - "false" + Type: String + BootstrapVariant: + Type: String + Default: "CompactConnect: Secure Bootstrap - Production Environment" + Description: Secure bootstrap template for CompactConnect prod environment with specific role trust policies + +Conditions: + HasTrustedAccounts: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccounts + HasTrustedAccountsForLookup: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccountsForLookup + HasCloudFormationExecutionPolicies: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: CloudFormationExecutionPolicies + HasCustomFileAssetsBucketName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: FileAssetsBucketName + CreateNewKey: + Fn::Equals: + - "" + - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - AWS_MANAGED_KEY + - Ref: FileAssetsBucketKmsKeyId + ShouldCreatePermissionsBoundary: + Fn::Equals: + - "true" + - Ref: UseExamplePermissionsBoundary + PermissionsBoundarySet: + Fn::Not: + - Fn::Equals: + - "" + - Ref: InputPermissionsBoundary + HasCustomContainerAssetsRepositoryName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: ContainerAssetsRepositoryName + UsePublicAccessBlockConfiguration: + Fn::Equals: + - "true" + - Ref: PublicAccessBlockConfiguration + +Resources: + FileAssetsBucketEncryptionKey: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + - kms:TagResource + - kms:UntagResource + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + Resource: "*" + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: + Ref: AWS::AccountId + kms:ViaService: + - Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: + Fn::Sub: ${FilePublishingRole.Arn} + Resource: "*" + Condition: CreateNewKey + FileAssetsBucketEncryptionKeyAlias: + Condition: CreateNewKey + Type: AWS::KMS::Alias + Properties: + AliasName: + Fn::Sub: alias/cdk-${Qualifier}-assets-key + TargetKeyId: + Ref: FileAssetsBucketEncryptionKey + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::If: + - HasCustomFileAssetsBucketName + - Fn::Sub: ${FileAssetsBucketName} + - Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: aws:kms + KMSMasterKeyID: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + PublicAccessBlockConfiguration: + Fn::If: + - UsePublicAccessBlockConfiguration + - BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + - Ref: AWS::NoValue + VersioningConfiguration: + Status: Enabled + LifecycleConfiguration: + Rules: + - Id: CleanupOldVersions + Status: Enabled + NoncurrentVersionExpiration: + NoncurrentDays: 365 + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + StagingBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: StagingBucket + PolicyDocument: + Id: AccessControl + Version: "2012-10-17" + Statement: + - Sid: AllowSSLRequestsOnly + Action: s3:* + Effect: Deny + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + Bool: + aws:SecureTransport: "false" + Principal: "*" + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + ImageTagMutability: IMMUTABLE + LifecyclePolicy: + LifecyclePolicyText: | + { + "rules": [ + { + "rulePriority": 1, + "description": "Untagged images should not exist, but expire any older than one year", + "selection": { + "tagStatus": "untagged", + "countType": "sinceImagePushed", + "countUnit": "days", + "countNumber": 365 + }, + "action": { "type": "expire" } + } + ] + } + RepositoryName: + Fn::If: + - HasCustomContainerAssetsRepositoryName + - Fn::Sub: ${ContainerAssetsRepositoryName} + - Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + RepositoryPolicyText: + Version: "2012-10-17" + Statement: + - Sid: LambdaECRImageRetrievalPolicy + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Condition: + StringLike: + aws:sourceArn: + Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:* + FilePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-prod-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: file-publishing + ImagePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-prod-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: image-publishing + LookupRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-prod-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-lookup-role-${AWS::AccountId}-${AWS::Region} + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess + Policies: + - PolicyDocument: + Statement: + - Sid: DontReadSecrets + Effect: Deny + Action: + - kms:Decrypt + Resource: "*" + Version: "2012-10-17" + PolicyName: LookupRolePolicy + Tags: + - Key: aws-cdk:bootstrap-role + Value: lookup + FilePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:GetEncryptionConfiguration + - s3:List* + - s3:DeleteObject* + - s3:PutObject* + - s3:Abort* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + StringEquals: + aws:ResourceAccount: + - Fn::Sub: ${AWS::AccountId} + Effect: Allow + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Resource: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/${FileAssetsBucketKmsKeyId} + Version: "2012-10-17" + Roles: + - Ref: FilePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + ImagePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - ecr:BatchCheckLayerAvailability + - ecr:DescribeRepositories + - ecr:DescribeImages + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: ${ContainerAssetsRepository.Arn} + Effect: Allow + - Action: + - ecr:GetAuthorizationToken + Resource: "*" + Effect: Allow + Version: "2012-10-17" + Roles: + - Ref: ImagePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + DeploymentActionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-prod-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + Policies: + - PolicyDocument: + Statement: + - Sid: CloudFormationPermissions + Effect: Allow + Action: + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:DescribeStacks + - cloudformation:ExecuteChangeSet + - cloudformation:CreateStack + - cloudformation:UpdateStack + - cloudformation:RollbackStack + - cloudformation:ContinueUpdateRollback + Resource: "*" + - Sid: PipelineCrossAccountArtifactsBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:Abort* + - s3:DeleteObject* + - s3:PutObject* + Resource: "*" + Condition: + StringNotEquals: + s3:ResourceAccount: + Ref: AWS::AccountId + - Sid: PipelineCrossAccountArtifactsKey + Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + kms:ViaService: + Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: iam:PassRole + Resource: + Fn::Sub: ${CloudFormationExecutionRole.Arn} + Effect: Allow + - Sid: CliPermissions + Action: + - cloudformation:DescribeStackEvents + - cloudformation:GetTemplate + - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection + - sts:GetCallerIdentity + - cloudformation:GetTemplateSummary + Resource: "*" + Effect: Allow + - Sid: CliStagingBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion} + Version: "2012-10-17" + PolicyName: default + RoleName: + Fn::Sub: cdk-${Qualifier}-deploy-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: deploy + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + Fn::If: + - HasCloudFormationExecutionPolicies + - Ref: CloudFormationExecutionPolicies + - Fn::If: + - HasTrustedAccounts + - Ref: AWS::NoValue + - - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + PermissionsBoundary: + Fn::If: + - PermissionsBoundarySet + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/${InputPermissionsBoundary} + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + CloudFormationExecutionBoundaryPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Statement: + - Sid: AllowCompactConnectServices + Effect: Allow + Action: + # API Gateway + - apigateway:* + # AWS Backup + - backup:* + # AWS Backup Storage + - backup-storage:* + # AWS Certificate Manager + - acm:* + # AWS Chatbot + - chatbot:* + # AWS CloudFormation + - cloudformation:* + # Amazon CloudFront + - cloudfront:* + # Amazon CloudWatch + - cloudwatch:* + - logs:* + # Amazon Cognito + - cognito-idp:* + - cognito-identity:* + # Amazon DynamoDB + - dynamodb:* + # Amazon EventBridge + - events:* + # AWS IAM (limited) + - iam:* + # AWS Key Management Service + - kms:* + # AWS Lambda + - lambda:* + # Amazon OpenSearch Service + - es:* + # Amazon EventBridge Pipes + - pipes:* + # Amazon Route 53 + - route53:* + # Amazon S3 + - s3:* + # AWS Secrets Manager + - secretsmanager:* + # Amazon SES + - ses:* + # Amazon SNS + - sns:* + # Amazon SQS + - sqs:* + # AWS Systems Manager + - ssm:* + # AWS Step Functions + - states:* + # AWS WAF V2 + - wafv2:* + # Support for CDK operations + - sts:AssumeRole + - sts:GetCallerIdentity + - sts:TagSession + Resource: "*" + # VPC Resources - Restricted EC2 permissions for VPC networking only + - Sid: AllowVpcNetworkingResources + Effect: Allow + Action: + # VPC management + - ec2:CreateVpc + - ec2:DeleteVpc + - ec2:DescribeVpcs + - ec2:ModifyVpcAttribute + - ec2:DescribeVpcAttribute + # Subnet management + - ec2:CreateSubnet + - ec2:DeleteSubnet + - ec2:DescribeSubnets + - ec2:ModifySubnetAttribute + # Route table management + - ec2:CreateRouteTable + - ec2:DeleteRouteTable + - ec2:DescribeRouteTables + - ec2:AssociateRouteTable + - ec2:DisassociateRouteTable + - ec2:CreateRoute + - ec2:DeleteRoute + - ec2:ReplaceRoute + # Security group management + - ec2:CreateSecurityGroup + - ec2:DeleteSecurityGroup + - ec2:DescribeSecurityGroups + - ec2:DescribeSecurityGroupRules + - ec2:AuthorizeSecurityGroupIngress + - ec2:AuthorizeSecurityGroupEgress + - ec2:RevokeSecurityGroupIngress + - ec2:RevokeSecurityGroupEgress + - ec2:UpdateSecurityGroupRuleDescriptionsIngress + - ec2:UpdateSecurityGroupRuleDescriptionsEgress + # VPC Endpoint management + - ec2:CreateVpcEndpoint + - ec2:DeleteVpcEndpoints + - ec2:DescribeVpcEndpoints + - ec2:ModifyVpcEndpoint + - ec2:DescribeVpcEndpointServices + - ec2:DescribePrefixLists + # VPC Flow Logs + - ec2:CreateFlowLogs + - ec2:DeleteFlowLogs + - ec2:DescribeFlowLogs + # Tagging + - ec2:CreateTags + - ec2:DeleteTags + # General describe operations needed by CDK + - ec2:DescribeAvailabilityZones + - ec2:DescribeNetworkInterfaces + Resource: "*" + # Explicitly deny EC2 instance operations + - Sid: DenyEc2InstanceOperations + Effect: Deny + Action: + - ec2:RunInstances + - ec2:StartInstances + - ec2:StopInstances + - ec2:TerminateInstances + - ec2:RebootInstances + - ec2:CreateImage + - ec2:RegisterImage + - ec2:ImportInstance + - ec2:ImportImage + - ec2:RequestSpotInstances + - ec2:RequestSpotFleet + - ec2:ModifyInstanceAttribute + - ec2:ModifySpotFleetRequest + - ec2:CreateLaunchTemplate + - ec2:CreateLaunchTemplateVersion + - ec2:ModifyLaunchTemplate + Resource: "*" + - Sid: DenyDangerousActions + Effect: Deny + Action: + # Prevent account-level changes + - organizations:* + - account:* + # Prevent billing changes + - aws-portal:* + - budgets:* + - ce:* + - cur:* + # Prevent support case changes + - support:* + # Prevent marketplace changes + - aws-marketplace:* + Resource: "*" + - Sid: AllowPassSelf + Effect: Allow + Action: + - iam:PassRole + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + - Sid: DenySelfTampering + Effect: Deny + NotAction: + - iam:PassRole + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + - Sid: DenyBoundaryPolicyTampering + Effect: Deny + Action: + - iam:* + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Version: "2012-10-17" + Description: Permissions boundary for CloudFormation execution role - limits to CompactConnect required services + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + Path: / + CdkBoostrapPermissionsBoundaryPolicy: + Condition: ShouldCreatePermissionsBoundary + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Statement: + - Sid: ExplicitAllowAll + Action: + - "*" + Effect: Allow + Resource: "*" + - Sid: DenyAccessIfRequiredPermBoundaryIsNotBeingApplied + Action: + - iam:CreateUser + - iam:CreateRole + - iam:PutRolePermissionsBoundary + - iam:PutUserPermissionsBoundary + Condition: + StringNotEquals: + iam:PermissionsBoundary: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Effect: Deny + Resource: "*" + - Sid: DenyPermBoundaryIAMPolicyAlteration + Action: + - iam:CreatePolicyVersion + - iam:DeletePolicy + - iam:DeletePolicyVersion + - iam:SetDefaultPolicyVersion + Effect: Deny + Resource: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + - Sid: DenyRemovalOfPermBoundaryFromAnyUserOrRole + Action: + - iam:DeleteUserPermissionsBoundary + - iam:DeleteRolePermissionsBoundary + Effect: Deny + Resource: "*" + Version: "2012-10-17" + Description: Bootstrap Permission Boundary + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Path: / + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: "23" +Outputs: + BucketName: + Description: The name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket} + BucketDomainName: + Description: The domain name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket.RegionalDomainName} + FileAssetKeyArn: + Description: The ARN of the KMS key used to encrypt the asset bucket (deprecated) + Value: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + Export: + Name: + Fn::Sub: CdkBootstrap-${Qualifier}-FileAssetKeyArn + ImageRepositoryName: + Description: The name of the ECR repository which hosts docker image assets + Value: + Fn::Sub: ${ContainerAssetsRepository} + BootstrapVersion: + Description: The version of the bootstrap resources that are currently mastered in this stack + Value: + Fn::GetAtt: + - CdkBootstrapVersion + - Value diff --git a/backend/social-work-app/resources/bootstrap-stack-test.yaml b/backend/social-work-app/resources/bootstrap-stack-test.yaml new file mode 100644 index 0000000000..36bd739947 --- /dev/null +++ b/backend/social-work-app/resources/bootstrap-stack-test.yaml @@ -0,0 +1,825 @@ +Description: This stack includes resources needed to deploy AWS CDK apps into this environment +Parameters: + TrustedAccounts: + Description: List of AWS accounts that are trusted to publish assets and deploy stacks to this environment + Default: "" + Type: CommaDelimitedList + TrustedAccountsForLookup: + Description: List of AWS accounts that are trusted to look up values in this environment + Default: "" + Type: CommaDelimitedList + CloudFormationExecutionPolicies: + Description: List of the ManagedPolicy ARN(s) to attach to the CloudFormation deployment role + Default: "" + Type: CommaDelimitedList + FileAssetsBucketName: + Description: The name of the S3 bucket used for file assets + Default: "" + Type: String + FileAssetsBucketKmsKeyId: + Description: Empty to create a new key (default), 'AWS_MANAGED_KEY' to use a managed S3 key, or the ID/ARN of an existing key. + Default: "" + Type: String + ContainerAssetsRepositoryName: + Description: A user-provided custom name to use for the container assets ECR repository + Default: "" + Type: String + Qualifier: + Description: An identifier to distinguish multiple bootstrap stacks in the same environment + Default: hnb659fds + Type: String + AllowedPattern: "[A-Za-z0-9_-]{1,10}" + ConstraintDescription: Qualifier must be an alphanumeric identifier of at most 10 characters + PublicAccessBlockConfiguration: + Description: Whether or not to enable S3 Staging Bucket Public Access Block Configuration + Default: "true" + Type: String + AllowedValues: + - "true" + - "false" + InputPermissionsBoundary: + Description: Whether or not to use either the CDK supplied or custom permissions boundary + Default: "" + Type: String + UseExamplePermissionsBoundary: + Default: "false" + AllowedValues: + - "true" + - "false" + Type: String + BootstrapVariant: + Type: String + Default: "CompactConnect: Secure Bootstrap - Test Environment" + Description: Secure bootstrap template for CompactConnect test environment with specific role trust policies + +Conditions: + HasTrustedAccounts: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccounts + HasTrustedAccountsForLookup: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: TrustedAccountsForLookup + HasCloudFormationExecutionPolicies: + Fn::Not: + - Fn::Equals: + - "" + - Fn::Join: + - "" + - Ref: CloudFormationExecutionPolicies + HasCustomFileAssetsBucketName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: FileAssetsBucketName + CreateNewKey: + Fn::Equals: + - "" + - Ref: FileAssetsBucketKmsKeyId + UseAwsManagedKey: + Fn::Equals: + - AWS_MANAGED_KEY + - Ref: FileAssetsBucketKmsKeyId + ShouldCreatePermissionsBoundary: + Fn::Equals: + - "true" + - Ref: UseExamplePermissionsBoundary + PermissionsBoundarySet: + Fn::Not: + - Fn::Equals: + - "" + - Ref: InputPermissionsBoundary + HasCustomContainerAssetsRepositoryName: + Fn::Not: + - Fn::Equals: + - "" + - Ref: ContainerAssetsRepositoryName + UsePublicAccessBlockConfiguration: + Fn::Equals: + - "true" + - Ref: PublicAccessBlockConfiguration + +Resources: + FileAssetsBucketEncryptionKey: + Type: AWS::KMS::Key + Properties: + KeyPolicy: + Statement: + - Action: + - kms:Create* + - kms:Describe* + - kms:Enable* + - kms:List* + - kms:Put* + - kms:Update* + - kms:Revoke* + - kms:Disable* + - kms:Get* + - kms:Delete* + - kms:ScheduleKeyDeletion + - kms:CancelKeyDeletion + - kms:GenerateDataKey + - kms:TagResource + - kms:UntagResource + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + Resource: "*" + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: "*" + Resource: "*" + Condition: + StringEquals: + kms:CallerAccount: + Ref: AWS::AccountId + kms:ViaService: + - Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Principal: + AWS: + Fn::Sub: ${FilePublishingRole.Arn} + Resource: "*" + Condition: CreateNewKey + FileAssetsBucketEncryptionKeyAlias: + Condition: CreateNewKey + Type: AWS::KMS::Alias + Properties: + AliasName: + Fn::Sub: alias/cdk-${Qualifier}-assets-key + TargetKeyId: + Ref: FileAssetsBucketEncryptionKey + StagingBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: + Fn::If: + - HasCustomFileAssetsBucketName + - Fn::Sub: ${FileAssetsBucketName} + - Fn::Sub: cdk-${Qualifier}-assets-${AWS::AccountId}-${AWS::Region} + AccessControl: Private + BucketEncryption: + ServerSideEncryptionConfiguration: + - ServerSideEncryptionByDefault: + SSEAlgorithm: aws:kms + KMSMasterKeyID: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::If: + - UseAwsManagedKey + - Ref: AWS::NoValue + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + PublicAccessBlockConfiguration: + Fn::If: + - UsePublicAccessBlockConfiguration + - BlockPublicAcls: true + BlockPublicPolicy: true + IgnorePublicAcls: true + RestrictPublicBuckets: true + - Ref: AWS::NoValue + VersioningConfiguration: + Status: Enabled + LifecycleConfiguration: + Rules: + - Id: CleanupOldVersions + Status: Enabled + NoncurrentVersionExpiration: + NoncurrentDays: 365 + UpdateReplacePolicy: Retain + DeletionPolicy: Retain + StagingBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: + Ref: StagingBucket + PolicyDocument: + Id: AccessControl + Version: "2012-10-17" + Statement: + - Sid: AllowSSLRequestsOnly + Action: s3:* + Effect: Deny + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + Bool: + aws:SecureTransport: "false" + Principal: "*" + ContainerAssetsRepository: + Type: AWS::ECR::Repository + Properties: + ImageTagMutability: IMMUTABLE + LifecyclePolicy: + LifecyclePolicyText: | + { + "rules": [ + { + "rulePriority": 1, + "description": "Untagged images should not exist, but expire any older than one year", + "selection": { + "tagStatus": "untagged", + "countType": "sinceImagePushed", + "countUnit": "days", + "countNumber": 365 + }, + "action": { "type": "expire" } + } + ] + } + RepositoryName: + Fn::If: + - HasCustomContainerAssetsRepositoryName + - Fn::Sub: ${ContainerAssetsRepositoryName} + - Fn::Sub: cdk-${Qualifier}-container-assets-${AWS::AccountId}-${AWS::Region} + RepositoryPolicyText: + Version: "2012-10-17" + Statement: + - Sid: LambdaECRImageRetrievalPolicy + Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Condition: + StringLike: + aws:sourceArn: + Fn::Sub: arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:* + FilePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-test-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: file-publishing + ImagePublishingRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-test-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: image-publishing + LookupRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + # Trust both backend and frontend pipeline roles from the pipeline account + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-test-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + RoleName: + Fn::Sub: cdk-${Qualifier}-lookup-role-${AWS::AccountId}-${AWS::Region} + ManagedPolicyArns: + - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/ReadOnlyAccess + Policies: + - PolicyDocument: + Statement: + - Sid: DontReadSecrets + Effect: Deny + Action: + - kms:Decrypt + Resource: "*" + Version: "2012-10-17" + PolicyName: LookupRolePolicy + Tags: + - Key: aws-cdk:bootstrap-role + Value: lookup + FilePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - s3:GetObject* + - s3:GetBucket* + - s3:GetEncryptionConfiguration + - s3:List* + - s3:DeleteObject* + - s3:PutObject* + - s3:Abort* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + Condition: + StringEquals: + aws:ResourceAccount: + - Fn::Sub: ${AWS::AccountId} + Effect: Allow + - Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Effect: Allow + Resource: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:key/${FileAssetsBucketKmsKeyId} + Version: "2012-10-17" + Roles: + - Ref: FilePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-file-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + ImagePublishingRoleDefaultPolicy: + Type: AWS::IAM::Policy + Properties: + PolicyDocument: + Statement: + - Action: + - ecr:PutImage + - ecr:InitiateLayerUpload + - ecr:UploadLayerPart + - ecr:CompleteLayerUpload + - ecr:BatchCheckLayerAvailability + - ecr:DescribeRepositories + - ecr:DescribeImages + - ecr:BatchGetImage + - ecr:GetDownloadUrlForLayer + Resource: + Fn::Sub: ${ContainerAssetsRepository.Arn} + Effect: Allow + - Action: + - ecr:GetAuthorizationToken + Resource: "*" + Effect: Allow + Version: "2012-10-17" + Roles: + - Ref: ImagePublishingRole + PolicyName: + Fn::Sub: cdk-${Qualifier}-image-publishing-role-default-policy-${AWS::AccountId}-${AWS::Region} + DeploymentActionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:TagSession + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + Ref: AWS::AccountId + - Fn::If: + - HasTrustedAccounts + - Action: sts:AssumeRole + Effect: Allow + Principal: + AWS: + - Fn::Sub: + - "arn:aws:iam::${TrustedAccount}:role/CompactConnect-test-SocialWork-CrossAccountRole" + - TrustedAccount: !Select [0, !Ref TrustedAccounts] + - Ref: AWS::NoValue + Policies: + - PolicyDocument: + Statement: + - Sid: CloudFormationPermissions + Effect: Allow + Action: + - cloudformation:CreateChangeSet + - cloudformation:DeleteChangeSet + - cloudformation:DescribeChangeSet + - cloudformation:DescribeStacks + - cloudformation:ExecuteChangeSet + - cloudformation:CreateStack + - cloudformation:UpdateStack + - cloudformation:RollbackStack + - cloudformation:ContinueUpdateRollback + Resource: "*" + - Sid: PipelineCrossAccountArtifactsBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + - s3:Abort* + - s3:DeleteObject* + - s3:PutObject* + Resource: "*" + Condition: + StringNotEquals: + s3:ResourceAccount: + Ref: AWS::AccountId + - Sid: PipelineCrossAccountArtifactsKey + Effect: Allow + Action: + - kms:Decrypt + - kms:DescribeKey + - kms:Encrypt + - kms:ReEncrypt* + - kms:GenerateDataKey* + Resource: "*" + Condition: + StringEquals: + kms:ViaService: + Fn::Sub: s3.${AWS::Region}.amazonaws.com + - Action: iam:PassRole + Resource: + Fn::Sub: ${CloudFormationExecutionRole.Arn} + Effect: Allow + - Sid: CliPermissions + Action: + - cloudformation:DescribeStackEvents + - cloudformation:GetTemplate + - cloudformation:DeleteStack + - cloudformation:UpdateTerminationProtection + - sts:GetCallerIdentity + - cloudformation:GetTemplateSummary + Resource: "*" + Effect: Allow + - Sid: CliStagingBucket + Effect: Allow + Action: + - s3:GetObject* + - s3:GetBucket* + - s3:List* + Resource: + - Fn::Sub: ${StagingBucket.Arn} + - Fn::Sub: ${StagingBucket.Arn}/* + - Sid: ReadVersion + Effect: Allow + Action: + - ssm:GetParameter + - ssm:GetParameters + Resource: + - Fn::Sub: arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:parameter${CdkBootstrapVersion} + Version: "2012-10-17" + PolicyName: default + RoleName: + Fn::Sub: cdk-${Qualifier}-deploy-role-${AWS::AccountId}-${AWS::Region} + Tags: + - Key: aws-cdk:bootstrap-role + Value: deploy + CloudFormationExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Statement: + - Action: sts:AssumeRole + Effect: Allow + Principal: + Service: cloudformation.amazonaws.com + Version: "2012-10-17" + ManagedPolicyArns: + Fn::If: + - HasCloudFormationExecutionPolicies + - Ref: CloudFormationExecutionPolicies + - Fn::If: + - HasTrustedAccounts + - Ref: AWS::NoValue + - - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AdministratorAccess + RoleName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + PermissionsBoundary: + Fn::If: + - PermissionsBoundarySet + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/${InputPermissionsBoundary} + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + CloudFormationExecutionBoundaryPolicy: + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Statement: + - Sid: AllowCompactConnectServices + Effect: Allow + Action: + # API Gateway + - apigateway:* + # AWS Backup + - backup:* + # AWS Backup Storage + - backup-storage:* + # AWS Certificate Manager + - acm:* + # AWS Chatbot + - chatbot:* + # AWS CloudFormation + - cloudformation:* + # Amazon CloudFront + - cloudfront:* + # Amazon CloudWatch + - cloudwatch:* + - logs:* + # Amazon Cognito + - cognito-idp:* + - cognito-identity:* + # Amazon DynamoDB + - dynamodb:* + # Amazon EventBridge + - events:* + # AWS IAM (limited) + - iam:* + # AWS Key Management Service + - kms:* + # AWS Lambda + - lambda:* + # Amazon OpenSearch Service + - es:* + # Amazon EventBridge Pipes + - pipes:* + # Amazon Route 53 + - route53:* + # Amazon S3 + - s3:* + # AWS Secrets Manager + - secretsmanager:* + # Amazon SES + - ses:* + # Amazon SNS + - sns:* + # Amazon SQS + - sqs:* + # AWS Systems Manager + - ssm:* + # AWS Step Functions + - states:* + # AWS WAF V2 + - wafv2:* + # Support for CDK operations + - sts:AssumeRole + - sts:GetCallerIdentity + - sts:TagSession + Resource: "*" + # VPC Resources - Restricted EC2 permissions for VPC networking only + - Sid: AllowVpcNetworkingResources + Effect: Allow + Action: + # VPC management + - ec2:CreateVpc + - ec2:DeleteVpc + - ec2:DescribeVpcs + - ec2:ModifyVpcAttribute + - ec2:DescribeVpcAttribute + # Subnet management + - ec2:CreateSubnet + - ec2:DeleteSubnet + - ec2:DescribeSubnets + - ec2:ModifySubnetAttribute + # Route table management + - ec2:CreateRouteTable + - ec2:DeleteRouteTable + - ec2:DescribeRouteTables + - ec2:AssociateRouteTable + - ec2:DisassociateRouteTable + - ec2:CreateRoute + - ec2:DeleteRoute + - ec2:ReplaceRoute + # Security group management + - ec2:CreateSecurityGroup + - ec2:DeleteSecurityGroup + - ec2:DescribeSecurityGroups + - ec2:DescribeSecurityGroupRules + - ec2:AuthorizeSecurityGroupIngress + - ec2:AuthorizeSecurityGroupEgress + - ec2:RevokeSecurityGroupIngress + - ec2:RevokeSecurityGroupEgress + - ec2:UpdateSecurityGroupRuleDescriptionsIngress + - ec2:UpdateSecurityGroupRuleDescriptionsEgress + # VPC Endpoint management + - ec2:CreateVpcEndpoint + - ec2:DeleteVpcEndpoints + - ec2:DescribeVpcEndpoints + - ec2:ModifyVpcEndpoint + - ec2:DescribeVpcEndpointServices + - ec2:DescribePrefixLists + # VPC Flow Logs + - ec2:CreateFlowLogs + - ec2:DeleteFlowLogs + - ec2:DescribeFlowLogs + # Tagging + - ec2:CreateTags + - ec2:DeleteTags + # General describe operations needed by CDK + - ec2:DescribeAvailabilityZones + - ec2:DescribeNetworkInterfaces + Resource: "*" + # Explicitly deny EC2 instance operations + - Sid: DenyEc2InstanceOperations + Effect: Deny + Action: + - ec2:RunInstances + - ec2:StartInstances + - ec2:StopInstances + - ec2:TerminateInstances + - ec2:RebootInstances + - ec2:CreateImage + - ec2:RegisterImage + - ec2:ImportInstance + - ec2:ImportImage + - ec2:RequestSpotInstances + - ec2:RequestSpotFleet + - ec2:ModifyInstanceAttribute + - ec2:ModifySpotFleetRequest + - ec2:CreateLaunchTemplate + - ec2:CreateLaunchTemplateVersion + - ec2:ModifyLaunchTemplate + Resource: "*" + - Sid: DenyDangerousActions + Effect: Deny + Action: + # Prevent account-level changes + - organizations:* + - account:* + # Prevent billing changes + - aws-portal:* + - budgets:* + - ce:* + - cur:* + # Prevent support case changes + - support:* + # Prevent marketplace changes + - aws-marketplace:* + Resource: "*" + - Sid: AllowPassSelf + Effect: Allow + Action: + - iam:PassRole + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + - Sid: DenySelfTampering + Effect: Deny + NotAction: + - iam:PassRole + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-${Qualifier}-cfn-exec-role-${AWS::AccountId}-${AWS::Region} + - Sid: DenyBoundaryPolicyTampering + Effect: Deny + Action: + - iam:* + Resource: + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + - Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Version: "2012-10-17" + Description: Permissions boundary for CloudFormation execution role - limits to CompactConnect required services + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-cfn-exec-boundary-${AWS::AccountId}-${AWS::Region} + Path: / + CdkBoostrapPermissionsBoundaryPolicy: + Condition: ShouldCreatePermissionsBoundary + Type: AWS::IAM::ManagedPolicy + Properties: + PolicyDocument: + Statement: + - Sid: ExplicitAllowAll + Action: + - "*" + Effect: Allow + Resource: "*" + - Sid: DenyAccessIfRequiredPermBoundaryIsNotBeingApplied + Action: + - iam:CreateUser + - iam:CreateRole + - iam:PutRolePermissionsBoundary + - iam:PutUserPermissionsBoundary + Condition: + StringNotEquals: + iam:PermissionsBoundary: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Effect: Deny + Resource: "*" + - Sid: DenyPermBoundaryIAMPolicyAlteration + Action: + - iam:CreatePolicyVersion + - iam:DeletePolicy + - iam:DeletePolicyVersion + - iam:SetDefaultPolicyVersion + Effect: Deny + Resource: + Fn::Sub: arn:${AWS::Partition}:iam::${AWS::AccountId}:policy/cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + - Sid: DenyRemovalOfPermBoundaryFromAnyUserOrRole + Action: + - iam:DeleteUserPermissionsBoundary + - iam:DeleteRolePermissionsBoundary + Effect: Deny + Resource: "*" + Version: "2012-10-17" + Description: Bootstrap Permission Boundary + ManagedPolicyName: + Fn::Sub: cdk-${Qualifier}-permissions-boundary-${AWS::AccountId}-${AWS::Region} + Path: / + CdkBootstrapVersion: + Type: AWS::SSM::Parameter + Properties: + Type: String + Name: + Fn::Sub: /cdk-bootstrap/${Qualifier}/version + Value: "23" +Outputs: + BucketName: + Description: The name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket} + BucketDomainName: + Description: The domain name of the S3 bucket owned by the CDK toolkit stack + Value: + Fn::Sub: ${StagingBucket.RegionalDomainName} + FileAssetKeyArn: + Description: The ARN of the KMS key used to encrypt the asset bucket (deprecated) + Value: + Fn::If: + - CreateNewKey + - Fn::Sub: ${FileAssetsBucketEncryptionKey.Arn} + - Fn::Sub: ${FileAssetsBucketKmsKeyId} + Export: + Name: + Fn::Sub: CdkBootstrap-${Qualifier}-FileAssetKeyArn + ImageRepositoryName: + Description: The name of the ECR repository which hosts docker image assets + Value: + Fn::Sub: ${ContainerAssetsRepository} + BootstrapVersion: + Description: The version of the bootstrap resources that are currently mastered in this stack + Value: + Fn::GetAtt: + - CdkBootstrapVersion + - Value diff --git a/backend/social-work-app/resources/cognito-blocked-notification.txt b/backend/social-work-app/resources/cognito-blocked-notification.txt new file mode 100644 index 0000000000..74f2820b2b --- /dev/null +++ b/backend/social-work-app/resources/cognito-blocked-notification.txt @@ -0,0 +1 @@ +We detected suspicious activity on your account and have temporarily blocked access. Please contact support if you need assistance. diff --git a/backend/social-work-app/resources/cognito-no-action-notification.txt b/backend/social-work-app/resources/cognito-no-action-notification.txt new file mode 100644 index 0000000000..74fba117a7 --- /dev/null +++ b/backend/social-work-app/resources/cognito-no-action-notification.txt @@ -0,0 +1 @@ +We detected suspicious activity on your account. If this wasn't you, please log in and secure your account. Please contact support if you need assistance. diff --git a/backend/social-work-app/resources/provider_managed_login_style_settings.json b/backend/social-work-app/resources/provider_managed_login_style_settings.json new file mode 100644 index 0000000000..635b392223 --- /dev/null +++ b/backend/social-work-app/resources/provider_managed_login_style_settings.json @@ -0,0 +1,449 @@ +{ + "components": { + "secondaryButton": { + "lightMode": { + "hover": { + "backgroundColor": "f2f8fdff", + "borderColor": "033160ff", + "textColor": "033160ff" + }, + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "2459A9ff", + "textColor": "2459A9ff" + }, + "active": { + "backgroundColor": "d3e7f9ff", + "borderColor": "033160ff", + "textColor": "033160ff" + } + }, + "darkMode": { + "hover": { + "backgroundColor": "192534ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + }, + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "539fe5ff", + "textColor": "539fe5ff" + }, + "active": { + "backgroundColor": "354150ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + } + } + }, + "form": { + "lightMode": { + "backgroundColor": "ffffffff", + "borderColor": "ffffffff" + }, + "borderRadius": 8.0, + "backgroundImage": { + "enabled": false + }, + "logo": { + "location": "CENTER", + "position": "TOP", + "enabled": true, + "formInclusion": "IN" + }, + "darkMode": { + "backgroundColor": "0f1b2aff", + "borderColor": "424650ff" + } + }, + "alert": { + "lightMode": { + "error": { + "backgroundColor": "fff7f7ff", + "borderColor": "d91515ff" + } + }, + "borderRadius": 12.0, + "darkMode": { + "error": { + "backgroundColor": "1a0000ff", + "borderColor": "eb6f6fff" + } + } + }, + "favicon": { + "enabledTypes": [ + "ICO" + ] + }, + "pageBackground": { + "image": { + "enabled": false + }, + "lightMode": { + "color": "f5f6f8ff" + }, + "darkMode": { + "color": "0f1b2aff" + } + }, + "pageText": { + "lightMode": { + "bodyColor": "414d5cff", + "headingColor": "000716ff", + "descriptionColor": "414d5c00" + }, + "darkMode": { + "bodyColor": "b6bec9ff", + "headingColor": "d1d5dbff", + "descriptionColor": "b6bec9ff" + } + }, + "phoneNumberSelector": { + "displayType": "TEXT" + }, + "primaryButton": { + "lightMode": { + "hover": { + "backgroundColor": "033160ff", + "textColor": "ffffffff" + }, + "defaults": { + "backgroundColor": "2459A9ff", + "textColor": "ffffffff" + }, + "active": { + "backgroundColor": "033160ff", + "textColor": "ffffffff" + }, + "disabled": { + "backgroundColor": "ffffffff", + "borderColor": "ffffffff" + } + }, + "darkMode": { + "hover": { + "backgroundColor": "89bdeeff", + "textColor": "000716ff" + }, + "defaults": { + "backgroundColor": "539fe5ff", + "textColor": "000716ff" + }, + "active": { + "backgroundColor": "539fe5ff", + "textColor": "000716ff" + }, + "disabled": { + "backgroundColor": "ffffffff", + "borderColor": "ffffffff" + } + } + }, + "pageFooter": { + "lightMode": { + "borderColor": "d5dbdbff", + "background": { + "color": "fafafaff" + } + }, + "backgroundImage": { + "enabled": false + }, + "logo": { + "location": "START", + "enabled": false + }, + "darkMode": { + "borderColor": "424650ff", + "background": { + "color": "0f141aff" + } + } + }, + "pageHeader": { + "lightMode": { + "borderColor": "d5dbdbff", + "background": { + "color": "fafafaff" + } + }, + "backgroundImage": { + "enabled": false + }, + "logo": { + "location": "START", + "enabled": false + }, + "darkMode": { + "borderColor": "424650ff", + "background": { + "color": "0f141aff" + } + } + }, + "idpButton": { + "standard": { + "lightMode": { + "hover": { + "backgroundColor": "f2f8fdff", + "borderColor": "033160ff", + "textColor": "033160ff" + }, + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "424650ff", + "textColor": "424650ff" + }, + "active": { + "backgroundColor": "d3e7f9ff", + "borderColor": "033160ff", + "textColor": "033160ff" + } + }, + "darkMode": { + "hover": { + "backgroundColor": "192534ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + }, + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "c6c6cdff", + "textColor": "c6c6cdff" + }, + "active": { + "backgroundColor": "354150ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + } + } + }, + "custom": {} + } + }, + "componentClasses": { + "dropDown": { + "lightMode": { + "hover": { + "itemBackgroundColor": "f4f4f4ff", + "itemBorderColor": "7d8998ff", + "itemTextColor": "000716ff" + }, + "defaults": { + "itemBackgroundColor": "ffffffff" + }, + "match": { + "itemBackgroundColor": "414d5cff", + "itemTextColor": "0972d3ff" + } + }, + "borderRadius": 8.0, + "darkMode": { + "hover": { + "itemBackgroundColor": "081120ff", + "itemBorderColor": "5f6b7aff", + "itemTextColor": "e9ebedff" + }, + "defaults": { + "itemBackgroundColor": "192534ff" + }, + "match": { + "itemBackgroundColor": "d1d5dbff", + "itemTextColor": "89bdeeff" + } + } + }, + "input": { + "lightMode": { + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "7d8998ff" + }, + "placeholderColor": "5f6b7aff" + }, + "borderRadius": 8.0, + "darkMode": { + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "5f6b7aff" + }, + "placeholderColor": "8d99a8ff" + } + }, + "inputDescription": { + "lightMode": { + "textColor": "5f6b7aff" + }, + "darkMode": { + "textColor": "8d99a8ff" + } + }, + "buttons": { + "borderRadius": 8.0 + }, + "optionControls": { + "lightMode": { + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "7d8998ff" + }, + "selected": { + "backgroundColor": "0972d3ff", + "foregroundColor": "ffffffff" + } + }, + "darkMode": { + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "7d8998ff" + }, + "selected": { + "backgroundColor": "539fe5ff", + "foregroundColor": "000716ff" + } + } + }, + "statusIndicator": { + "lightMode": { + "success": { + "backgroundColor": "f2fcf3ff", + "borderColor": "037f0cff", + "indicatorColor": "037f0cff" + }, + "pending": { + "indicatorColor": "AAAAAAAA" + }, + "warning": { + "backgroundColor": "fffce9ff", + "borderColor": "8d6605ff", + "indicatorColor": "8d6605ff" + }, + "error": { + "backgroundColor": "fff7f7ff", + "borderColor": "d91515ff", + "indicatorColor": "d91515ff" + } + }, + "darkMode": { + "success": { + "backgroundColor": "001a02ff", + "borderColor": "29ad32ff", + "indicatorColor": "29ad32ff" + }, + "pending": { + "indicatorColor": "AAAAAAAA" + }, + "warning": { + "backgroundColor": "1d1906ff", + "borderColor": "e0ca57ff", + "indicatorColor": "e0ca57ff" + }, + "error": { + "backgroundColor": "1a0000ff", + "borderColor": "eb6f6fff", + "indicatorColor": "eb6f6fff" + } + } + }, + "divider": { + "lightMode": { + "borderColor": "ebebf0ff" + }, + "darkMode": { + "borderColor": "232b37ff" + } + }, + "idpButtons": { + "icons": { + "enabled": true + } + }, + "focusState": { + "lightMode": { + "borderColor": "0972d3ff" + }, + "darkMode": { + "borderColor": "539fe5ff" + } + }, + "inputLabel": { + "lightMode": { + "textColor": "000716ff" + }, + "darkMode": { + "textColor": "d1d5dbff" + } + }, + "link": { + "lightMode": { + "hover": { + "textColor": "033160ff" + }, + "defaults": { + "textColor": "0972d3ff" + } + }, + "darkMode": { + "hover": { + "textColor": "89bdeeff" + }, + "defaults": { + "textColor": "539fe5ff" + } + } + } + }, + "categories": { + "form": { + "sessionTimerDisplay": "NONE", + "instructions": { + "enabled": false + }, + "languageSelector": { + "enabled": false + }, + "displayGraphics": true, + "location": { + "horizontal": "CENTER", + "vertical": "CENTER" + } + }, + "auth": { + "federation": { + "interfaceStyle": "BUTTON_LIST", + "order": [] + }, + "authMethodOrder": [ + [ + { + "display": "BUTTON", + "type": "FEDERATED" + }, + { + "display": "INPUT", + "type": "USERNAME_PASSWORD" + } + ] + ] + }, + "global": { + "colorSchemeMode": "LIGHT", + "pageHeader": { + "enabled": false + }, + "pageFooter": { + "enabled": false + }, + "spacingDensity": "REGULAR" + }, + "signUp": { + "acceptanceElements": [ + { + "enforcement": "NONE", + "textKey": "en" + } + ] + } + } +} diff --git a/backend/social-work-app/resources/staff_managed_login_style_settings.json b/backend/social-work-app/resources/staff_managed_login_style_settings.json new file mode 100644 index 0000000000..4e2ff6f19f --- /dev/null +++ b/backend/social-work-app/resources/staff_managed_login_style_settings.json @@ -0,0 +1,449 @@ +{ + "components": { + "secondaryButton": { + "lightMode": { + "hover": { + "backgroundColor": "f2f8fdff", + "borderColor": "033160ff", + "textColor": "033160ff" + }, + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "2459A9ff", + "textColor": "2459A9ff" + }, + "active": { + "backgroundColor": "d3e7f9ff", + "borderColor": "033160ff", + "textColor": "033160ff" + } + }, + "darkMode": { + "hover": { + "backgroundColor": "192534ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + }, + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "539fe5ff", + "textColor": "539fe5ff" + }, + "active": { + "backgroundColor": "354150ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + } + } + }, + "form": { + "lightMode": { + "backgroundColor": "ffffffff", + "borderColor": "ffffffff" + }, + "borderRadius": 8.0, + "backgroundImage": { + "enabled": false + }, + "logo": { + "location": "CENTER", + "position": "TOP", + "enabled": true, + "formInclusion": "IN" + }, + "darkMode": { + "backgroundColor": "0f1b2aff", + "borderColor": "424650ff" + } + }, + "alert": { + "lightMode": { + "error": { + "backgroundColor": "fff7f7ff", + "borderColor": "d91515ff" + } + }, + "borderRadius": 12.0, + "darkMode": { + "error": { + "backgroundColor": "1a0000ff", + "borderColor": "eb6f6fff" + } + } + }, + "favicon": { + "enabledTypes": [ + "ICO" + ] + }, + "pageBackground": { + "image": { + "enabled": true + }, + "lightMode": { + "color": "f5f6f8ff" + }, + "darkMode": { + "color": "0f1b2aff" + } + }, + "pageText": { + "lightMode": { + "bodyColor": "414d5cff", + "headingColor": "000716ff", + "descriptionColor": "414d5c00" + }, + "darkMode": { + "bodyColor": "b6bec9ff", + "headingColor": "d1d5dbff", + "descriptionColor": "b6bec9ff" + } + }, + "phoneNumberSelector": { + "displayType": "TEXT" + }, + "primaryButton": { + "lightMode": { + "hover": { + "backgroundColor": "033160ff", + "textColor": "ffffffff" + }, + "defaults": { + "backgroundColor": "2459A9ff", + "textColor": "ffffffff" + }, + "active": { + "backgroundColor": "033160ff", + "textColor": "ffffffff" + }, + "disabled": { + "backgroundColor": "ffffffff", + "borderColor": "ffffffff" + } + }, + "darkMode": { + "hover": { + "backgroundColor": "89bdeeff", + "textColor": "000716ff" + }, + "defaults": { + "backgroundColor": "539fe5ff", + "textColor": "000716ff" + }, + "active": { + "backgroundColor": "539fe5ff", + "textColor": "000716ff" + }, + "disabled": { + "backgroundColor": "ffffffff", + "borderColor": "ffffffff" + } + } + }, + "pageFooter": { + "lightMode": { + "borderColor": "d5dbdbff", + "background": { + "color": "fafafaff" + } + }, + "backgroundImage": { + "enabled": false + }, + "logo": { + "location": "START", + "enabled": false + }, + "darkMode": { + "borderColor": "424650ff", + "background": { + "color": "0f141aff" + } + } + }, + "pageHeader": { + "lightMode": { + "borderColor": "d5dbdbff", + "background": { + "color": "fafafaff" + } + }, + "backgroundImage": { + "enabled": false + }, + "logo": { + "location": "START", + "enabled": false + }, + "darkMode": { + "borderColor": "424650ff", + "background": { + "color": "0f141aff" + } + } + }, + "idpButton": { + "standard": { + "lightMode": { + "hover": { + "backgroundColor": "f2f8fdff", + "borderColor": "033160ff", + "textColor": "033160ff" + }, + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "424650ff", + "textColor": "424650ff" + }, + "active": { + "backgroundColor": "d3e7f9ff", + "borderColor": "033160ff", + "textColor": "033160ff" + } + }, + "darkMode": { + "hover": { + "backgroundColor": "192534ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + }, + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "c6c6cdff", + "textColor": "c6c6cdff" + }, + "active": { + "backgroundColor": "354150ff", + "borderColor": "89bdeeff", + "textColor": "89bdeeff" + } + } + }, + "custom": {} + } + }, + "componentClasses": { + "dropDown": { + "lightMode": { + "hover": { + "itemBackgroundColor": "f4f4f4ff", + "itemBorderColor": "7d8998ff", + "itemTextColor": "000716ff" + }, + "defaults": { + "itemBackgroundColor": "ffffffff" + }, + "match": { + "itemBackgroundColor": "414d5cff", + "itemTextColor": "0972d3ff" + } + }, + "borderRadius": 8.0, + "darkMode": { + "hover": { + "itemBackgroundColor": "081120ff", + "itemBorderColor": "5f6b7aff", + "itemTextColor": "e9ebedff" + }, + "defaults": { + "itemBackgroundColor": "192534ff" + }, + "match": { + "itemBackgroundColor": "d1d5dbff", + "itemTextColor": "89bdeeff" + } + } + }, + "input": { + "lightMode": { + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "7d8998ff" + }, + "placeholderColor": "5f6b7aff" + }, + "borderRadius": 8.0, + "darkMode": { + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "5f6b7aff" + }, + "placeholderColor": "8d99a8ff" + } + }, + "inputDescription": { + "lightMode": { + "textColor": "5f6b7aff" + }, + "darkMode": { + "textColor": "8d99a8ff" + } + }, + "buttons": { + "borderRadius": 8.0 + }, + "optionControls": { + "lightMode": { + "defaults": { + "backgroundColor": "ffffffff", + "borderColor": "7d8998ff" + }, + "selected": { + "backgroundColor": "0972d3ff", + "foregroundColor": "ffffffff" + } + }, + "darkMode": { + "defaults": { + "backgroundColor": "0f1b2aff", + "borderColor": "7d8998ff" + }, + "selected": { + "backgroundColor": "539fe5ff", + "foregroundColor": "000716ff" + } + } + }, + "statusIndicator": { + "lightMode": { + "success": { + "backgroundColor": "f2fcf3ff", + "borderColor": "037f0cff", + "indicatorColor": "037f0cff" + }, + "pending": { + "indicatorColor": "AAAAAAAA" + }, + "warning": { + "backgroundColor": "fffce9ff", + "borderColor": "8d6605ff", + "indicatorColor": "8d6605ff" + }, + "error": { + "backgroundColor": "fff7f7ff", + "borderColor": "d91515ff", + "indicatorColor": "d91515ff" + } + }, + "darkMode": { + "success": { + "backgroundColor": "001a02ff", + "borderColor": "29ad32ff", + "indicatorColor": "29ad32ff" + }, + "pending": { + "indicatorColor": "AAAAAAAA" + }, + "warning": { + "backgroundColor": "1d1906ff", + "borderColor": "e0ca57ff", + "indicatorColor": "e0ca57ff" + }, + "error": { + "backgroundColor": "1a0000ff", + "borderColor": "eb6f6fff", + "indicatorColor": "eb6f6fff" + } + } + }, + "divider": { + "lightMode": { + "borderColor": "ebebf0ff" + }, + "darkMode": { + "borderColor": "232b37ff" + } + }, + "idpButtons": { + "icons": { + "enabled": true + } + }, + "focusState": { + "lightMode": { + "borderColor": "0972d3ff" + }, + "darkMode": { + "borderColor": "539fe5ff" + } + }, + "inputLabel": { + "lightMode": { + "textColor": "000716ff" + }, + "darkMode": { + "textColor": "d1d5dbff" + } + }, + "link": { + "lightMode": { + "hover": { + "textColor": "033160ff" + }, + "defaults": { + "textColor": "0972d3ff" + } + }, + "darkMode": { + "hover": { + "textColor": "89bdeeff" + }, + "defaults": { + "textColor": "539fe5ff" + } + } + } + }, + "categories": { + "form": { + "sessionTimerDisplay": "NONE", + "instructions": { + "enabled": false + }, + "languageSelector": { + "enabled": false + }, + "displayGraphics": true, + "location": { + "horizontal": "CENTER", + "vertical": "CENTER" + } + }, + "auth": { + "federation": { + "interfaceStyle": "BUTTON_LIST", + "order": [] + }, + "authMethodOrder": [ + [ + { + "display": "BUTTON", + "type": "FEDERATED" + }, + { + "display": "INPUT", + "type": "USERNAME_PASSWORD" + } + ] + ] + }, + "global": { + "colorSchemeMode": "LIGHT", + "pageHeader": { + "enabled": false + }, + "pageFooter": { + "enabled": false + }, + "spacingDensity": "REGULAR" + }, + "signUp": { + "acceptanceElements": [ + { + "enforcement": "NONE", + "textKey": "en" + } + ] + } + } +} \ No newline at end of file diff --git a/backend/social-work-app/ruff.toml b/backend/social-work-app/ruff.toml new file mode 100644 index 0000000000..66682be809 --- /dev/null +++ b/backend/social-work-app/ruff.toml @@ -0,0 +1,112 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv" +] + +line-length = 120 +indent-width = 4 + +# Assume Python 3.12 +target-version = "py312" + +[lint] +select = [ + "A", # flake8-builtins + "ARG", # flake8-unused-arguments + "B", # flake8-bugbear + "BLE", # flake8-blind-except + "DTZ", # flake8-datetimez + "E", # pycodestyle + "F", # Pyflakes + "FIX", # flake8-fixme + "FA", # flake8-future-annotations + "G", # flake8-logging-format + "I", # isort + "N", # pep8-naming + "PIE", # flake8-pie + "SLF", # flake8-self + "RET", # flake8-return + "RSE", # flake8-raise + "S", # flake8-bandit + "T", # flake8-print + "UP", # pyupgrade + "W", # warning +] +ignore = [ + "S311", # suspicious-non-cryptographic-random-usage + "N818", # error-suffix-on-exception-name + # the following are ignored by recommendation of ruff documentation + # due to conflicts with the ruff formatter see https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + "W191", # indentation contains tabs + "E111", # indentation-with-invalid-multiple + "E114", # indentation-with-invalid-multiple-comment + "E117", # over-indented + "D206", # indent-with-spaces + "D300", # triple-single-quotes + "Q000", # bad-quotes-inline-string + "Q001", # bad-quotes-multiline-string + "Q002", # bad-quotes-docstring + "Q003", # avoidable-escaped-quote + "COM812", # missing-trailing-comma + "COM819", # prohibited-trailing-comma + "ISC001", # single-line-implicit-string-concatenation + "ISC002", # multi-line-implicit-string-concatenation +] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +quote-style = "single" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" diff --git a/backend/social-work-app/stacks/__init__.py b/backend/social-work-app/stacks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/stacks/api_lambda_stack/__init__.py b/backend/social-work-app/stacks/api_lambda_stack/__init__.py new file mode 100644 index 0000000000..25ae492eea --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/__init__.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from aws_cdk.aws_logs import QueryDefinition, QueryString +from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack as ps + +from .bulk_upload_url import BulkUploadUrlLambdas +from .compact_configuration_api import CompactConfigurationApiLambdas +from .feature_flags import FeatureFlagsLambdas +from .post_licenses import PostLicensesLambdas +from .provider_management import ProviderManagementLambdas +from .public_lookup_api import PublicLookupApiLambdas +from .staff_users import StaffUsersLambdas + + +class ApiLambdaStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + environment_context: dict, + persistent_stack: ps.PersistentStack, + **kwargs, + ) -> None: + super().__init__( + scope, + construct_id, + environment_name=environment_name, + environment_context=environment_context, + **kwargs, + ) + + data_event_bus = SSMParameterUtility.load_data_event_bus_from_ssm_parameter(self) + + # Initialize log groups list for QueryDefinition + self.log_groups = [] + + # we only pass the API_BASE_URL env var if the API_DOMAIN_NAME is set + # this is because the API_BASE_URL is used by the feature flag client to call the flag check endpoint + if persistent_stack.api_domain_name: + self.common_env_vars.update({'API_BASE_URL': f'https://{persistent_stack.api_domain_name}'}) + + # Feature Flags related API lambdas + self.feature_flags_lambdas = FeatureFlagsLambdas( + scope=self, + persistent_stack=persistent_stack, + ) + + # Bulk upload url lambdas + self.bulk_upload_url_lambdas = BulkUploadUrlLambdas( + scope=self, + persistent_stack=persistent_stack, + api_lambda_stack=self, + ) + + # Compact configuration lambdas + self.compact_configuration_lambdas = CompactConfigurationApiLambdas( + scope=self, + persistent_stack=persistent_stack, + api_lambda_stack=self, + ) + + # Post licenses lambdas + self.post_licenses_lambdas = PostLicensesLambdas( + scope=self, + persistent_stack=persistent_stack, + api_lambda_stack=self, + ) + + # Provider Management lambdas + self.provider_management_lambdas = ProviderManagementLambdas( + scope=self, + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + api_lambda_stack=self, + ) + + # Public lookup lambdas + self.public_lookup_lambdas = PublicLookupApiLambdas( + scope=self, + persistent_stack=persistent_stack, + api_lambda_stack=self, + ) + + # Staff user lambdas + self.staff_users_lambdas = StaffUsersLambdas( + scope=self, + persistent_stack=persistent_stack, + api_lambda_stack=self, + ) + + # Create the QueryDefinition after all lambda modules have been initialized and added their log groups + self._create_runtime_query_definition() + + def _create_runtime_query_definition(self): + """Create the QueryDefinition for runtime logs after all lambda modules have been initialized.""" + QueryDefinition( + self, + 'RuntimeQuery', + query_definition_name=f'{self.node.id}/Lambdas', + query_string=QueryString( + fields=['@timestamp', 'level', 'status', 'message', 'method', 'path', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=self.log_groups, + ) diff --git a/backend/social-work-app/stacks/api_lambda_stack/bulk_upload_url.py b/backend/social-work-app/stacks/api_lambda_stack/bulk_upload_url.py new file mode 100644 index 0000000000..b5091bf5f2 --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/bulk_upload_url.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import os + +from aws_cdk import Stack +from aws_cdk.aws_iam import IRole +from aws_cdk.aws_s3 import IBucket +from aws_cdk.aws_sns import ITopic +from common_constructs.python_function import PythonFunction +from constructs import Construct + +from stacks import api_lambda_stack as als +from stacks import persistent_stack as ps + + +class BulkUploadUrlLambdas: + def __init__( + self, + *, + scope: Construct, + persistent_stack: ps.PersistentStack, + api_lambda_stack: als.ApiLambdaStack, + ): + super().__init__() + stack = Stack.of(scope) + + env_vars = { + 'BULK_BUCKET_NAME': persistent_stack.bulk_uploads_bucket.bucket_name, + **stack.common_env_vars, + } + + self.bulk_upload_url_handler = self._bulk_upload_url_handler( + scope=scope, + env_vars=env_vars, + license_upload_role=persistent_stack.ssn_table.license_upload_role, + bulk_uploads_bucket=persistent_stack.bulk_uploads_bucket, + alarm_topic=persistent_stack.alarm_topic, + ) + api_lambda_stack.log_groups.append(self.bulk_upload_url_handler.log_group) + + def _bulk_upload_url_handler( + self, + scope: Construct, + env_vars: dict, + license_upload_role: IRole, + bulk_uploads_bucket: IBucket, + alarm_topic: ITopic, + ): + handler = PythonFunction( + scope, + 'V1BulkUrlHandler', + description='Get upload url handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'bulk_upload.py'), + handler='bulk_upload_url_handler', + role=license_upload_role, + environment=env_vars, + alarm_topic=alarm_topic, + ) + # Grant the handler permissions to write to the bulk bucket + bulk_uploads_bucket.grant_write(handler) + return handler diff --git a/backend/social-work-app/stacks/api_lambda_stack/compact_configuration_api.py b/backend/social-work-app/stacks/api_lambda_stack/compact_configuration_api.py new file mode 100644 index 0000000000..a054e3df3e --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/compact_configuration_api.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration, Stack +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from constructs import Construct + +from stacks import api_lambda_stack as als +from stacks import persistent_stack as ps + + +class CompactConfigurationApiLambdas: + def __init__( + self, + *, + scope: Construct, + persistent_stack: ps.PersistentStack, + api_lambda_stack: als.ApiLambdaStack, + ): + super().__init__() + stack = Stack.of(scope) + + env_vars = { + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + **stack.common_env_vars, + } + + self.compact_configuration_api_handler = self._compact_configuration_api_handler( + scope=scope, + env_vars=env_vars, + alarm_topic=persistent_stack.alarm_topic, + data_encryption_key=persistent_stack.shared_encryption_key, + compact_configuration_table=persistent_stack.compact_configuration_table, + ) + api_lambda_stack.log_groups.append(self.compact_configuration_api_handler.log_group) + + def _compact_configuration_api_handler( + self, + scope: Construct, + env_vars: dict, + data_encryption_key: IKey, + compact_configuration_table: ITable, + alarm_topic: ITopic, + ): + stack = Stack.of(scope) + handler = PythonFunction( + scope, + 'CompactConfigurationApiFunction', + index=os.path.join('handlers', 'compact_configuration.py'), + lambda_dir='compact-configuration', + handler='compact_configuration_api_handler', + environment=env_vars, + timeout=Duration.seconds(28), + alarm_topic=alarm_topic, + ) + data_encryption_key.grant_decrypt(handler) + compact_configuration_table.grant_read_write_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler diff --git a/backend/social-work-app/stacks/api_lambda_stack/feature_flags.py b/backend/social-work-app/stacks/api_lambda_stack/feature_flags.py new file mode 100644 index 0000000000..e7f2bfcb37 --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/feature_flags.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os + +from aws_cdk.aws_secretsmanager import Secret +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack + +from stacks import persistent_stack as ps + + +class FeatureFlagsLambdas: + def __init__( + self, + *, + scope: Stack, + persistent_stack: ps.PersistentStack, + ) -> None: + self.scope = scope + self.persistent_stack = persistent_stack + + self.stack: Stack = Stack.of(scope) + lambda_environment = { + **self.stack.common_env_vars, + } + + # Get the StatsIg secret for each environment + environment_name = self.stack.common_env_vars['ENVIRONMENT_NAME'] + self.statsig_secret = Secret.from_secret_name_v2( + self.scope, + 'StatsigSecret', + f'compact-connect/env/{environment_name}/statsig/credentials', + ) + + self.check_feature_flag_function = self._create_check_feature_flag_function(lambda_environment) + + def _create_check_feature_flag_function(self, lambda_environment: dict) -> PythonFunction: + check_feature_flag_function = PythonFunction( + self.scope, + 'CheckFeatureFlagHandler', + description='Check feature flag handler', + lambda_dir='feature-flag', + index=os.path.join('handlers', 'check_feature_flag.py'), + handler='check_feature_flag', + environment=lambda_environment, + alarm_topic=self.persistent_stack.alarm_topic, + ) + + # Grant permission to read the StatsIg secret + self.statsig_secret.grant_read(check_feature_flag_function) + + NagSuppressions.add_resource_suppressions_by_path( + self.stack, + path=f'{check_feature_flag_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are scoped to the StatsIg secret it needs to access.', + }, + ], + ) + + return check_feature_flag_function diff --git a/backend/social-work-app/stacks/api_lambda_stack/post_licenses.py b/backend/social-work-app/stacks/api_lambda_stack/post_licenses.py new file mode 100644 index 0000000000..539694a63a --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/post_licenses.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os + +from aws_cdk import Stack +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_iam import IRole +from aws_cdk.aws_sns import ITopic +from aws_cdk.aws_sqs import IQueue +from common_constructs.python_function import PythonFunction +from constructs import Construct + +from stacks import api_lambda_stack as als +from stacks import persistent_stack as ps + + +class PostLicensesLambdas: + def __init__( + self, + *, + scope: Construct, + persistent_stack: ps.PersistentStack, + api_lambda_stack: als.ApiLambdaStack, + ): + super().__init__() + stack = Stack.of(scope) + + env_vars = { + 'LICENSE_PREPROCESSING_QUEUE_URL': persistent_stack.ssn_table.preprocessor_queue.queue.queue_url, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + 'RATE_LIMITING_TABLE_NAME': persistent_stack.rate_limiting_table.table_name, + **stack.common_env_vars, + } + + self.post_licenses_handler = self._post_licenses_handler( + scope=scope, + env_vars=env_vars, + license_upload_role=persistent_stack.ssn_table.license_upload_role, + license_preprocessing_queue=persistent_stack.ssn_table.preprocessor_queue.queue, + compact_configuration_table=persistent_stack.compact_configuration_table, + rate_limiting_table=persistent_stack.rate_limiting_table, + alarm_topic=persistent_stack.alarm_topic, + ) + api_lambda_stack.log_groups.append(self.post_licenses_handler.log_group) + + # TODO: remove this after the ApiStack removal is deployed through production # noqa: FIX002 + stack.export_value(self.post_licenses_handler.function_arn) + + def _post_licenses_handler( + self, + scope: Construct, + env_vars: dict, + license_upload_role: IRole, + license_preprocessing_queue: IQueue, + compact_configuration_table: ITable, + rate_limiting_table: ITable, + alarm_topic: ITopic, + ): + handler = PythonFunction( + scope, + 'V1PostLicensesHandler', + description='Post licenses handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'licenses.py'), + handler='post_licenses', + role=license_upload_role, + environment=env_vars, + alarm_topic=alarm_topic, + ) + + # Grant permissions to put messages on the preprocessing queue + license_preprocessing_queue.grant_send_messages(handler) + compact_configuration_table.grant_read_data(handler) + rate_limiting_table.grant_read_write_data(handler) + return handler diff --git a/backend/social-work-app/stacks/api_lambda_stack/provider_management.py b/backend/social-work-app/stacks/api_lambda_stack/provider_management.py new file mode 100644 index 0000000000..a6a101db03 --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/provider_management.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import os + +from aws_cdk.aws_events import EventBus +from aws_cdk.aws_lambda import Code, Function, Runtime +from aws_cdk.aws_logs import RetentionDays +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from constructs import Construct + +from stacks import api_lambda_stack as als +from stacks import persistent_stack as ps + + +class ProviderManagementLambdas: + def __init__( + self, + *, + scope: Stack, + persistent_stack: ps.PersistentStack, + data_event_bus: EventBus, + api_lambda_stack: als.ApiLambdaStack, + ) -> None: + self.scope = scope + self.persistent_stack = persistent_stack + self.data_event_bus = data_event_bus + + self.stack: Stack = Stack.of(scope) + lambda_environment = { + 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'EVENT_BUS_NAME': data_event_bus.event_bus_name, + 'PROV_FAM_GIV_MID_INDEX_NAME': persistent_stack.provider_table.provider_fam_giv_mid_index_name, + 'PROV_DATE_OF_UPDATE_INDEX_NAME': persistent_stack.provider_table.provider_date_of_update_index_name, + 'RATE_LIMITING_TABLE_NAME': persistent_stack.rate_limiting_table.table_name, + 'USER_POOL_ID': persistent_stack.staff_users.user_pool_id, + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': persistent_stack.email_notification_service_lambda.function_name, + 'USERS_TABLE_NAME': persistent_stack.staff_users.user_table.table_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + **self.stack.common_env_vars, + } + + # Create all the lambda handlers + self.provider_investigation_handler = self._create_provider_investigation_handler(lambda_environment) + api_lambda_stack.log_groups.append(self.provider_investigation_handler.log_group) + self.get_provider_handler = self._get_provider_handler(lambda_environment) + api_lambda_stack.log_groups.append(self.get_provider_handler.log_group) + self.query_providers_handler = self._query_providers_handler(lambda_environment) + api_lambda_stack.log_groups.append(self.query_providers_handler.log_group) + self.provider_encumbrance_handler = self._add_provider_encumbrance_handler(lambda_environment) + api_lambda_stack.log_groups.append(self.provider_encumbrance_handler.log_group) + + # TODO: Remove this dummy once ApiStack no longer imports this lambda. # noqa: FIX002 + self._create_dummy_get_provider_ssn_handler(scope) + + def _create_dummy_get_provider_ssn_handler(self, scope: Construct) -> None: + """ + Keep a no-op Lambda with the original construct id so ApiStack cross-stack imports (export of ARN and log + group name) remain valid until phase 1 removes those references from the API template. + """ + stack = Stack.of(scope) + dummy_function = Function( + scope, + 'GetProviderSSNHandler', # Must match original + description='Get provider SSN handler dummy function', + handler='handler', + code=Code.from_inline('def handler(*args, **kwargs):\n return'), + runtime=Runtime.PYTHON_3_14, + log_retention=RetentionDays.ONE_DAY, # Triggers creation of the LogRetention custom resource + ) + stack.export_value(dummy_function.log_group.log_group_name) + stack.export_value(dummy_function.function_arn) + + NagSuppressions.add_resource_suppressions( + dummy_function, + suppressions=[ + { + 'id': 'HIPAA.Security-LambdaDLQ', + 'reason': 'This function is a dummy function to get past a deadly embrace with cross-stack ' + 'dependencies. It will be removed in a future update. It does not need a DLQ.', + }, + { + 'id': 'HIPAA.Security-LambdaInsideVPC', + 'reason': 'This function is a dummy function to get past a deadly embrace with cross-stack ' + 'dependencies. It will be removed in a future update. It does not need to be in a VPC.', + }, + ], + ) + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{dummy_function.node.path}/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'reason': 'The AWSBasicExecutionPolicy is suitable for this lambda', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{stack.node.path}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'reason': 'The actions in this policy are specifically what this lambda needs ' + 'and is scoped to one table, user pool, and one secret.', + }, + ], + ) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{stack.node.path}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are scoped specifically to what this lambda needs to manage' + ' log groups.', + }, + ], + ) + + def _create_provider_investigation_handler(self, lambda_environment: dict) -> PythonFunction: + """Create and configure the Lambda handler for investigating a provider's privilege or license.""" + handler = PythonFunction( + self.scope, + 'ProviderInvestigationHandler', + description='Provider investigation handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'investigation.py'), + handler='investigation_handler', + environment=lambda_environment, + alarm_topic=self.persistent_stack.alarm_topic, + ) + + # Grant necessary permissions + self.persistent_stack.provider_table.grant_read_write_data(handler) + self.persistent_stack.staff_users.user_table.grant_read_data(handler) + self.data_event_bus.grant_put_events_to(handler) + + NagSuppressions.add_resource_suppressions_by_path( + self.stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to tables and an event bus.', + }, + ], + ) + + return handler + + def _get_provider_handler( + self, + lambda_environment: dict, + ) -> PythonFunction: + handler = PythonFunction( + self.scope, + 'GetProviderHandler', + description='Get provider handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'providers.py'), + handler='get_provider', + environment=lambda_environment, + alarm_topic=self.persistent_stack.alarm_topic, + ) + self.persistent_stack.shared_encryption_key.grant_decrypt(handler) + self.persistent_stack.provider_table.grant_read_data(handler) + self.persistent_stack.compact_configuration_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + self.stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _query_providers_handler( + self, + lambda_environment: dict, + ) -> PythonFunction: + handler = PythonFunction( + self.scope, + 'QueryProvidersHandler', + description='Query providers handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'providers.py'), + handler='query_providers', + environment=lambda_environment, + alarm_topic=self.persistent_stack.alarm_topic, + ) + self.persistent_stack.shared_encryption_key.grant_decrypt(handler) + self.persistent_stack.provider_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(handler.role), + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'appliesTo': [ + 'Action::kms:GenerateDataKey*', + 'Action::kms:ReEncrypt*', + 'Resource::/index/*', + ], + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _add_provider_encumbrance_handler( + self, + lambda_environment: dict, + ) -> PythonFunction: + """Create and configure the Lambda handler for encumbering a provider's privilege or license.""" + handler = PythonFunction( + self.scope, + 'ProviderEncumbranceHandler', + description='Provider encumbrance handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'encumbrance.py'), + handler='encumbrance_handler', + environment=lambda_environment, + alarm_topic=self.persistent_stack.alarm_topic, + ) + self.persistent_stack.provider_table.grant_read_write_data(handler) + self.persistent_stack.staff_users.user_table.grant_read_data(handler) + self.persistent_stack.compact_configuration_table.grant_read_data(handler) + self.data_event_bus.grant_put_events_to(handler) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(handler.role), + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read/write ' + 'and is scoped to the needed tables and event bus.', + }, + ], + ) + + return handler diff --git a/backend/social-work-app/stacks/api_lambda_stack/public_lookup_api.py b/backend/social-work-app/stacks/api_lambda_stack/public_lookup_api.py new file mode 100644 index 0000000000..4dd0923389 --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/public_lookup_api.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import os + +from aws_cdk import Stack +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from constructs import Construct + +from stacks import api_lambda_stack as als +from stacks import persistent_stack as ps + + +class PublicLookupApiLambdas: + def __init__( + self, + *, + scope: Construct, + persistent_stack: ps.PersistentStack, + api_lambda_stack: als.ApiLambdaStack, + ): + super().__init__() + + stack = Stack.of(scope) + lambda_environment = { + 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'PROV_FAM_GIV_MID_INDEX_NAME': persistent_stack.provider_table.provider_fam_giv_mid_index_name, + 'PROV_DATE_OF_UPDATE_INDEX_NAME': persistent_stack.provider_table.provider_date_of_update_index_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + **stack.common_env_vars, + } + + self.get_provider_handler = self._get_provider_handler( + scope=scope, + env_vars=lambda_environment, + data_encryption_key=persistent_stack.shared_encryption_key, + provider_table=persistent_stack.provider_table, + compact_configuration_table=persistent_stack.compact_configuration_table, + alarm_topic=persistent_stack.alarm_topic, + ) + api_lambda_stack.log_groups.append(self.get_provider_handler.log_group) + + self.query_providers_handler = self._query_providers_handler( + scope=scope, + env_vars=lambda_environment, + data_encryption_key=persistent_stack.shared_encryption_key, + provider_table=persistent_stack.provider_table, + compact_configuration_table=persistent_stack.compact_configuration_table, + alarm_topic=persistent_stack.alarm_topic, + ) + api_lambda_stack.log_groups.append(self.query_providers_handler.log_group) + + # Dummy export to avoid CDK deadly embrace: public query providers now uses + # SearchPersistentStack.public_handler; this lambda is no longer wired to the API. + # TODO: remove this export (and the lambda above) after the stack is deployed to all envs # noqa: FIX002 + stack.export_value(self.query_providers_handler.function_arn) + + def _get_provider_handler( + self, + scope: Construct, + env_vars: dict, + data_encryption_key: IKey, + provider_table: ITable, + compact_configuration_table: ITable, + alarm_topic: ITopic, + ) -> PythonFunction: + stack = Stack.of(scope) + + handler = PythonFunction( + scope, + 'PublicGetProviderHandler', + description='Public Get provider handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'public_lookup.py'), + handler='public_get_provider', + environment=env_vars, + alarm_topic=alarm_topic, + ) + data_encryption_key.grant_decrypt(handler) + provider_table.grant_read_data(handler) + compact_configuration_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _query_providers_handler( + self, + scope: Construct, + env_vars: dict, + data_encryption_key: IKey, + provider_table: ITable, + compact_configuration_table: ITable, + alarm_topic: ITopic, + ) -> PythonFunction: + handler = PythonFunction( + scope, + 'PublicQueryProvidersHandler', + description='Public Query providers handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'public_lookup.py'), + handler='public_query_providers', + environment=env_vars, + alarm_topic=alarm_topic, + ) + data_encryption_key.grant_decrypt(handler) + provider_table.grant_read_data(handler) + compact_configuration_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(handler.role), + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'appliesTo': [ + 'Action::kms:GenerateDataKey*', + 'Action::kms:ReEncrypt*', + 'Resource::/index/*', + ], + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler diff --git a/backend/social-work-app/stacks/api_lambda_stack/staff_users.py b/backend/social-work-app/stacks/api_lambda_stack/staff_users.py new file mode 100644 index 0000000000..609390c7f4 --- /dev/null +++ b/backend/social-work-app/stacks/api_lambda_stack/staff_users.py @@ -0,0 +1,463 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration, Stack +from aws_cdk.aws_cloudwatch import ( + Alarm, + CfnAlarm, + ComparisonOperator, + Metric, + TreatMissingData, +) +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_cognito import IUserPool +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.user_pool import UserPool +from constructs import Construct + +from stacks import api_lambda_stack as als +from stacks import persistent_stack as ps + + +class StaffUsersLambdas: + def __init__( + self, + *, + scope: Construct, + persistent_stack: ps.PersistentStack, + api_lambda_stack: als.ApiLambdaStack, + ): + super().__init__() + stack = Stack.of(scope) + + env_vars = { + **stack.common_env_vars, + 'USER_POOL_ID': persistent_stack.staff_users.user_pool_id, + 'USERS_TABLE_NAME': persistent_stack.staff_users.user_table.table_name, + 'FAM_GIV_INDEX_NAME': persistent_stack.staff_users.user_table.family_given_index_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + } + + self.get_me_handler = self._get_me_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + ) + api_lambda_stack.log_groups.append(self.get_me_handler.log_group) + + self.patch_me_handler = self._patch_me_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + user_pool=persistent_stack.staff_users, + ) + api_lambda_stack.log_groups.append(self.patch_me_handler.log_group) + + self.get_users_handler = self._get_users_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + ) + api_lambda_stack.log_groups.append(self.get_users_handler.log_group) + + self.get_user_handler = self._get_user_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + ) + api_lambda_stack.log_groups.append(self.get_user_handler.log_group) + + self.patch_user_handler = self._patch_user_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + compact_configuration_table=persistent_stack.compact_configuration_table, + ) + api_lambda_stack.log_groups.append(self.patch_user_handler.log_group) + + self.delete_user_handler = self._delete_user_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + staff_user_pool=persistent_stack.staff_users, + ) + api_lambda_stack.log_groups.append(self.delete_user_handler.log_group) + + self.post_user_handler = self._post_user_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + user_pool=persistent_stack.staff_users, + compact_configuration_table=persistent_stack.compact_configuration_table, + alarm_topic=persistent_stack.alarm_topic, + ) + api_lambda_stack.log_groups.append(self.post_user_handler.log_group) + + self.reinvite_user_handler = self._reinvite_user_handler( + scope=scope, + env_vars=env_vars, + data_encryption_key=persistent_stack.shared_encryption_key, + user_table=persistent_stack.staff_users.user_table, + user_pool=persistent_stack.staff_users, + ) + api_lambda_stack.log_groups.append(self.reinvite_user_handler.log_group) + + def _get_me_handler(self, scope: Construct, env_vars: dict, data_encryption_key: IKey, user_table: ITable): + stack = Stack.of(scope) + handler = PythonFunction( + scope, + 'GetMeStaffUserHandler', + lambda_dir='staff-users', + index=os.path.join('handlers', 'me.py'), + handler='get_me', + environment=env_vars, + ) + data_encryption_key.grant_decrypt(handler) + user_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _patch_me_handler( + self, scope: Construct, env_vars: dict, data_encryption_key: IKey, user_table: ITable, user_pool: IUserPool + ): + stack = Stack.of(scope) + + handler = PythonFunction( + scope, + 'PatchMeStaffUserHandler', + lambda_dir='staff-users', + index=os.path.join('handlers', 'me.py'), + handler='patch_me', + environment=env_vars, + ) + data_encryption_key.grant_encrypt_decrypt(handler) + user_table.grant_read_write_data(handler) + user_pool.grant(handler, 'cognito-idp:AdminUpdateUserAttributes') + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _get_users_handler(self, scope: Construct, env_vars: dict, data_encryption_key: IKey, user_table: ITable): + stack = Stack.of(scope) + + handler = PythonFunction( + scope, + 'GetStaffUsersHandler', + lambda_dir='staff-users', + index=os.path.join('handlers', 'users.py'), + handler='get_users', + environment=env_vars, + ) + data_encryption_key.grant_decrypt(handler) + user_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _get_user_handler(self, scope: Construct, env_vars: dict, data_encryption_key: IKey, user_table: ITable): + stack = Stack.of(scope) + + handler = PythonFunction( + scope, + 'GetStaffUserHandler', + lambda_dir='staff-users', + index=os.path.join('handlers', 'users.py'), + handler='get_one_user', + environment=env_vars, + ) + data_encryption_key.grant_decrypt(handler) + user_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _patch_user_handler( + self, + scope: Construct, + env_vars: dict, + data_encryption_key: IKey, + user_table: ITable, + compact_configuration_table: ITable, + ): + stack = Stack.of(scope) + + handler = PythonFunction( + scope, + 'PatchUserHandler', + lambda_dir='staff-users', + index=os.path.join('handlers', 'users.py'), + handler='patch_user', + environment=env_vars, + ) + data_encryption_key.grant_encrypt_decrypt(handler) + user_table.grant_read_write_data(handler) + compact_configuration_table.grant_read_data(handler) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _delete_user_handler( + self, scope: Construct, env_vars: dict, data_encryption_key: IKey, user_table: ITable, staff_user_pool: UserPool + ): + stack = Stack.of(scope) + + handler = PythonFunction( + scope, + 'DeleteStaffUserFunction', + lambda_dir='staff-users', + index=os.path.join('handlers', 'users.py'), + handler='delete_user', + environment=env_vars, + ) + + # Grant permissions to the function + data_encryption_key.grant_encrypt_decrypt(handler) + user_table.grant_read_write_data(handler) + staff_user_pool.grant(handler, 'cognito-idp:AdminDisableUser') + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _add_post_user_metrics( + self, + scope: Construct, + alarm_topic: ITopic, + ): + # Create a metric to track how many times this endpoint has been invoked within an hour + staff_user_created_hourly_count_metric = Metric( + namespace='compact-connect', + metric_name='staff-user-created', + statistic='SampleCount', + period=Duration.hours(1), + dimensions_map={'service': 'common'}, + ) + + # Setting a flat rate of 5 Staff users per hour to alarm on + self.max_hourly_staff_users_created_alarm = Alarm( + scope, + 'MaxHourlyStaffUserCreatedAlarm', + metric=staff_user_created_hourly_count_metric, + threshold=5, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=f'{scope.node.path} max hourly staff users created alarm. The POST staff user ' + f'endpoint has been invoked more than an expected threshold within an hour period. ' + f'Investigation is required to ensure requests are authorized.', + ) + self.max_hourly_staff_users_created_alarm.add_alarm_action(SnsAction(alarm_topic)) + + # Also create a daily metric + staff_user_created_daily_count_metric = Metric( + namespace='compact-connect', + metric_name='staff-user-created', + statistic='SampleCount', + period=Duration.days(1), + dimensions_map={'service': 'common'}, + ) + + # Setting a flat rate of 20 Staff users created per day to alarm on + self.max_daily_staff_users_created_alarm = Alarm( + scope, + 'MaxDailyStaffUserCreatedAlarm', + metric=staff_user_created_daily_count_metric, + threshold=20, + evaluation_periods=1, + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=f'{scope.node.path} max daily staff users created alarm. The POST staff user endpoint ' + f'has been invoked more than an expected threshold within a day. ' + f'Investigation is required to ensure requests are authorized.', + ) + self.max_daily_staff_users_created_alarm.add_alarm_action(SnsAction(alarm_topic)) + + # We'll monitor longer access patterns to detect anomalies, over time + # The L2 construct, Alarm, doesn't yet support Anomaly Detection as a configuration + # so we're using the L1 construct, CfnAlarm + self.staff_user_creation_anomaly_detection_alarm = CfnAlarm( + scope, + 'StaffUserCreationAnomalyAlarm', + alarm_description=f'{scope.node.path} staff-user-created anomaly detection. Anomalies in the number of ' + f'staff users created per day are detected. Investigation is required to ensure requests ' + f'are authorized.', + comparison_operator='GreaterThanUpperThreshold', + evaluation_periods=1, + treat_missing_data='notBreaching', + actions_enabled=True, + alarm_actions=[alarm_topic.node.default_child.ref], + metrics=[ + CfnAlarm.MetricDataQueryProperty(id='ad1', expression='ANOMALY_DETECTION_BAND(m1, 2)'), + CfnAlarm.MetricDataQueryProperty( + id='m1', + metric_stat=CfnAlarm.MetricStatProperty( + metric=CfnAlarm.MetricProperty( + metric_name=staff_user_created_daily_count_metric.metric_name, + namespace=staff_user_created_daily_count_metric.namespace, + dimensions=[CfnAlarm.DimensionProperty(name='service', value='common')], + ), + period=3600, + stat='SampleCount', + ), + ), + ], + threshold_metric_id='ad1', + ) + + def _post_user_handler( + self, + scope: Construct, + env_vars: dict, + data_encryption_key: IKey, + user_table: ITable, + user_pool: IUserPool, + compact_configuration_table: ITable, + alarm_topic: ITopic, + ): + stack = Stack.of(scope) + handler = PythonFunction( + scope, + 'PostStaffUserHandler', + lambda_dir='staff-users', + index=os.path.join('handlers', 'users.py'), + handler='post_user', + environment=env_vars, + ) + data_encryption_key.grant_encrypt_decrypt(handler) + user_table.grant_read_write_data(handler) + user_pool.grant( + handler, + 'cognito-idp:AdminCreateUser', + 'cognito-idp:AdminDeleteUser', + 'cognito-idp:AdminDisableUser', + 'cognito-idp:AdminEnableUser', + 'cognito-idp:AdminGetUser', + 'cognito-idp:AdminResetUserPassword', + 'cognito-idp:AdminSetUserPassword', + ) + compact_configuration_table.grant_read_data(handler) + + self._add_post_user_metrics(scope, alarm_topic) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler + + def _reinvite_user_handler( + self, scope: Construct, env_vars: dict, data_encryption_key: IKey, user_table: ITable, user_pool: IUserPool + ): + stack = Stack.of(scope) + + handler = PythonFunction( + scope, + 'ReinviteStaffUserFunction', + lambda_dir='staff-users', + index=os.path.join('handlers', 'users.py'), + handler='reinvite_user', + environment=env_vars, + ) + + # Grant permissions to the function + data_encryption_key.grant_encrypt_decrypt(handler) + user_table.grant_read_write_data(handler) + user_pool.grant( + handler, + 'cognito-idp:AdminGetUser', + 'cognito-idp:AdminResetUserPassword', + 'cognito-idp:AdminSetUserPassword', + 'cognito-idp:AdminCreateUser', + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + return handler diff --git a/backend/social-work-app/stacks/api_stack/__init__.py b/backend/social-work-app/stacks/api_stack/__init__.py new file mode 100644 index 0000000000..b9d56c5862 --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/__init__.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from common_constructs.security_profile import SecurityProfile +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack as ps +from stacks import search_persistent_stack as sps +from stacks.api_lambda_stack import ApiLambdaStack + +from .api import LicenseApi + + +class ApiStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + environment_context: dict, + persistent_stack: ps.PersistentStack, + api_lambda_stack: ApiLambdaStack, + search_persistent_stack: sps.SearchPersistentStack, + **kwargs, + ): + super().__init__( + scope, construct_id, environment_context=environment_context, environment_name=environment_name, **kwargs + ) + + security_profile = SecurityProfile[environment_context.get('security_profile', 'RECOMMENDED')] + + self.api = LicenseApi( + self, + 'LicenseApi', + environment_name=environment_name, + security_profile=security_profile, + persistent_stack=persistent_stack, + api_lambda_stack=api_lambda_stack, + search_persistent_stack=search_persistent_stack, + domain_name=self.api_domain_name, + ) diff --git a/backend/social-work-app/stacks/api_stack/api.py b/backend/social-work-app/stacks/api_stack/api.py new file mode 100644 index 0000000000..8baf0458e8 --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/api.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from functools import cached_property + +from common_constructs.compact_connect_api import CompactConnectApi +from constructs import Construct + +from stacks import persistent_stack as ps +from stacks import search_persistent_stack as sps +from stacks.api_lambda_stack import ApiLambdaStack + + +class LicenseApi(CompactConnectApi): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + persistent_stack: ps.PersistentStack, + api_lambda_stack: ApiLambdaStack, + search_persistent_stack: sps.SearchPersistentStack, + **kwargs, + ): + super().__init__( + scope, + construct_id, + alarm_topic=persistent_stack.alarm_topic, + staff_users_user_pool=persistent_stack.staff_users, + **kwargs, + ) + from stacks.api_stack.v1_api import V1Api + + self.v1_api = V1Api( + self.root, + persistent_stack=persistent_stack, + api_lambda_stack=api_lambda_stack, + search_persistent_stack=search_persistent_stack, + ) + + @cached_property + def staff_users_authorizer(self): + from aws_cdk.aws_apigateway import CognitoUserPoolsAuthorizer + + return CognitoUserPoolsAuthorizer(self, 'StaffUsersPoolAuthorizer', cognito_user_pools=[self.staff_users]) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/__init__.py b/backend/social-work-app/stacks/api_stack/v1_api/__init__.py new file mode 100644 index 0000000000..e14e23d9ed --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/__init__.py @@ -0,0 +1,4 @@ +# ruff: noqa: F401 +# We place this import here so it can be referenced by other +# CDK resources +from .api import V1Api diff --git a/backend/social-work-app/stacks/api_stack/v1_api/api.py b/backend/social-work-app/stacks/api_stack/v1_api/api.py new file mode 100644 index 0000000000..60b90fa6e5 --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/api.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from aws_cdk import Stack +from aws_cdk.aws_apigateway import AuthorizationType, IResource, MethodOptions + +from stacks import persistent_stack as ps +from stacks import search_persistent_stack as sps +from stacks.api_lambda_stack import ApiLambdaStack + +from .api_model import ApiModel +from .bulk_upload_url import BulkUploadUrl +from .compact_configuration_api import CompactConfigurationApi +from .feature_flags import FeatureFlagsApi +from .provider_management import ProviderManagement +from .public_lookup_api import PublicLookupApi +from .staff_users import StaffUsers + + +class V1Api: + """v1 of the Provider Data API""" + + def __init__( + self, + root: IResource, + persistent_stack: ps.PersistentStack, + api_lambda_stack: ApiLambdaStack, + search_persistent_stack: sps.SearchPersistentStack, + ): + super().__init__() + from stacks.api_stack.api import LicenseApi + + self.root = root + self.resource = root.add_resource('v1') + self.api: LicenseApi = root.api + self.api_model = ApiModel(api=self.api) + stack: Stack = Stack.of(self.resource) + _active_compacts = persistent_stack.get_list_of_compact_abbreviations() + + # we only pass the API_BASE_URL env var if the API_DOMAIN_NAME is set + # this is because the API_BASE_URL is used by the feature flag client to call the flag check endpoint + if persistent_stack.api_domain_name: + stack.common_env_vars.update({'API_BASE_URL': f'https://{persistent_stack.api_domain_name}'}) + + read_scopes = [] + write_scopes = [] + admin_scopes = [] + # set the compact level scopes + for compact in _active_compacts: + # We only set the readGeneral permission scope at the compact level, since users with any permissions + # within a compact are implicitly granted this scope + read_scopes.append(f'{compact}/readGeneral') + write_scopes.append(f'{compact}/write') + admin_scopes.append(f'{compact}/admin') + + _active_compact_jurisdictions = persistent_stack.get_list_of_active_jurisdictions_for_compact_environment( + compact=compact + ) + + # We also include the jurisdiction level compact scopes for all jurisdictions active within the compact + # The one exception to this is the readPrivate scope, as this is exclusively checked in the runtime code + # to determine what data to return from the query related endpoints + for jurisdiction in _active_compact_jurisdictions: + write_scopes.append(f'{jurisdiction}/{compact}.write') + admin_scopes.append(f'{jurisdiction}/{compact}.admin') + + read_auth_method_options = MethodOptions( + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=read_scopes, + ) + write_auth_method_options = MethodOptions( + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=write_scopes, + ) + + admin_auth_method_options = MethodOptions( + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=admin_scopes, + ) + + # /v1/flags + self.flags_resource = self.resource.add_resource('flags') + self.feature_flags = FeatureFlagsApi( + resource=self.flags_resource, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + ) + + # /v1/public + self.public_resource = self.resource.add_resource('public') + # POST /v1/public/compacts/{compact}/providers/query + # GET /v1/public/compacts/{compact}/providers/{providerId} + self.public_compacts_resource = self.public_resource.add_resource('compacts') + # /v1/public/jurisdictions + self.public_jurisdictions_resource = self.public_resource.add_resource('jurisdictions') + # /v1/public/jurisdictions/live + self.live_jurisdictions_resource = self.public_jurisdictions_resource.add_resource('live') + self.public_compacts_compact_resource = self.public_compacts_resource.add_resource('{compact}') + self.public_compacts_compact_providers_resource = self.public_compacts_compact_resource.add_resource( + 'providers' + ) + self.public_lookup_api = PublicLookupApi( + resource=self.public_compacts_compact_providers_resource, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + search_persistent_stack=search_persistent_stack, + ) + + # /v1/compacts + self.compacts_resource = self.resource.add_resource('compacts') + # /v1/compacts/{compact} + self.compact_resource = self.compacts_resource.add_resource('{compact}') + + # POST /v1/compacts/{compact}/providers/query + # GET /v1/compacts/{compact}/providers/{providerId} + providers_resource = self.compact_resource.add_resource('providers') + self.provider_management = ProviderManagement( + resource=providers_resource, + method_options=read_auth_method_options, + admin_method_options=admin_auth_method_options, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + ) + # GET /v1/compacts/{compact}/jurisdictions + self.jurisdictions_resource = self.compact_resource.add_resource('jurisdictions') + # GET /v1/compacts/{compact}/jurisdictions/{jurisdiction} + self.jurisdiction_resource = self.jurisdictions_resource.add_resource('{jurisdiction}') + # GET /v1/public/compacts/{compact}/jurisdictions + self.public_compacts_compact_jurisdictions_resource = self.public_compacts_compact_resource.add_resource( + 'jurisdictions' + ) + + self.compact_configuration_api = CompactConfigurationApi( + api=self.api, + compact_resource=self.compact_resource, + live_jurisdictions_resource=self.live_jurisdictions_resource, + jurisdictions_resource=self.jurisdictions_resource, + public_jurisdictions_resource=self.public_compacts_compact_jurisdictions_resource, + jurisdiction_resource=self.jurisdiction_resource, + general_read_method_options=read_auth_method_options, + admin_method_options=admin_auth_method_options, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + ) + + # GET /v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses/bulk-upload + licenses_resource = self.jurisdiction_resource.add_resource('licenses') + BulkUploadUrl( + resource=licenses_resource, + method_options=write_auth_method_options, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + ) + + # /v1/staff-users + self.staff_users_admin_resource = self.compact_resource.add_resource('staff-users') + self.staff_users_self_resource = self.resource.add_resource('staff-users') + # GET /v1/staff-users/me + # PATCH /v1/staff-users/me + # GET /v1/compacts/{compact}/staff-users + # POST /v1/compacts/{compact}/staff-users + # GET /v1/compacts/{compact}/staff-users/{userId} + # PATCH /v1/compacts/{compact}/staff-users/{userId} + self.staff_users = StaffUsers( + admin_resource=self.staff_users_admin_resource, + self_resource=self.staff_users_self_resource, + admin_scopes=admin_scopes, + api_model=self.api_model, + api_lambda_stack=api_lambda_stack, + ) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/api_model.py b/backend/social-work-app/stacks/api_stack/v1_api/api_model.py new file mode 100644 index 0000000000..f1cbfea206 --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/api_model.py @@ -0,0 +1,1698 @@ +# ruff: noqa: SLF001 +# This class initializes the api models for the root api, which we then want to set as protected +# so other classes won't modify it. This is a valid use case for protected access to work with cdk. +from __future__ import annotations + +from aws_cdk.aws_apigateway import JsonSchema, JsonSchemaType, Model +from common_constructs.stack import AppStack + +# Importing module level to allow lazy loading for typing +from common_constructs import compact_connect_api + + +class ApiModel: + """This class is responsible for defining the model definitions used in the API endpoints.""" + + def __init__(self, api: compact_connect_api.CompactConnectApi): + self.stack: AppStack = AppStack.of(api) + self.api = api + + @property + def message_response_model(self) -> Model: + """Basic response that returns a string message""" + if hasattr(self.api, '_v1_message_response_model'): + return self.api._v1_message_response_model + self.api._v1_message_response_model = self.api.add_model( + 'V1MessageResponseModel', + description='Simple message response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['message'], + properties={ + 'message': JsonSchema( + type=JsonSchemaType.STRING, + description='A message about the request', + ), + }, + ), + ) + return self.api._v1_message_response_model + + @property + def post_licenses_error_response_model(self) -> Model: + """Response model for POST licenses which specifies error responses""" + if hasattr(self.api, '_v1_post_licenses_response_model'): + return self.api._v1_post_licenses_response_model + self.api._v1_post_licenses_response_model = self.api.add_model( + 'V1PostLicensesResponseModel', + description='POST licenses response model supporting both success and error responses', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'message': JsonSchema( + type=JsonSchemaType.STRING, + description='Message indicating success or failure', + ), + 'errors': JsonSchema( + type=JsonSchemaType.OBJECT, + description='Validation errors by record index', + additional_properties=JsonSchema( + type=JsonSchemaType.OBJECT, + description='Errors for a specific record', + additional_properties=JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema(type=JsonSchemaType.STRING), + description='List of error messages for a field', + ), + ), + ), + }, + ), + ) + return self.api._v1_post_licenses_response_model + + @property + def query_providers_request_model(self) -> Model: + """Return the query providers request model, which should only be created once per API""" + if hasattr(self.api, '_v1_query_providers_request_model'): + return self.api._v1_query_providers_request_model + self.api._v1_query_providers_request_model = self.api.add_model( + 'V1QueryProvidersRequestModel', + description='Query providers request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + required=['query'], + properties={ + 'query': JsonSchema( + type=JsonSchemaType.OBJECT, + description='The query parameters', + additional_properties=False, + properties={ + 'providerId': JsonSchema( + type=JsonSchemaType.STRING, + description='Internal UUID for the provider', + pattern=compact_connect_api.UUID4_FORMAT, + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + description='Filter for providers with license in a jurisdiction', + enum=self.api.node.get_context('jurisdictions'), + ), + 'givenName': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + description='Filter for providers with a given name (familyName is required if' + ' givenName is provided)', + ), + 'familyName': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + description='Filter for providers with a family name', + ), + 'licenseNumber': JsonSchema( + type=JsonSchemaType.STRING, + min_length=1, + max_length=100, + description='Filter for licenses with a specific license number', + ), + }, + ), + 'pagination': self._pagination_request_schema, + 'sorting': self._sorting_schema, + }, + ), + ) + return self.api._v1_query_providers_request_model + + @property + def query_providers_response_model(self) -> Model: + """Return the query providers response model, which should only be created once per API""" + if hasattr(self.api, '_v1_query_providers_response_model'): + return self.api._v1_query_providers_response_model + self.api._v1_query_providers_response_model = self.api.add_model( + 'V1QueryProvidersResponseModel', + description='Query providers response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['providers', 'pagination'], + properties={ + 'providers': JsonSchema( + type=JsonSchemaType.ARRAY, + max_length=100, + items=self._providers_response_schema, + ), + 'pagination': self._pagination_response_schema, + 'sorting': self._sorting_schema, + }, + ), + ) + return self.api._v1_query_providers_response_model + + @property + def provider_response_model(self) -> Model: + """Return the provider response model, which should only be created once per API""" + if hasattr(self.api, '_v1_get_provider_response_model'): + return self.api._v1_get_provider_response_model + self.api._v1_get_provider_response_model = self.api.add_model( + 'V1GetProviderResponseModel', + description='Get provider response model', + schema=self._provider_detail_response_schema, + ) + return self.api._v1_get_provider_response_model + + @property + def bulk_upload_response_model(self) -> Model: + """Return the Bulk Upload Response Model, which should only be created once per API""" + if hasattr(self.api, '_v1_bulk_upload_response_model'): + return self.api._v1_bulk_upload_response_model + + self.api._v1_bulk_upload_response_model = self.api.add_model( + 'BulkUploadResponseModel', + description='Bulk upload url response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['upload'], + properties={ + 'upload': JsonSchema( + type=JsonSchemaType.OBJECT, + required=['url', 'fields'], + properties={ + 'url': JsonSchema(type=JsonSchemaType.STRING), + 'fields': JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=JsonSchema(type=JsonSchemaType.STRING), + ), + }, + ) + }, + ), + ) + return self.api._v1_bulk_upload_response_model + + @property + def post_staff_user_model(self): + """Return the Post User Model, which should only be created once per API""" + if hasattr(self.api, 'v1_post_user_request_model'): + return self.api.v1_post_user_request_model + + self.api.v1_post_user_request_model = self.api.add_model( + 'V1PostUserRequestModel', + description='Post user request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['attributes', 'permissions'], + additional_properties=False, + properties=self._common_staff_user_properties, + ), + ) + return self.api.v1_post_user_request_model + + @property + def get_staff_user_me_model(self): + """Return the Get Me Model, which should only be created once per API""" + if hasattr(self.api, 'v1_get_me_model'): + return self.api.v1_get_me_model + + self.api.v1_get_me_model = self.api.add_model( + 'V1GetMeModel', + description='Get me response model', + schema=self._staff_user_response_schema, + ) + return self.api.v1_get_me_model + + @property + def get_staff_users_response_model(self): + """Return the Get Users Model, which should only be created once per API""" + if hasattr(self.api, 'v1_get_staff_users_response_model'): + return self.api.v1_get_staff_users_response_model + + self.api.v1_get_staff_users_response_model = self.api.add_model( + 'V1GetStaffUsersModel', + description='Get staff users response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={ + 'users': JsonSchema(type=JsonSchemaType.ARRAY, items=self._staff_user_response_schema), + 'pagination': self._pagination_response_schema, + }, + ), + ) + return self.api.v1_get_staff_users_response_model + + @property + def patch_staff_user_me_model(self): + """Return the Get Me Model, which should only be created once per API""" + if hasattr(self.api, 'v1_patch_me_request_model'): + return self.api.v1_patch_me_request_model + + self.api.v1_patch_me_request_model = self.api.add_model( + 'V1PatchMeRequestModel', + description='Patch me request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={ + 'attributes': self._staff_user_patch_attributes_schema, + }, + ), + ) + return self.api.v1_patch_me_request_model + + @property + def patch_staff_user_model(self): + """Return the Patch User Model, which should only be created once per API""" + if hasattr(self.api, 'v1_patch_user_request_model'): + return self.api.v1_patch_user_request_model + + self.api.v1_patch_user_request_model = self.api.add_model( + 'V1PatchUserRequestModel', + description='Patch user request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={'permissions': self._staff_user_permissions_schema}, + ), + ) + return self.api.v1_patch_user_request_model + + @property + def _staff_user_attributes_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=['email', 'givenName', 'familyName'], + additional_properties=False, + properties={ + 'email': JsonSchema(type=JsonSchemaType.STRING, min_length=5, max_length=100), + 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + }, + ) + + @property + def _staff_user_patch_attributes_schema(self): + """No support for changing a user's email address.""" + return JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={ + 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + }, + ) + + @property + def _staff_user_permissions_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={ + 'actions': JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), + }, + ), + 'jurisdictions': JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'actions': JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={ + 'write': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), + }, + ), + }, + ), + ), + }, + ), + ) + + @property + def _common_staff_user_properties(self): + return {'attributes': self._staff_user_attributes_schema, 'permissions': self._staff_user_permissions_schema} + + @property + def _staff_user_response_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=['userId', 'attributes', 'permissions', 'status'], + additional_properties=False, + properties={ + 'userId': JsonSchema(type=JsonSchemaType.STRING), + 'status': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + **self._common_staff_user_properties, + }, + ) + + @property + def post_privilege_encumbrance_request_model(self) -> Model: + """Return the post privilege encumbrance request model, which should only be created once per API""" + if hasattr(self.api, '_v1_post_privilege_encumbrance_request_model'): + return self.api._v1_post_privilege_encumbrance_request_model + self.api._v1_post_privilege_encumbrance_request_model = self.api.add_model( + 'V1PostPrivilegeEncumbranceRequestModel', + description='Post privilege encumbrance request model', + schema=self._encumbrance_request_schema, + ) + + return self.api._v1_post_privilege_encumbrance_request_model + + @property + def post_license_encumbrance_request_model(self) -> Model: + """Return the post license encumbrance request model, which should only be created once per API""" + if hasattr(self.api, '_v1_post_license_encumbrance_request_model'): + return self.api._v1_post_license_encumbrance_request_model + self.api._v1_post_license_encumbrance_request_model = self.api.add_model( + 'V1PostLicenseEncumbranceRequestModel', + description='Post license encumbrance request model', + schema=self._encumbrance_request_schema, + ) + + return self.api._v1_post_license_encumbrance_request_model + + @property + def patch_privilege_encumbrance_request_model(self) -> Model: + """Return the patch privilege encumbrance request model for lifting encumbrances, + which should only be created once per API""" + if hasattr(self.api, '_v1_patch_privilege_encumbrance_request_model'): + return self.api._v1_patch_privilege_encumbrance_request_model + self.api._v1_patch_privilege_encumbrance_request_model = self.api.add_model( + 'V1PatchPrivilegeEncumbranceRequestModel', + description='Patch privilege encumbrance request model for lifting encumbrances', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + required=['effectiveLiftDate'], + properties={ + 'effectiveLiftDate': JsonSchema( + type=JsonSchemaType.STRING, + description='The effective date when the encumbrance will be lifted', + format='date', + pattern=compact_connect_api.YMD_FORMAT, + ), + }, + ), + ) + + return self.api._v1_patch_privilege_encumbrance_request_model + + @property + def patch_license_encumbrance_request_model(self) -> Model: + """Return the patch license encumbrance request model for lifting encumbrances, + which should only be created once per API""" + if hasattr(self.api, '_v1_patch_license_encumbrance_request_model'): + return self.api._v1_patch_license_encumbrance_request_model + self.api._v1_patch_license_encumbrance_request_model = self.api.add_model( + 'V1PatchLicenseEncumbranceRequestModel', + description='Patch license encumbrance request model for lifting encumbrances', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + required=['effectiveLiftDate'], + properties={ + 'effectiveLiftDate': JsonSchema( + type=JsonSchemaType.STRING, + description='The effective date when the encumbrance will be lifted', + format='date', + pattern=compact_connect_api.YMD_FORMAT, + ), + }, + ), + ) + + return self.api._v1_patch_license_encumbrance_request_model + + @property + def _providers_response_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'givenName', + 'familyName', + 'licenseStatus', + 'compactEligibility', + 'jurisdictionUploadedLicenseStatus', + 'jurisdictionUploadedCompactEligibility', + 'compact', + 'licenseJurisdiction', + 'dateOfUpdate', + 'dateOfExpiration', + 'birthMonthDay', + ], + properties=self._common_provider_properties, + ) + + @property + def _provider_detail_response_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'givenName', + 'familyName', + 'compact', + 'licenseJurisdiction', + 'dateOfUpdate', + 'dateOfExpiration', + 'birthMonthDay', + 'licenses', + 'privileges', + 'adverseActions', + ], + properties={ + 'adverseActions': self._adverse_action_schema, + 'licenses': JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'compact', + 'jurisdiction', + 'dateOfUpdate', + 'givenName', + 'middleName', + 'familyName', + 'homeAddressStreet1', + 'homeAddressCity', + 'homeAddressState', + 'homeAddressPostalCode', + 'licenseType', + 'dateOfIssuance', + 'dateOfRenewal', + 'dateOfExpiration', + 'birthMonthDay', + 'licenseStatus', + 'compactEligibility', + 'jurisdictionUploadedLicenseStatus', + 'jurisdictionUploadedCompactEligibility', + 'history', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['license-home']), + 'providerId': JsonSchema( + type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT + ), + 'compact': JsonSchema( + type=JsonSchemaType.STRING, enum=self.stack.node.get_context('compacts') + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=self.stack.node.get_context('jurisdictions'), + ), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.license_type_names), + 'dateOfUpdate': JsonSchema( + type=JsonSchemaType.STRING, + format='date-time', + ), + 'licenseStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'compactEligibility': JsonSchema( + type=JsonSchemaType.STRING, enum=['eligible', 'ineligible'] + ), + 'jurisdictionUploadedLicenseStatus': JsonSchema( + type=JsonSchemaType.STRING, enum=['active', 'inactive'] + ), + 'jurisdictionUploadedCompactEligibility': JsonSchema( + type=JsonSchemaType.STRING, enum=['eligible', 'ineligible'] + ), + 'ssnLastFour': JsonSchema(type=JsonSchemaType.STRING, pattern='^[0-9]{4}$'), + 'history': JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'updateType', + 'compact', + 'jurisdiction', + 'dateOfUpdate', + 'previous', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['licenseUpdate']), + 'updateType': self._update_type_schema, + 'compact': JsonSchema( + type=JsonSchemaType.STRING, enum=self.stack.node.get_context('compacts') + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=self.stack.node.get_context('jurisdictions'), + ), + 'licenseType': JsonSchema( + type=JsonSchemaType.STRING, enum=self.stack.license_type_names + ), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'previous': JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'givenName', + 'middleName', + 'familyName', + 'dateOfUpdate', + 'dateOfIssuance', + 'dateOfRenewal', + 'dateOfExpiration', + 'homeAddressStreet1', + 'homeAddressCity', + 'homeAddressState', + 'homeAddressPostalCode', + 'jurisdictionUploadedLicenseStatus', + 'jurisdictionUploadedCompactEligibility', + ], + properties={ + 'jurisdictionUploadedLicenseStatus': JsonSchema( + type=JsonSchemaType.STRING, enum=['active', 'inactive'] + ), + 'jurisdictionUploadedCompactEligibility': JsonSchema( + type=JsonSchemaType.STRING, enum=['eligible', 'ineligible'] + ), + **self._common_license_properties, + }, + ), + 'updatedValues': JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'jurisdictionUploadedLicenseStatus': JsonSchema( + type=JsonSchemaType.STRING, enum=['active', 'inactive'] + ), + 'jurisdictionUploadedCompactEligibility': JsonSchema( + type=JsonSchemaType.STRING, enum=['eligible', 'ineligible'] + ), + **self._common_license_properties, + }, + ), + 'removedValues': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of field names that were present in the previous record' + ' but removed in the update', + items=JsonSchema(type=JsonSchemaType.STRING), + ), + }, + ), + ), + 'adverseActions': self._adverse_action_schema, + 'investigations': JsonSchema( + type=JsonSchemaType.ARRAY, + items=self._investigation_schema, + ), + 'investigationStatus': JsonSchema( + type=JsonSchemaType.STRING, + enum=['underInvestigation'], + description='Status indicating if the license is under investigation', + ), + **self._common_license_properties, + }, + ), + ), + 'privileges': JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'compact', + 'jurisdiction', + 'dateOfExpiration', + 'compactTransactionId', + 'licenseType', + 'licenseJurisdiction', + 'administratorSetStatus', + 'status', + 'history', + ], + properties={ + 'history': JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'updateType', + 'compact', + 'jurisdiction', + 'dateOfUpdate', + 'previous', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['privilegeUpdate']), + 'updateType': self._update_type_schema, + 'compact': JsonSchema( + type=JsonSchemaType.STRING, enum=self.stack.node.get_context('compacts') + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=self.stack.node.get_context('jurisdictions'), + ), + 'licenseType': JsonSchema( + type=JsonSchemaType.STRING, enum=self.stack.license_type_names + ), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'previous': JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'dateOfExpiration', + 'compactTransactionId', + 'licenseJurisdiction', + 'administratorSetStatus', + ], + properties=self._common_privilege_properties, + ), + 'updatedValues': JsonSchema( + type=JsonSchemaType.OBJECT, properties=self._common_privilege_properties + ), + 'removedValues': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of field names that were present in the previous record' + ' but removed in the update', + items=JsonSchema(type=JsonSchemaType.STRING), + ), + }, + ), + ), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.license_type_names), + 'adverseActions': self._adverse_action_schema, + 'investigations': JsonSchema( + type=JsonSchemaType.ARRAY, + items=self._investigation_schema, + ), + 'investigationStatus': JsonSchema( + type=JsonSchemaType.STRING, + enum=['underInvestigation'], + description='Status indicating if the privilege is under investigation', + ), + **self._common_privilege_properties, + }, + ), + ), + **self._common_provider_properties, + }, + ) + + @property + def _adverse_action_schema(self): + return JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'compact', + 'providerId', + 'jurisdiction', + 'licenseTypeAbbreviation', + 'licenseType', + 'actionAgainst', + 'effectiveStartDate', + 'creationDate', + 'adverseActionId', + 'dateOfUpdate', + 'encumbranceType', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['adverseAction']), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.node.get_context('compacts')), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=self.stack.node.get_context('jurisdictions'), + ), + 'licenseTypeAbbreviation': JsonSchema(type=JsonSchemaType.STRING), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING), + 'actionAgainst': JsonSchema(type=JsonSchemaType.STRING), + 'effectiveStartDate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'creationDate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'adverseActionId': JsonSchema(type=JsonSchemaType.STRING), + 'effectiveLiftDate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'encumbranceType': JsonSchema(type=JsonSchemaType.STRING), + 'clinicalPrivilegeActionCategories': self._clinical_privilege_action_categories_schema, # noqa: E501 + 'liftingUser': JsonSchema(type=JsonSchemaType.STRING), + }, + ), + ) + + @property + def _update_type_schema(self) -> JsonSchema: + return JsonSchema( + type=JsonSchemaType.STRING, + enum=[ + 'deactivation', + 'expiration', + 'issuance', + 'other', + 'renewal', + 'encumbrance', + 'lifting_encumbrance', + # this is specific to privileges that are deactivated due to a state license deactivation, + 'licenseDeactivation', + ], + ) + + @property + def _clinical_privilege_action_categories_schema(self) -> JsonSchema: + return JsonSchema( + type=JsonSchemaType.ARRAY, + description='The categories of clinical privilege action', + items=JsonSchema( + type=JsonSchemaType.STRING, + enum=['fraud', 'consumer harm', 'other'], + ), + ) + + @property + def _encumbrance_request_schema(self) -> JsonSchema: + """Common schema for encumbrance request data used in both POST and PATCH investigation endpoints""" + return JsonSchema( + type=JsonSchemaType.OBJECT, + description='Encumbrance data to create', + additional_properties=False, + required=['encumbranceEffectiveDate', 'encumbranceType', 'clinicalPrivilegeActionCategories'], + properties={ + 'encumbranceEffectiveDate': JsonSchema( + type=JsonSchemaType.STRING, + description='The effective date of the encumbrance', + format='date', + pattern=compact_connect_api.YMD_FORMAT, + ), + 'encumbranceType': self._encumbrance_type_schema, + 'clinicalPrivilegeActionCategories': self._clinical_privilege_action_categories_schema, + }, + ) + + @property + def _encumbrance_type_schema(self) -> JsonSchema: + """Common schema for encumbrance type field""" + return JsonSchema( + type=JsonSchemaType.STRING, + description='The type of encumbrance', + enum=[ + 'suspension', + 'revocation', + 'surrender of license', + ], + ) + + @property + def _investigation_schema(self) -> JsonSchema: + """Common schema for investigation objects""" + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'compact', + 'providerId', + 'investigationId', + 'jurisdiction', + 'licenseType', + 'dateOfUpdate', + 'creationDate', + 'submittingUser', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['investigation']), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.node.get_context('compacts')), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'investigationId': JsonSchema(type=JsonSchemaType.STRING), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=self.stack.node.get_context('jurisdictions'), + ), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'creationDate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'submittingUser': JsonSchema(type=JsonSchemaType.STRING), + }, + ) + + @property + def _common_license_properties(self) -> dict: + return { + 'licenseNumber': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'middleName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'dateOfBirth': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'homeAddressStreet1': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressStreet2': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'homeAddressCity': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressState': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressPostalCode': JsonSchema(type=JsonSchemaType.STRING, min_length=5, max_length=7), + 'dateOfIssuance': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfRenewal': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'licenseStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'licenseStatusName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'compactEligibility': JsonSchema(type=JsonSchemaType.STRING, enum=['eligible', 'ineligible']), + 'emailAddress': JsonSchema(type=JsonSchemaType.STRING, format='email', min_length=5, max_length=100), + 'phoneNumber': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.PHONE_NUMBER_FORMAT), + 'suffix': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + } + + @property + def _common_provider_properties(self) -> dict: + return { + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['provider']), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'ssnLastFour': JsonSchema(type=JsonSchemaType.STRING, pattern='^[0-9]{4}$'), + 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'middleName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'suffix': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'licenseStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'compactEligibility': JsonSchema(type=JsonSchemaType.STRING, enum=['eligible', 'ineligible']), + 'jurisdictionUploadedLicenseStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'jurisdictionUploadedCompactEligibility': JsonSchema( + type=JsonSchemaType.STRING, enum=['eligible', 'ineligible'] + ), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.node.get_context('compacts')), + 'birthMonthDay': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.MD_FORMAT + ), + 'dateOfBirth': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'licenseJurisdiction': JsonSchema( + type=JsonSchemaType.STRING, enum=self.stack.node.get_context('jurisdictions') + ), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + } + + @property + def _common_privilege_properties(self) -> dict: + return { + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['privilege']), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.node.get_context('compacts')), + 'jurisdiction': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.node.get_context('jurisdictions')), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'compactTransactionId': JsonSchema(type=JsonSchemaType.STRING), + 'licenseJurisdiction': JsonSchema( + type=JsonSchemaType.STRING, enum=self.stack.node.get_context('jurisdictions') + ), + 'administratorSetStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'status': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + } + + @property + def _sorting_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + description='How to sort results', + required=['key'], + properties={ + 'key': JsonSchema( + type=JsonSchemaType.STRING, + description='The key to sort results by', + enum=['dateOfUpdate', 'familyName'], + ), + 'direction': JsonSchema( + type=JsonSchemaType.STRING, + description='Direction to sort results by', + enum=['ascending', 'descending'], + ), + }, + ) + + @property + def _pagination_request_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={ + 'lastKey': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=1024), + 'pageSize': JsonSchema(type=JsonSchemaType.INTEGER, minimum=5, maximum=100), + }, + ) + + @property + def _pagination_response_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'lastKey': JsonSchema(type=[JsonSchemaType.STRING, JsonSchemaType.NULL], min_length=1, max_length=1024), + 'prevLastKey': JsonSchema( + type=[JsonSchemaType.STRING, JsonSchemaType.NULL], + min_length=1, + max_length=1024, + ), + 'pageSize': JsonSchema(type=JsonSchemaType.INTEGER, minimum=5, maximum=100), + }, + ) + + @property + def get_compact_jurisdictions_response_model(self) -> Model: + """Return the compact jurisdictions response model, which should only be created once per API""" + if hasattr(self.api, '_v1_get_compact_jurisdictions_response_model'): + return self.api._v1_get_compact_jurisdictions_response_model + + self.api._v1_get_compact_jurisdictions_response_model = self.api.add_model( + 'V1GetCompactJurisdictionsResponseModel', + description='Get compact jurisdictions response model', + schema=JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'compact', + 'jurisdictionName', + 'postalAbbreviation', + ], + properties={ + 'compact': JsonSchema(type=JsonSchemaType.STRING), + 'jurisdictionName': JsonSchema( + type=JsonSchemaType.STRING, + description='The name of the jurisdiction', + ), + 'postalAbbreviation': JsonSchema( + type=JsonSchemaType.STRING, + description='The postal abbreviation of the jurisdiction', + ), + }, + ), + ), + ) + + return self.api._v1_get_compact_jurisdictions_response_model + + @property + def get_compact_configuration_response_model(self) -> Model: + """Return the compact configuration response model for GET /v1/compacts/{compact}""" + if hasattr(self.api, '_v1_get_compact_configuration_response_model'): + return self.api._v1_get_compact_configuration_response_model + + self.api._v1_get_compact_configuration_response_model = self.api.add_model( + 'V1GetCompactConfigurationResponseModel', + description='Get compact configuration response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'compactAbbr', + 'compactName', + 'compactOperationsTeamEmails', + 'compactAdverseActionsNotificationEmails', + 'licenseeRegistrationEnabled', + 'configuredStates', + ], + properties={ + 'compactAbbr': JsonSchema( + type=JsonSchemaType.STRING, description='The abbreviation of the compact' + ), + 'compactName': JsonSchema(type=JsonSchemaType.STRING, description='The full name of the compact'), + 'compactOperationsTeamEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for operations team notifications', + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'compactAdverseActionsNotificationEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for adverse actions notifications', + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'licenseeRegistrationEnabled': JsonSchema( + type=JsonSchemaType.BOOLEAN, + description='Denotes whether licensee registration is enabled', + ), + 'configuredStates': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of states that have submitted configurations and their live status', + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['postalAbbreviation', 'isLive'], + properties={ + 'postalAbbreviation': JsonSchema( + type=JsonSchemaType.STRING, + description='The postal abbreviation of the jurisdiction', + enum=self.api.node.get_context('jurisdictions'), + ), + 'isLive': JsonSchema( + type=JsonSchemaType.BOOLEAN, + description='Whether the state is live and available for registrations.', + ), + }, + ), + ), + }, + ), + ) + return self.api._v1_get_compact_configuration_response_model + + @property + def put_compact_request_model(self) -> Model: + """Return the compact configuration request model for POST /v1/compacts/{compact}""" + if hasattr(self.api, '_v1_put_compact_request_model'): + return self.api._v1_put_compact_request_model + + self.api._v1_put_compact_request_model = self.api.add_model( + 'V1PutCompactRequestModel', + description='Put compact configuration request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + required=[ + 'compactOperationsTeamEmails', + 'compactAdverseActionsNotificationEmails', + 'licenseeRegistrationEnabled', + 'configuredStates', + ], + properties={ + 'compactOperationsTeamEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for operations team notifications', + min_items=1, + max_items=10, + unique_items=True, + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'compactAdverseActionsNotificationEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for adverse actions notifications', + min_items=1, + max_items=10, + unique_items=True, + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'licenseeRegistrationEnabled': JsonSchema( + type=JsonSchemaType.BOOLEAN, + description='Denotes whether licensee registration is enabled', + ), + 'configuredStates': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of states that have submitted configurations and their live status', + items=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + required=['postalAbbreviation', 'isLive'], + properties={ + 'postalAbbreviation': JsonSchema( + type=JsonSchemaType.STRING, + description='The postal abbreviation of the jurisdiction', + enum=self.api.node.get_context('jurisdictions'), + ), + 'isLive': JsonSchema( + type=JsonSchemaType.BOOLEAN, + description='Whether the state is live and available for registrations.', + ), + }, + ), + ), + }, + ), + ) + return self.api._v1_put_compact_request_model + + @property + def get_jurisdiction_response_model(self) -> Model: + """Return the jurisdiction configuration response model for + GET /v1/compacts/{compact}/jurisdictions/{jurisdiction} + """ + if hasattr(self.api, '_v1_get_jurisdiction_response_model'): + return self.api._v1_get_jurisdiction_response_model + + self.api._v1_get_jurisdiction_response_model = self.api.add_model( + 'V1GetJurisdictionResponseModel', + description='Get jurisdiction configuration response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'compact', + 'jurisdictionName', + 'postalAbbreviation', + 'jurisdictionOperationsTeamEmails', + 'jurisdictionAdverseActionsNotificationEmails', + 'licenseeRegistrationEnabled', + ], + properties={ + 'compact': JsonSchema( + type=JsonSchemaType.STRING, + description='The compact this jurisdiction configuration belongs to', + enum=self.stack.node.get_context('compacts'), + ), + 'jurisdictionName': JsonSchema( + type=JsonSchemaType.STRING, + description='The name of the jurisdiction', + ), + 'postalAbbreviation': JsonSchema( + type=JsonSchemaType.STRING, + description='The postal abbreviation of the jurisdiction', + ), + 'jurisdictionOperationsTeamEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for operations team notifications', + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'jurisdictionAdverseActionsNotificationEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for adverse actions notifications', + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'licenseeRegistrationEnabled': JsonSchema( + type=JsonSchemaType.BOOLEAN, + description='Denotes whether licensee registration is enabled', + ), + }, + ), + ) + return self.api._v1_get_jurisdiction_response_model + + @property + def put_jurisdiction_request_model(self) -> Model: + """Return the jurisdiction configuration request model for + POST /v1/compacts/{compact}/jurisdictions/{jurisdiction} + """ + if hasattr(self.api, '_v1_put_jurisdiction_request_model'): + return self.api._v1_put_jurisdiction_request_model + + self.api._v1_put_jurisdiction_request_model = self.api.add_model( + 'V1PutJurisdictionRequestModel', + description='Put jurisdiction configuration request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + required=[ + 'jurisdictionOperationsTeamEmails', + 'jurisdictionAdverseActionsNotificationEmails', + 'licenseeRegistrationEnabled', + ], + properties={ + 'jurisdictionOperationsTeamEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for operations team notifications', + min_items=1, + max_items=10, + unique_items=True, + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'jurisdictionAdverseActionsNotificationEmails': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of email addresses for adverse actions notifications', + min_items=1, + max_items=10, + unique_items=True, + items=JsonSchema(type=JsonSchemaType.STRING, format='email'), + ), + 'licenseeRegistrationEnabled': JsonSchema( + type=JsonSchemaType.BOOLEAN, + description='Denotes whether licensee registration is enabled', + ), + }, + ), + ) + return self.api._v1_put_jurisdiction_request_model + + @property + def public_query_providers_response_model(self) -> Model: + """Return the public query providers response model, which should only be created once per API""" + if hasattr(self.api, '_v1_public_query_providers_response_model'): + return self.api._v1_public_query_providers_response_model + + self.api._v1_public_query_providers_response_model = self.api.add_model( + 'V1PublicQueryProvidersResponseModel', + description='Public query providers response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['providers', 'pagination'], + properties={ + 'providers': JsonSchema( + type=JsonSchemaType.ARRAY, + max_length=100, + items=self._public_license_search_response_schema, + ), + 'pagination': self._pagination_response_schema, + 'query': JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'providerId': JsonSchema( + type=JsonSchemaType.STRING, + description='Internal UUID for the provider', + pattern=compact_connect_api.UUID4_FORMAT, + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + description='Filter for providers with privilege/license in a jurisdiction', + enum=self.api.node.get_context('jurisdictions'), + ), + 'givenName': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + description='Filter for providers with a given name', + ), + 'familyName': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + description='Filter for providers with a family name', + ), + 'licenseNumber': JsonSchema( + type=JsonSchemaType.STRING, + min_length=1, + max_length=100, + description='Filter for licenses with a specific license number', + ), + }, + ), + 'sorting': self._sorting_schema, + }, + ), + ) + return self.api._v1_public_query_providers_response_model + + @property + def public_provider_response_model(self) -> Model: + """Return the public provider response model, which should only be created once per API""" + if hasattr(self.api, '_v1_public_provider_response_model'): + return self.api._v1_public_provider_response_model + + self.api._v1_public_provider_response_model = self.api.add_model( + 'V1PublicProviderResponseModel', + description='Public provider response model', + schema=self._public_provider_detailed_response_schema, + ) + return self.api._v1_public_provider_response_model + + @property + def get_live_jurisdictions_model(self) -> Model: + """Return the get live jurisdictions response model, which should only be created once per API""" + if hasattr(self.api, '_v1_get_live_jurisdictions_response_model'): + return self.api._v1_get_live_jurisdictions_response_model + + # Shape: { "": ["ky", "oh", ...], ... } + # Keys are dynamic compact abbreviations; values are arrays of jurisdiction abbreviations + self.api._v1_get_live_jurisdictions_response_model = self.api.add_model( + 'V1GetLiveJurisdictionsResponseModel', + description='Dictionary keyed by compact abbreviations with arrays of live jurisdiction abbreviations', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.STRING, + enum=self.api.node.get_context('jurisdictions'), + ), + ), + ), + ) + return self.api._v1_get_live_jurisdictions_response_model + + @property + def _public_provider_detailed_response_schema(self): + """Schema for public provider responses based on ProviderPublicResponseSchema""" + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'dateOfUpdate', + 'compact', + 'licenseJurisdiction', + 'givenName', + 'familyName', + ], + properties={ + 'licenses': JsonSchema( + type=JsonSchemaType.ARRAY, + description='Sanitized home-state license rows (LicensePublicResponseSchema)', + items=self._public_license_public_response_schema, + ), + 'privileges': JsonSchema( + type=JsonSchemaType.ARRAY, + items=self._public_privilege_response_schema, + ), + **self._common_public_provider_properties, + }, + ) + + @property + def _public_license_public_response_schema(self): + """License items in public GET provider responses; mirrors LicensePublicResponseSchema.""" + stack: AppStack = AppStack.of(self.api) + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'compact', + 'jurisdiction', + 'licenseType', + 'licenseStatus', + 'compactEligibility', + 'dateOfExpiration', + 'licenseNumber', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['license']), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=stack.node.get_context('jurisdictions'), + ), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.license_type_names), + 'licenseStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'compactEligibility': JsonSchema(type=JsonSchemaType.STRING, enum=['eligible', 'ineligible']), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'licenseNumber': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + }, + ) + + @property + def _public_privilege_response_schema(self): + """Schema for public privilege responses""" + stack: AppStack = AppStack.of(self.api) + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'compact', + 'jurisdiction', + 'licenseJurisdiction', + 'licenseType', + 'dateOfExpiration', + 'administratorSetStatus', + 'status', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['privilege']), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=stack.node.get_context('jurisdictions'), + ), + 'licenseJurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=stack.node.get_context('jurisdictions'), + ), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.license_type_names), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'administratorSetStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'status': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + }, + ) + + @property + def provider_registration_request_model(self) -> Model: + """Return the provider registration request model, which should only be created once per API""" + if hasattr(self.api, '_v1_provider_registration_request_model'): + return self.api._v1_provider_registration_request_model + + self.api._v1_provider_registration_request_model = self.api.add_model( + 'V1ProviderRegistrationRequestModel', + description='Provider registration request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'givenName', + 'familyName', + 'email', + 'partialSocial', + 'dob', + 'jurisdiction', + 'licenseType', + 'compact', + 'token', + ], + properties={ + 'givenName': JsonSchema( + type=JsonSchemaType.STRING, + description="Provider's given name", + max_length=200, + ), + 'familyName': JsonSchema( + type=JsonSchemaType.STRING, + description="Provider's family name", + max_length=200, + ), + 'email': JsonSchema( + type=JsonSchemaType.STRING, + description="Provider's email address", + format='email', + min_length=5, + max_length=100, + ), + 'partialSocial': JsonSchema( + type=JsonSchemaType.STRING, + description='Last 4 digits of SSN', + min_length=4, + max_length=4, + ), + 'dob': JsonSchema( + type=JsonSchemaType.STRING, + description='Date of birth in YYYY-MM-DD format', + pattern=compact_connect_api.YMD_FORMAT, + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + description='Two-letter jurisdiction code', + min_length=2, + max_length=2, + enum=self.api.node.get_context('jurisdictions'), + ), + 'licenseType': JsonSchema( + type=JsonSchemaType.STRING, + description='Type of license', + max_length=500, + enum=self.stack.license_type_names, + ), + 'compact': JsonSchema( + type=JsonSchemaType.STRING, + description='Compact name', + # note that here we do not specify the enum with the list of compacts + # this is intentional as we do not want the api to return this list + # from the registration endpoint. + max_length=100, + ), + 'token': JsonSchema( + type=JsonSchemaType.STRING, + description='ReCAPTCHA token', + ), + }, + ), + ) + return self.api._v1_provider_registration_request_model + + @property + def _public_license_search_response_schema(self): + """Schema for public query providers response""" + stack: AppStack = AppStack.of(self.api) + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'providerId', + 'givenName', + 'familyName', + 'licenseJurisdiction', + 'compact', + 'licenseType', + 'licenseNumber', + 'licenseEligibility', + ], + properties={ + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'licenseJurisdiction': JsonSchema( + type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions') + ), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'licenseType': JsonSchema( + type=JsonSchemaType.STRING, + description='License type or profession designation for this license row', + ), + 'licenseNumber': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'licenseEligibility': JsonSchema( + type=JsonSchemaType.STRING, + description='Whether the license is eligible for compact participation in public search results', + enum=['eligible', 'ineligible'], + ), + }, + ) + + @property + def _public_providers_response_schema(self): + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'givenName', + 'familyName', + 'compact', + 'licenseJurisdiction', + ], + properties=self._common_public_provider_properties, + ) + + @property + def _common_public_provider_properties(self) -> dict: + stack: AppStack = AppStack.of(self.api) + + return { + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['provider']), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'middleName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'suffix': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'licenseJurisdiction': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions')), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + } + + @property + def check_feature_flag_request_model(self) -> Model: + """Request model for POST /v1/flags/check""" + if hasattr(self.api, '_v1_check_feature_flag_request_model'): + return self.api._v1_check_feature_flag_request_model + + self.api._v1_check_feature_flag_request_model = self.api.add_model( + 'V1CheckFeatureFlagRequestModel', + description='Check feature flag request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + properties={ + 'context': JsonSchema( + type=JsonSchemaType.OBJECT, + description='Optional context for feature flag evaluation', + additional_properties=False, + properties={ + 'userId': JsonSchema( + type=JsonSchemaType.STRING, + description='Optional user ID for feature flag evaluation', + min_length=1, + max_length=100, + ), + 'customAttributes': JsonSchema( + type=JsonSchemaType.OBJECT, + description='Optional custom attributes for feature flag evaluation', + additional_properties=JsonSchema(type=JsonSchemaType.STRING), + ), + }, + ), + }, + ), + ) + return self.api._v1_check_feature_flag_request_model + + @property + def check_feature_flag_response_model(self) -> Model: + """Response model for POST /v1/flags/check""" + if hasattr(self.api, '_v1_check_feature_flag_response_model'): + return self.api._v1_check_feature_flag_response_model + + self.api._v1_check_feature_flag_response_model = self.api.add_model( + 'V1CheckFeatureFlagResponseModel', + description='Check feature flag response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['enabled'], + properties={ + 'enabled': JsonSchema( + type=JsonSchemaType.BOOLEAN, + description='Whether the feature flag is enabled', + ), + }, + ), + ) + return self.api._v1_check_feature_flag_response_model + + @property + def post_privilege_investigation_request_model(self) -> Model: + """POST privilege investigation request model""" + if not hasattr(self.api, '_v1_post_privilege_investigation_request_model'): + self.api._v1_post_privilege_investigation_request_model = self.api.add_model( + 'V1PostPrivilegeInvestigationRequestModel', + description='Post privilege investigation request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={}, + ), + ) + return self.api._v1_post_privilege_investigation_request_model + + @property + def post_license_investigation_request_model(self) -> Model: + """POST license investigation request model""" + if not hasattr(self.api, '_v1_post_license_investigation_request_model'): + self.api._v1_post_license_investigation_request_model = self.api.add_model( + 'V1PostLicenseInvestigationRequestModel', + description='Post license investigation request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={}, + ), + ) + return self.api._v1_post_license_investigation_request_model + + @property + def patch_privilege_investigation_request_model(self) -> Model: + """PATCH privilege investigation request model""" + if not hasattr(self.api, '_v1_patch_privilege_investigation_request_model'): + self.api._v1_patch_privilege_investigation_request_model = self.api.add_model( + 'V1PatchPrivilegeInvestigationRequestModel', + description='Patch privilege investigation request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['action'], + properties={ + 'action': JsonSchema(type=JsonSchemaType.STRING, enum=['close']), + 'encumbrance': self._encumbrance_request_schema, + }, + ), + ) + return self.api._v1_patch_privilege_investigation_request_model + + @property + def patch_license_investigation_request_model(self) -> Model: + """PATCH license investigation request model""" + if not hasattr(self.api, '_v1_patch_license_investigation_request_model'): + self.api._v1_patch_license_investigation_request_model = self.api.add_model( + 'V1PatchLicenseInvestigationRequestModel', + description='Patch license investigation request model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['action'], + properties={ + 'action': JsonSchema(type=JsonSchemaType.STRING, enum=['close']), + 'encumbrance': self._encumbrance_request_schema, + }, + ), + ) + return self.api._v1_patch_license_investigation_request_model diff --git a/backend/social-work-app/stacks/api_stack/v1_api/bulk_upload_url.py b/backend/social-work-app/stacks/api_stack/v1_api/bulk_upload_url.py new file mode 100644 index 0000000000..291e6452eb --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/bulk_upload_url.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from aws_cdk import Duration +from aws_cdk.aws_apigateway import AuthorizationType, LambdaIntegration, MethodOptions, MethodResponse, Resource +from common_constructs.compact_connect_api import CompactConnectApi + +from stacks.api_lambda_stack import ApiLambdaStack + +from .api_model import ApiModel + + +class BulkUploadUrl: + def __init__( + self, + *, + resource: Resource, + method_options: MethodOptions, + api_model: ApiModel, + api_lambda_stack: ApiLambdaStack, + ): + super().__init__() + + self.resource = resource.add_resource('bulk-upload') + self.api: CompactConnectApi = resource.api + self.api_model = api_model + self._add_bulk_upload_url( + method_options=method_options, + api_lambda_stack=api_lambda_stack, + ) + + def _add_bulk_upload_url( + self, + *, + method_options: MethodOptions, + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.bulk_upload_url_lambdas.bulk_upload_url_handler + + self.resource.add_method( + 'GET', + request_validator=self.api.parameter_body_validator, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.bulk_upload_response_model}, + ), + ], + integration=LambdaIntegration( + handler, + timeout=Duration.seconds(29), + ), + request_parameters={'method.request.header.Authorization': True} + if method_options.authorization_type != AuthorizationType.NONE + else {}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/compact_configuration_api.py b/backend/social-work-app/stacks/api_stack/v1_api/compact_configuration_api.py new file mode 100644 index 0000000000..7009ad3400 --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/compact_configuration_api.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource +from cdk_nag import NagSuppressions +from common_constructs.compact_connect_api import CompactConnectApi +from common_constructs.python_function import PythonFunction + +from stacks.api_lambda_stack import ApiLambdaStack + +from .api_model import ApiModel + + +class CompactConfigurationApi: + """ + This class manages all the endpoints related to reading compact configuration data for both compacts and their + associated jurisdictions. + """ + + def __init__( + self, + *, + api: CompactConnectApi, + compact_resource: Resource, + live_jurisdictions_resource: Resource, + jurisdictions_resource: Resource, + public_jurisdictions_resource: Resource, + jurisdiction_resource: Resource, + general_read_method_options: MethodOptions, + admin_method_options: MethodOptions, + api_model: ApiModel, + api_lambda_stack: ApiLambdaStack, + ): + super().__init__() + + self.api = api + # /v1/compacts/{compact} + self.staff_users_compact_resource = compact_resource + # /v1/public/jurisdictions/live + self.live_jurisdictions_resource = live_jurisdictions_resource + # /v1/compacts/{compact}/jurisdictions + self.staff_users_jurisdictions_resource = jurisdictions_resource + # /v1/compacts/{compact}/jurisdictions/{jurisdiction} + self.staff_users_jurisdiction_resource = jurisdiction_resource + # /v1/public/compacts/{compact}/jurisdictions + self.public_jurisdictions_resource = public_jurisdictions_resource + self.api_model = api_model + + # Create the compact configration api lambda function that will be shared by all compact configuration + # related endpoints + compact_configuration_api_function = ( + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler + ) + + self._add_staff_users_get_compact_jurisdictions_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + general_read_method_options=general_read_method_options, + ) + + self._add_public_get_compact_jurisdictions_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + ) + + self._add_get_live_jurisdictions_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + ) + + self._add_staff_users_get_compact_configuration_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + general_read_method_options=general_read_method_options, + ) + + self._add_staff_users_put_compact_configuration_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + admin_method_options=admin_method_options, + ) + + self._add_staff_users_get_jurisdiction_configuration_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + general_read_method_options=general_read_method_options, + ) + + self._add_staff_users_put_jurisdiction_configuration_endpoint( + compact_configuration_api_handler=compact_configuration_api_function, + admin_method_options=admin_method_options, + ) + + def _add_staff_users_get_compact_jurisdictions_endpoint( + self, compact_configuration_api_handler: PythonFunction, general_read_method_options: MethodOptions + ): + self.staff_users_jurisdictions_resource.add_method( + 'GET', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_compact_jurisdictions_response_model}, + ), + ], + request_parameters={'method.request.header.Authorization': True}, + authorization_type=general_read_method_options.authorization_type, + authorizer=general_read_method_options.authorizer, + authorization_scopes=general_read_method_options.authorization_scopes, + ) + + def _add_public_get_compact_jurisdictions_endpoint(self, compact_configuration_api_handler: PythonFunction): + public_get_compact_jurisdictions_method = self.public_jurisdictions_resource.add_method( + 'GET', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_compact_jurisdictions_response_model}, + ), + ], + ) + + # Add suppressions for the public GET endpoint + NagSuppressions.add_resource_suppressions( + public_get_compact_jurisdictions_method, + suppressions=[ + { + 'id': 'AwsSolutions-APIG4', + 'reason': 'This is a public endpoint that intentionally does not require authorization', + }, + { + 'id': 'AwsSolutions-COG4', + 'reason': 'This is a public endpoint that intentionally ' + 'does not use a Cognito user pool authorizer', + }, + ], + ) + + def _add_get_live_jurisdictions_endpoint(self, compact_configuration_api_handler: PythonFunction): + """Add GET endpoint for /v1/public/jurisdictions/live""" + get_live_compact_jurisdictions_method = self.live_jurisdictions_resource.add_method( + 'GET', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_live_jurisdictions_model}, + ), + ], + request_parameters={ + 'method.request.querystring.compact': False, + }, + ) + + # Add suppressions for the public GET endpoint + NagSuppressions.add_resource_suppressions( + get_live_compact_jurisdictions_method, + suppressions=[ + { + 'id': 'AwsSolutions-APIG4', + 'reason': 'This is a public endpoint that intentionally does not require authorization', + }, + { + 'id': 'AwsSolutions-COG4', + 'reason': 'This is a public endpoint that intentionally ' + 'does not use a Cognito user pool authorizer', + }, + ], + ) + + def _add_staff_users_get_compact_configuration_endpoint( + self, compact_configuration_api_handler: PythonFunction, general_read_method_options: MethodOptions + ): + """Add GET endpoint for /v1/compacts/{compact}""" + self.staff_users_compact_resource.add_method( + 'GET', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_compact_configuration_response_model}, + ), + ], + request_parameters={'method.request.header.Authorization': True}, + authorization_type=general_read_method_options.authorization_type, + authorizer=general_read_method_options.authorizer, + authorization_scopes=general_read_method_options.authorization_scopes, + ) + + def _add_staff_users_put_compact_configuration_endpoint( + self, compact_configuration_api_handler: PythonFunction, admin_method_options: MethodOptions + ): + """Add PUT endpoint for /v1/compacts/{compact}""" + self.staff_users_compact_resource.add_method( + 'PUT', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + request_parameters={'method.request.header.Authorization': True}, + request_models={'application/json': self.api_model.put_compact_request_model}, + authorization_type=admin_method_options.authorization_type, + authorizer=admin_method_options.authorizer, + authorization_scopes=admin_method_options.authorization_scopes, + ) + + def _add_staff_users_get_jurisdiction_configuration_endpoint( + self, compact_configuration_api_handler: PythonFunction, general_read_method_options: MethodOptions + ): + """Add GET endpoint for /v1/compacts/{compact}/jurisdictions/{jurisdiction}""" + self.staff_users_jurisdiction_resource.add_method( + 'GET', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_jurisdiction_response_model}, + ), + ], + request_parameters={'method.request.header.Authorization': True}, + authorization_type=general_read_method_options.authorization_type, + authorizer=general_read_method_options.authorizer, + authorization_scopes=general_read_method_options.authorization_scopes, + ) + + def _add_staff_users_put_jurisdiction_configuration_endpoint( + self, compact_configuration_api_handler: PythonFunction, admin_method_options: MethodOptions + ): + """Add PUT endpoint for /v1/compacts/{compact}/jurisdictions/{jurisdiction}""" + self.staff_users_jurisdiction_resource.add_method( + 'PUT', + LambdaIntegration(compact_configuration_api_handler), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + request_parameters={'method.request.header.Authorization': True}, + request_models={'application/json': self.api_model.put_jurisdiction_request_model}, + authorization_type=admin_method_options.authorization_type, + authorizer=admin_method_options.authorizer, + authorization_scopes=admin_method_options.authorization_scopes, + ) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/feature_flags.py b/backend/social-work-app/stacks/api_stack/v1_api/feature_flags.py new file mode 100644 index 0000000000..ba810aa309 --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/feature_flags.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from aws_cdk.aws_apigateway import LambdaIntegration, Resource +from cdk_nag import NagSuppressions +from common_constructs.compact_connect_api import CompactConnectApi + +from stacks.api_lambda_stack import ApiLambdaStack + +from .api_model import ApiModel + + +class FeatureFlagsApi: + """Feature flags API endpoints""" + + def __init__( + self, + *, + resource: Resource, + api_model: ApiModel, + api_lambda_stack: ApiLambdaStack, + ): + super().__init__() + self.resource = resource + self.api: CompactConnectApi = resource.api + self.api_model = api_model + + # POST /v1/flags/{flagId}/check + flag_id_resource = resource.add_resource('{flagId}') + check_resource = flag_id_resource.add_resource('check') + self.check_flag_method = check_resource.add_method( + 'POST', + integration=LambdaIntegration(api_lambda_stack.feature_flags_lambdas.check_feature_flag_function), + request_models={'application/json': api_model.check_feature_flag_request_model}, + method_responses=[ + { + 'statusCode': '200', + 'responseModels': {'application/json': api_model.check_feature_flag_response_model}, + }, + ], + ) + + # Add suppressions for the public GET endpoint + NagSuppressions.add_resource_suppressions( + self.check_flag_method, + suppressions=[ + { + 'id': 'AwsSolutions-APIG4', + 'reason': 'This is a public endpoint that intentionally does not require authorization', + }, + { + 'id': 'AwsSolutions-COG4', + 'reason': 'This is a public endpoint that intentionally ' + 'does not use a Cognito user pool authorizer', + }, + ], + ) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/provider_management.py b/backend/social-work-app/stacks/api_stack/v1_api/provider_management.py new file mode 100644 index 0000000000..967880e29c --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/provider_management.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +from aws_cdk import Duration +from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource +from common_constructs.compact_connect_api import CompactConnectApi +from common_constructs.python_function import PythonFunction + +from stacks.api_lambda_stack import ApiLambdaStack + +from .api_model import ApiModel + + +class ProviderManagement: + """ + These endpoints are used by staff users to view and manage provider records + """ + + def __init__( + self, + *, + resource: Resource, + method_options: MethodOptions, + admin_method_options: MethodOptions, + api_model: ApiModel, + api_lambda_stack: ApiLambdaStack, + ): + super().__init__() + + self.resource = resource + self.api: CompactConnectApi = resource.api + self.api_model = api_model + + # Create the nested resources used by endpoints + self.provider_resource = self.resource.add_resource('{providerId}') + self.privileges_resource = self.provider_resource.add_resource('privileges') + self.privilege_jurisdiction_resource = self.privileges_resource.add_resource('jurisdiction').add_resource( + '{jurisdiction}' + ) + self.privilege_jurisdiction_license_type_resource = self.privilege_jurisdiction_resource.add_resource( + 'licenseType' + ).add_resource('{licenseType}') + + self.licenses_resource = self.provider_resource.add_resource('licenses') + self.license_jurisdiction_resource = self.licenses_resource.add_resource('jurisdiction').add_resource( + '{jurisdiction}' + ) + self.license_jurisdiction_license_type_resource = self.license_jurisdiction_resource.add_resource( + 'licenseType' + ).add_resource('{licenseType}') + + self._add_query_providers( + method_options=method_options, + query_providers_handler=api_lambda_stack.provider_management_lambdas.query_providers_handler, + ) + self._add_get_provider( + method_options=method_options, + get_provider_handler=api_lambda_stack.provider_management_lambdas.get_provider_handler, + ) + + self._add_encumber_privilege( + method_options=admin_method_options, + provider_encumbrance_handler=api_lambda_stack.provider_management_lambdas.provider_encumbrance_handler, + ) + + self._add_encumber_license( + method_options=admin_method_options, + provider_encumbrance_handler=api_lambda_stack.provider_management_lambdas.provider_encumbrance_handler, + ) + + self._add_investigation_privilege( + method_options=admin_method_options, + investigation_handler=api_lambda_stack.provider_management_lambdas.provider_investigation_handler, + ) + + self._add_investigation_license( + method_options=admin_method_options, + investigation_handler=api_lambda_stack.provider_management_lambdas.provider_investigation_handler, + ) + + def _add_get_provider( + self, + method_options: MethodOptions, + get_provider_handler: PythonFunction, + ): + self.provider_resource.add_method( + 'GET', + request_validator=self.api.parameter_body_validator, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.provider_response_model}, + ), + ], + integration=LambdaIntegration(get_provider_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + def _add_query_providers( + self, + method_options: MethodOptions, + query_providers_handler: PythonFunction, + ): + query_resource = self.resource.add_resource('query') + + query_resource.add_method( + 'POST', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.query_providers_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.query_providers_response_model}, + ), + ], + integration=LambdaIntegration(query_providers_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + def _add_encumber_privilege( + self, + method_options: MethodOptions, + provider_encumbrance_handler: PythonFunction, + ): + """Add POST /providers/{providerId}/privileges/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/encumbrance endpoint.""" + self.encumbrance_privilege_resource = self.privilege_jurisdiction_license_type_resource.add_resource( + 'encumbrance' + ) + self.encumbrance_privilege_resource.add_method( + 'POST', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.post_privilege_encumbrance_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(provider_encumbrance_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + # Add PATCH method for lifting privilege encumbrances - now with encumbranceId in path + self.encumbrance_privilege_id_resource = self.encumbrance_privilege_resource.add_resource('{encumbranceId}') + self.encumbrance_privilege_id_resource.add_method( + 'PATCH', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.patch_privilege_encumbrance_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(provider_encumbrance_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + def _add_encumber_license( + self, + method_options: MethodOptions, + provider_encumbrance_handler: PythonFunction, + ): + """Add POST /providers/{providerId}/licenses/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/encumbrance endpoint.""" + self.encumbrance_license_resource = self.license_jurisdiction_license_type_resource.add_resource('encumbrance') + self.encumbrance_license_resource.add_method( + 'POST', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.post_license_encumbrance_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(provider_encumbrance_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + # Add PATCH method for lifting license encumbrances - now with encumbranceId in path + self.encumbrance_license_id_resource = self.encumbrance_license_resource.add_resource('{encumbranceId}') + self.encumbrance_license_id_resource.add_method( + 'PATCH', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.patch_license_encumbrance_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(provider_encumbrance_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + def _add_investigation_privilege( + self, + method_options: MethodOptions, + investigation_handler: PythonFunction, + ): + """Add POST /providers/{providerId}/privileges/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/investigation endpoint.""" + self.investigation_privilege_resource = self.privilege_jurisdiction_license_type_resource.add_resource( + 'investigation' + ) + self.investigation_privilege_resource.add_method( + 'POST', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.post_privilege_investigation_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(investigation_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + # Add PATCH method for closing privilege investigations - now with investigationId in path + self.investigation_privilege_id_resource = self.investigation_privilege_resource.add_resource( + '{investigationId}' + ) + self.investigation_privilege_id_resource.add_method( + 'PATCH', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.patch_privilege_investigation_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(investigation_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + def _add_investigation_license( + self, + method_options: MethodOptions, + investigation_handler: PythonFunction, + ): + """Add POST /providers/{providerId}/licenses/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/investigation endpoint.""" + self.investigation_license_resource = self.license_jurisdiction_license_type_resource.add_resource( + 'investigation' + ) + self.investigation_license_resource.add_method( + 'POST', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.post_license_investigation_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(investigation_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + # Add PATCH method for closing license investigations - now with investigationId in path + self.investigation_license_id_resource = self.investigation_license_resource.add_resource('{investigationId}') + self.investigation_license_id_resource.add_method( + 'PATCH', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.patch_license_investigation_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.message_response_model}, + ), + ], + integration=LambdaIntegration(investigation_handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/public_lookup_api.py b/backend/social-work-app/stacks/api_stack/v1_api/public_lookup_api.py new file mode 100644 index 0000000000..a0fe63568b --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/public_lookup_api.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from aws_cdk import Duration +from aws_cdk.aws_apigateway import LambdaIntegration, MethodResponse, Resource +from cdk_nag import NagSuppressions +from common_constructs.compact_connect_api import CompactConnectApi + +from stacks import search_persistent_stack as sps +from stacks.api_lambda_stack import ApiLambdaStack + +from .api_model import ApiModel + + +class PublicLookupApi: + def __init__( + self, + *, + resource: Resource, + api_model: ApiModel, + api_lambda_stack: ApiLambdaStack, + search_persistent_stack: sps.SearchPersistentStack, + ): + super().__init__() + + self.resource = resource + self.api: CompactConnectApi = resource.api + self.api_model = api_model + + self.provider_resource = self.resource.add_resource('{providerId}') + self.provider_jurisdiction_resource = self.provider_resource.add_resource('jurisdiction').add_resource( + '{jurisdiction}' + ) + self.provider_jurisdiction_license_type_resource = self.provider_jurisdiction_resource.add_resource( + 'licenseType' + ).add_resource('{licenseType}') + + self._add_public_query_providers(search_persistent_stack=search_persistent_stack) + self._add_public_get_provider( + api_lambda_stack=api_lambda_stack, + ) + + def _add_public_get_provider( + self, + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.public_lookup_lambdas.get_provider_handler + + public_get_provider_method = self.provider_resource.add_method( + 'GET', + request_validator=self.api.parameter_body_validator, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.public_provider_response_model}, + ), + ], + integration=LambdaIntegration(handler, timeout=Duration.seconds(29)), + ) + + # Add suppressions for the public GET endpoint + NagSuppressions.add_resource_suppressions( + public_get_provider_method, + suppressions=[ + { + 'id': 'AwsSolutions-APIG4', + 'reason': 'This is a public endpoint that intentionally does not require authorization', + }, + { + 'id': 'AwsSolutions-COG4', + 'reason': 'This is a public endpoint that intentionally ' + 'does not use a Cognito user pool authorizer', + }, + ], + ) + + def _add_public_query_providers(self, search_persistent_stack: sps.SearchPersistentStack): + query_resource = self.resource.add_resource('query') + + handler = search_persistent_stack.search_handler.public_handler + + public_query_provider_method = query_resource.add_method( + 'POST', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.query_providers_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.public_query_providers_response_model}, + ), + ], + integration=LambdaIntegration(handler, timeout=Duration.seconds(29)), + ) + + # Add suppressions for the public POST endpoint + NagSuppressions.add_resource_suppressions( + public_query_provider_method, + suppressions=[ + { + 'id': 'AwsSolutions-APIG4', + 'reason': 'This is a public endpoint that intentionally does not require authorization', + }, + { + 'id': 'AwsSolutions-COG4', + 'reason': 'This is a public endpoint that intentionally ' + 'does not use a Cognito user pool authorizer', + }, + ], + ) diff --git a/backend/social-work-app/stacks/api_stack/v1_api/staff_users.py b/backend/social-work-app/stacks/api_stack/v1_api/staff_users.py new file mode 100644 index 0000000000..564f217217 --- /dev/null +++ b/backend/social-work-app/stacks/api_stack/v1_api/staff_users.py @@ -0,0 +1,303 @@ +from __future__ import annotations + +from aws_cdk.aws_apigateway import ( + AuthorizationType, + LambdaIntegration, + MethodResponse, + Resource, +) +from common_constructs.compact_connect_api import CompactConnectApi + +from stacks import persistent_stack as ps +from stacks.api_lambda_stack import ApiLambdaStack + +from .api_model import ApiModel + + +class StaffUsers: + def __init__( + self, + *, + admin_resource: Resource, + self_resource: Resource, + admin_scopes: list[str], + api_lambda_stack: ApiLambdaStack, + api_model: ApiModel, + ): + super().__init__() + + self.stack: ps.PersistentStack = ps.PersistentStack.of(admin_resource) + self.admin_resource = admin_resource + self.api: CompactConnectApi = admin_resource.api + self.api_model = api_model + + # / + self._add_get_users( + self.admin_resource, + admin_scopes, + api_lambda_stack=api_lambda_stack, + ) + self._add_post_user(self.admin_resource, admin_scopes, api_lambda_stack=api_lambda_stack) + + self.user_id_resource = self.admin_resource.add_resource('{userId}') + # /{userId} + self._add_get_user(self.user_id_resource, admin_scopes, api_lambda_stack=api_lambda_stack) + self._add_patch_user(self.user_id_resource, admin_scopes, api_lambda_stack=api_lambda_stack) + self._add_delete_user(self.user_id_resource, admin_scopes, api_lambda_stack=api_lambda_stack) + + # /{userId}/reinvite + self.reinvite_resource = self.user_id_resource.add_resource('reinvite') + self._add_reinvite_user(self.reinvite_resource, admin_scopes, api_lambda_stack=api_lambda_stack) + + self.me_resource = self_resource.add_resource('me') + # /me + profile_scopes = ['profile'] + self._add_get_me(self.me_resource, profile_scopes, api_lambda_stack=api_lambda_stack) + self._add_patch_me(self.me_resource, profile_scopes, api_lambda_stack=api_lambda_stack) + + def _add_get_me( + self, + me_resource: Resource, + scopes: list[str], + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.staff_users_lambdas.get_me_handler + + # Add the GET method to the me_resource + me_resource.add_method( + 'GET', + integration=LambdaIntegration(handler), + request_validator=self.api.parameter_body_validator, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_staff_user_me_model}, + response_parameters={'method.response.header.Access-Control-Allow-Origin': True}, + ), + MethodResponse( + status_code='404', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + ], + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + ) + + def _add_patch_me( + self, + me_resource: Resource, + scopes: list[str], + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.staff_users_lambdas.patch_me_handler + + # Add the PATCH method to the me_resource + me_resource.add_method( + 'PATCH', + integration=LambdaIntegration(handler), + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.patch_staff_user_me_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_staff_user_me_model}, + response_parameters={'method.response.header.Access-Control-Allow-Origin': True}, + ), + MethodResponse( + status_code='404', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + ], + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + ) + + def _add_get_users( + self, + users_resource: Resource, + scopes: list[str], + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.staff_users_lambdas.get_users_handler + + # Add the GET method to the users resource + users_resource.add_method( + 'GET', + integration=LambdaIntegration(handler), + request_validator=self.api.parameter_body_validator, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_staff_users_response_model}, + response_parameters={'method.response.header.Access-Control-Allow-Origin': True}, + ), + ], + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + ) + + def _add_get_user( + self, + user_id_resource: Resource, + scopes: list[str], + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.staff_users_lambdas.get_user_handler + + # Add the GET method to the user_id resource + user_id_resource.add_method( + 'GET', + integration=LambdaIntegration(handler), + request_validator=self.api.parameter_body_validator, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_staff_user_me_model}, + response_parameters={'method.response.header.Access-Control-Allow-Origin': True}, + ), + MethodResponse( + status_code='404', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + ], + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + ) + + def _add_patch_user( + self, + user_resource: Resource, + scopes: list[str], + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.staff_users_lambdas.patch_user_handler + + # Add the PATCH method to the me_resource + user_resource.add_method( + 'PATCH', + integration=LambdaIntegration(handler), + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.patch_staff_user_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_staff_user_me_model}, + response_parameters={'method.response.header.Access-Control-Allow-Origin': True}, + ), + MethodResponse( + status_code='404', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + ], + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + ) + + def _add_delete_user( + self, + user_id_resource: Resource, + scopes: list[str], + *, + api_lambda_stack: ApiLambdaStack, + ) -> None: + """Add DELETE method to delete a staff user's record. + + :param user_id_resource: The API Gateway Resource to add the method to + :param scopes: List of OAuth scopes required for this endpoint + :param env_vars: Environment variables to pass to the Lambda function + :param persistent_stack: Stack containing persistent resources + """ + handler = api_lambda_stack.staff_users_lambdas.delete_user_handler + + # Add the method to the resource + user_id_resource.add_method( + 'DELETE', + LambdaIntegration(handler), + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + method_responses=[ + MethodResponse( + status_code='200', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + MethodResponse( + status_code='404', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + ], + ) + + def _add_post_user( + self, + users_resource: Resource, + scopes: list[str], + api_lambda_stack: ApiLambdaStack, + ): + handler = api_lambda_stack.staff_users_lambdas.post_user_handler + + # Add the POST method to the me_resource + users_resource.add_method( + 'POST', + integration=LambdaIntegration(handler), + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.post_staff_user_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_staff_user_me_model}, + response_parameters={'method.response.header.Access-Control-Allow-Origin': True}, + ), + ], + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + ) + + def _add_reinvite_user( + self, + reinvite_resource: Resource, + scopes: list[str], + api_lambda_stack: ApiLambdaStack, + ) -> None: + handler = api_lambda_stack.staff_users_lambdas.reinvite_user_handler + + # Add the method to the resource + reinvite_resource.add_method( + 'POST', + LambdaIntegration(handler), + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=scopes, + method_responses=[ + MethodResponse( + status_code='200', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + MethodResponse( + status_code='404', + response_models={ + 'application/json': self.api_model.message_response_model, + }, + ), + ], + ) diff --git a/backend/social-work-app/stacks/disaster_recovery_stack/__init__.py b/backend/social-work-app/stacks/disaster_recovery_stack/__init__.py new file mode 100644 index 0000000000..4c1f3d8ef5 --- /dev/null +++ b/backend/social-work-app/stacks/disaster_recovery_stack/__init__.py @@ -0,0 +1,168 @@ +from aws_cdk import RemovalPolicy, Stack +from aws_cdk.aws_dynamodb import Table +from aws_cdk.aws_iam import PolicyStatement, ServicePrincipal +from aws_cdk.aws_kms import Key +from aws_cdk.aws_s3 import BlockPublicAccess, Bucket, BucketEncryption, ObjectOwnership +from cdk_nag import NagSuppressions +from common_constructs.ssn_table import SSN_RESTORED_TABLE_NAME_PREFIX, SSNTable +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack as ps +from stacks.disaster_recovery_stack.license_upload_rollback_step_function import ( + LicenseUploadRollbackStepFunctionConstruct, +) +from stacks.disaster_recovery_stack.restore_dynamo_db_table_step_function import ( + RestoreDynamoDbTableStepFunctionConstruct, +) +from stacks.disaster_recovery_stack.sync_table_step_function import SyncTableDataStepFunctionConstruct + + +class DisasterRecoveryStack(AppStack): + """ + This stack instantiates resources for restoring data from backups to recover from disasters that + impact the entire system. It leverages AWS step functions to automate the recovery process and reduce the risk of + developer error the comes with manual rollbacks. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + + removal_policy = RemovalPolicy.RETAIN if environment_name == 'prod' else RemovalPolicy.DESTROY + self.dr_shared_encryption_key = Key( + self, + 'DisasterRecoverySharedEncryptionKey', + enable_key_rotation=True, + alias=f'{self.stack_name}-shared-encryption-key', + removal_policy=removal_policy, + ) + + # Allow CloudWatch Logs service to use this KMS key for encrypting log streams + # for DR State Machine log groups + self.dr_shared_encryption_key.add_to_resource_policy( + PolicyStatement( + principals=[ServicePrincipal(f'logs.{self.region}.amazonaws.com')], + actions=[ + 'kms:Encrypt', + 'kms:Decrypt', + 'kms:ReEncrypt*', + 'kms:GenerateDataKey*', + 'kms:DescribeKey', + ], + resources=['*'], + ) + ) + + # Create S3 bucket for license upload rollback results + stack = Stack.of(self) + self.disaster_recovery_results_bucket = Bucket( + self, + 'DisasterRecoveryResultsBucket', + encryption=BucketEncryption.KMS, + encryption_key=self.dr_shared_encryption_key, + removal_policy=removal_policy, + auto_delete_objects=removal_policy == RemovalPolicy.DESTROY, + versioned=True, + enforce_ssl=True, + block_public_access=BlockPublicAccess.BLOCK_ALL, + object_ownership=ObjectOwnership.BUCKET_OWNER_ENFORCED, + server_access_logs_bucket=persistent_stack.access_logs_bucket, + server_access_logs_prefix=f'_logs/{stack.account}/{stack.region}/{self.node.path}/DisasterRecoveryResultsBucket/', + ) + + # Suppress replication requirement - replication to a logs archive account may be added as a future enhancement + NagSuppressions.add_resource_suppressions( + self.disaster_recovery_results_bucket, + suppressions=[ + { + 'id': 'HIPAA.Security-S3BucketReplicationEnabled', + 'reason': 'This bucket is for generating one time' + ' results of the rollback workflow and is not intended to be replicated.', + }, + ], + ) + + # Create Step Functions for restoring DynamoDB tables + self.dr_workflows = {} + + dr_enabled_tables = [ + persistent_stack.provider_table, + persistent_stack.compact_configuration_table, + persistent_stack.data_event_table, + persistent_stack.staff_users.user_table, + ] + + for table in dr_enabled_tables: + self.dr_workflows[table.table_name] = self._create_dynamodb_table_dr_recovery_workflow( + table=table, shared_persistent_stack_key=persistent_stack.shared_encryption_key + ) + + # Enable DR for the SSN table with special handling for security + self.dr_workflows[persistent_stack.ssn_table.table_name] = self._create_ssn_dynamodb_table_dr_recovery_workflow( + ssn_table=persistent_stack.ssn_table + ) + + # Create License Upload Rollback workflow + self.license_upload_rollback_workflow = LicenseUploadRollbackStepFunctionConstruct( + self, + 'LicenseUploadRollback', + persistent_stack=persistent_stack, + rollback_results_bucket=self.disaster_recovery_results_bucket, + dr_shared_encryption_key=self.dr_shared_encryption_key, + ) + + def _create_dynamodb_table_dr_recovery_workflow(self, table: Table, shared_persistent_stack_key: Key): + """Create the DR workflow for a standard DynamoDB table.""" + # Prefix for restored (source) tables created by the restore workflow. The + # SyncTableData construct uses this to grant read permissions on any + # restored table that follows this naming convention. + restored_table_name_prefix = 'DR-TEMP-' + + sync_table_step_function = SyncTableDataStepFunctionConstruct( + self, + f'{table.node.id[0:50]}-SyncTableData', + table=table, + source_table_name_prefix=restored_table_name_prefix, + dr_shared_encryption_key=self.dr_shared_encryption_key, + ) + + return RestoreDynamoDbTableStepFunctionConstruct( + self, + f'DR-RestoreTableStepFunction-{table.node.id[0:50]}', + restored_table_name_prefix=restored_table_name_prefix, + table=table, + sync_table_data_state_machine_arn=sync_table_step_function.state_machine.state_machine_arn, + encryption_key_for_restore=shared_persistent_stack_key, + dr_shared_encryption_key=self.dr_shared_encryption_key, + ) + + def _create_ssn_dynamodb_table_dr_recovery_workflow(self, ssn_table: SSNTable): + """Create the DR workflow for the SSN DynamoDB table.""" + ssn_sync_table_step_function = SyncTableDataStepFunctionConstruct( + self, + f'{ssn_table.node.id[0:50]}-SyncTableData', + table=ssn_table, + source_table_name_prefix=SSN_RESTORED_TABLE_NAME_PREFIX, + dr_shared_encryption_key=self.dr_shared_encryption_key, + ssn_encryption_key=ssn_table.key, + ssn_dr_lambda_role=ssn_table.disaster_recovery_lambda_role, + ) + + return RestoreDynamoDbTableStepFunctionConstruct( + self, + f'DR-RestoreTableStepFunction-{ssn_table.node.id[0:50]}', + restored_table_name_prefix=SSN_RESTORED_TABLE_NAME_PREFIX, + table=ssn_table, + sync_table_data_state_machine_arn=ssn_sync_table_step_function.state_machine.state_machine_arn, + encryption_key_for_restore=ssn_table.key, + dr_shared_encryption_key=self.dr_shared_encryption_key, + ssn_dr_step_function_role=ssn_table.disaster_recovery_step_function_role, + ) diff --git a/backend/social-work-app/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py b/backend/social-work-app/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py new file mode 100644 index 0000000000..2c1b6c2a84 --- /dev/null +++ b/backend/social-work-app/stacks/disaster_recovery_stack/license_upload_rollback_step_function.py @@ -0,0 +1,245 @@ +import os + +from aws_cdk import Duration +from aws_cdk.aws_events import EventBus +from aws_cdk.aws_kms import Key +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_s3 import Bucket +from aws_cdk.aws_stepfunctions import ( + Choice, + Condition, + DefinitionBody, + Fail, + IChainable, + LogLevel, + LogOptions, + Pass, + StateMachine, + Succeed, +) +from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import Stack +from constructs import Construct + +from stacks import persistent_stack as ps + + +class LicenseUploadRollbackStepFunctionConstruct(Construct): + """ + Step Function construct for rolling back invalid license uploads. + + This construct creates a Lambda function to process the rollback and a Step Function + state machine to orchestrate the process with pagination support. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + persistent_stack: ps.PersistentStack, + rollback_results_bucket: Bucket, + dr_shared_encryption_key: Key, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + stack = Stack.of(self) + # We explicitly get the event bus arn from parameter store, to avoid issues with cross stack updates + data_event_bus = SSMParameterUtility.load_data_event_bus_from_ssm_parameter(self) + + # Create Lambda function for rollback processing + self._create_rollback_function( + stack=stack, + persistent_stack=persistent_stack, + rollback_results_bucket=rollback_results_bucket, + data_event_bus=data_event_bus, + ) + + # Build Step Function definition + definition = self._build_rollback_state_machine_definition() + + # Create log group for state machine + state_machine_log_group = LogGroup( + self, + 'LicenseUploadRollbackStateMachineLogs', + # this state machine will hopefully not be run often, so we will not automatically clear these logs + retention=RetentionDays.INFINITE, + encryption_key=dr_shared_encryption_key, + ) + + # Suppress retention period requirement - we are deliberately retaining logs indefinitely + NagSuppressions.add_resource_suppressions( + state_machine_log_group, + suppressions=[ + { + 'id': 'HIPAA.Security-CloudWatchLogGroupRetentionPeriod', + 'reason': 'This system will be used infrequently.' + ' We are deliberately retaining logs indefinitely here.', + }, + ], + ) + + # Create state machine + self.state_machine = StateMachine( + self, + 'LicenseUploadRollbackStateMachine', + definition_body=DefinitionBody.from_chainable(definition), + timeout=Duration.hours(8), # Long timeout for processing many providers + logs=LogOptions( + destination=state_machine_log_group, + level=LogLevel.ALL, + include_execution_data=True, + ), + tracing_enabled=True, + ) + + # Grant state machine permission to invoke the Lambda + self.rollback_function.grant_invoke(self.state_machine) + + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{self.state_machine.node.path}/Role/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the specific + Lambda function that this state machine needs access to. + """, + }, + ], + ) + + def _create_rollback_function( + self, + stack: Stack, + persistent_stack: ps.PersistentStack, + rollback_results_bucket: Bucket, + data_event_bus: EventBus, + ): + """Create the Lambda function for processing license upload rollback.""" + self.rollback_function = PythonFunction( + self, + 'LicenseUploadRollbackFunction', + description='Rollback invalid license uploads for a compact/jurisdiction/time window', + lambda_dir='disaster-recovery', + index=os.path.join('handlers', 'rollback_license_upload.py'), + handler='rollback_license_upload', + timeout=Duration.minutes(15), + memory_size=3008, # for managing potentially large results files + environment={ + **stack.common_env_vars, + 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'DISASTER_RECOVERY_RESULTS_BUCKET_NAME': rollback_results_bucket.bucket_name, + 'LICENSE_UPLOAD_DATE_INDEX_NAME': persistent_stack.provider_table.license_upload_date_gsi_name, + 'EVENT_BUS_NAME': data_event_bus.event_bus_name, + }, + ) + + # Grant permissions to read/write provider table + persistent_stack.shared_encryption_key.grant_decrypt(self.rollback_function) + persistent_stack.provider_table.grant_read_write_data(self.rollback_function) + + # Grant S3 permissions for results bucket + rollback_results_bucket.grant_read_write(self.rollback_function) + + # Grant EventBridge permissions to publish events + data_event_bus.grant_put_events_to(self.rollback_function) + + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{self.rollback_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific table, S3 bucket, and event bus that this lambda needs access to. + """, + }, + ], + ) + + def _build_rollback_state_machine_definition(self) -> IChainable: + """ + Build the Step Function definition for license upload rollback. + + Flow: + 1. Initialize - Set up execution parameters including executionId + 2. RollbackLicenses (Lambda) - Process providers and rollback + 3. CheckStatus - Check if complete or needs continuation + - IN_PROGRESS: Loop back to RollbackLicenses + - COMPLETE: Success + - default: Fail + """ + + # Initialize state - prepare input and add executionId + initialize_rollback = Pass( + self, + 'InitializeRollback', + parameters={ + 'compact.$': '$.compact', + 'jurisdiction.$': '$.jurisdiction', + 'startDateTime.$': '$.startDateTime', + 'endDateTime.$': '$.endDateTime', + 'rollbackReason.$': '$.rollbackReason', + 'executionName.$': '$$.Execution.Name', + 'providersProcessed': 0, + }, + comment='Initialize rollback parameters with execution ID', + result_path='$', + ) + + # Rollback licenses Lambda task + rollback_licenses_task = LambdaInvoke( + self, + 'RollbackLicenses', + lambda_function=self.rollback_function, + comment='Process license upload rollback for affected providers', + payload_response_only=True, + result_path='$', + retry_on_service_exceptions=True, + ) + + # Check rollback status + rollback_status_choice = Choice( + self, + 'CheckRollbackStatus', + comment='Check if rollback is complete or needs continuation', + ) + + # Rollback failed state + rollback_failed = Fail( + self, + 'RollbackFailed', + comment='Rollback operation failed', + cause='Rollback operation encountered an error', + error='RollbackError', + ) + + # Success state + rollback_complete = Succeed( + self, + 'RollbackComplete', + comment='License upload rollback completed successfully', + ) + + # Define flow logic + initialize_rollback.next(rollback_licenses_task) + rollback_licenses_task.next(rollback_status_choice) + + # Rollback status flow + rollback_status_choice.when( + Condition.string_equals('$.rollbackStatus', 'COMPLETE'), + rollback_complete, + ).when( + Condition.string_equals('$.rollbackStatus', 'IN_PROGRESS'), + rollback_licenses_task, # Loop back to continue processing + ).otherwise(rollback_failed) + + # Start with initialization + return initialize_rollback diff --git a/backend/social-work-app/stacks/disaster_recovery_stack/restore_dynamo_db_table_step_function.py b/backend/social-work-app/stacks/disaster_recovery_stack/restore_dynamo_db_table_step_function.py new file mode 100644 index 0000000000..ac5ffbbe65 --- /dev/null +++ b/backend/social-work-app/stacks/disaster_recovery_stack/restore_dynamo_db_table_step_function.py @@ -0,0 +1,313 @@ +from aws_cdk import Duration +from aws_cdk.aws_dynamodb import Table +from aws_cdk.aws_iam import Effect, PolicyStatement, Role +from aws_cdk.aws_kms import Key +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_stepfunctions import ( + Choice, + Condition, + CustomState, + DefinitionBody, + Fail, + LogLevel, + LogOptions, + Parallel, + Pass, + QueryLanguage, + StateMachine, + Succeed, + Wait, + WaitTime, +) +from cdk_nag import NagSuppressions +from common_constructs.stack import Stack +from constructs import Construct + + +class RestoreDynamoDbTableStepFunctionConstruct(Construct): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + restored_table_name_prefix: str, + table: Table, + sync_table_data_state_machine_arn: str, + encryption_key_for_restore: Key, + dr_shared_encryption_key: Key, + ssn_dr_step_function_role: Role | None = None, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + stack = Stack.of(self) + + # Build Step Function definition with SDK tasks and polling loops + # Use SSN encryption key if provided, otherwise use shared persistent stack key + definition = self._build_state_machine_definition( + table=table, + restored_table_name_prefix=restored_table_name_prefix, + sync_table_data_state_machine_arn=sync_table_data_state_machine_arn, + persistent_stack_encryption_key=encryption_key_for_restore, + ssn_restore_workflow=ssn_dr_step_function_role is not None, + ) + + state_machine_log_group = LogGroup( + self, + f'{table.node.id}DRRestoreTableDataStateMachineLogs', + retention=RetentionDays.ONE_MONTH, + encryption_key=dr_shared_encryption_key, + ) + + self.state_machine = StateMachine( + self, + f'DRRestoreDynamoDbTable{table.node.id}StateMachine', + definition_body=DefinitionBody.from_chainable(definition), + timeout=Duration.hours(2), + logs=LogOptions( + destination=state_machine_log_group, + level=LogLevel.ALL, + include_execution_data=True, + ), + tracing_enabled=True, + query_language=QueryLanguage.JSONATA, + # if the ssn step function role is not defined, this will be set to None and a default role will + # be defined + role=ssn_dr_step_function_role if ssn_dr_step_function_role else None, + ) + + # Only add permissions if not using the SSN DR step function role (which already has all needed permissions) + if ssn_dr_step_function_role is None: + # Add permissions for SDK tasks + self.state_machine.add_to_role_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'dynamodb:CreateBackup', # For backup creation + 'dynamodb:DescribeBackup', # For backup status polling + 'dynamodb:RestoreTableToPointInTime', # For creating table from PITR backup + 'dynamodb:DescribeTable', # For table status polling + # The following permissions are needed for restoring data into the PITR table + # https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazondynamodb.html#amazondynamodb-actions-as-permissions + 'dynamodb:BatchWriteItem', + 'dynamodb:DeleteItem', + 'dynamodb:GetItem', + 'dynamodb:PutItem', + 'dynamodb:Query', + 'dynamodb:Scan', + 'dynamodb:UpdateItem', + ], + resources=[ + table.table_arn, # Table for backup operations + f'{table.table_arn}/backup/*', # Backup resources + f'arn:aws:dynamodb:{stack.region}:{stack.account}:table/{restored_table_name_prefix}*', + f'arn:aws:dynamodb:{stack.region}:{stack.account}:table/{restored_table_name_prefix}*/index/*', + ], + ) + ) + # Add permissions to start sync table step function and check for completion + self.state_machine.add_to_role_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'states:StartExecution', # For invoking sync table step function + # permissions needed for step function to track synchronous events of step function execution + 'events:PutTargets', + 'events:PutRule', + 'events:DescribeRule', + ], + resources=[ + sync_table_data_state_machine_arn, # Sync table step function + # rule used for tracking step function execution events + f'arn:aws:events:{stack.region}:{stack.account}:rule/StepFunctionsGetEventsForStepFunctionsExecutionRule', + ], + ) + ) + + # Grant permissions for the encryption key used for restore + encryption_key_for_restore.grant_encrypt_decrypt(self.state_machine) + self.state_machine.add_to_role_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + # this is needed to recover a table that is encrypted with a custom managed KMS key + 'kms:DescribeKey', + 'kms:CreateGrant', + ], + resources=[encryption_key_for_restore.key_arn], + ) + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{self.state_machine.node.path}/Role/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to creating + specific DynamoDB table DR backups that this state machine is designed to restore. + """, + }, + ], + ) + + def _build_state_machine_definition( + self, + table: Table, + restored_table_name_prefix: str, + sync_table_data_state_machine_arn: str, + persistent_stack_encryption_key: Key, + ssn_restore_workflow: bool, + ): + """Builds restore + backup in parallel, then sync execution with polling loops.""" + stack = Stack.of(self) + + new_table_name = f'{restored_table_name_prefix}{table.table_name[0:32]}' + + # Initialize: allow passing through required inputs + initialize_restore_step = Pass( + self, + 'Restore-Initialize', + assign={ + 'restoreTableName': f"{{% '{new_table_name}' & $states.input.incidentId %}}", + 'incidentId': '{% $states.input.incidentId %}', + # guard rail for admin to confirm which table that are attempting to recover + 'tableNameRecoveryConfirmation': '{% $states.input.tableNameRecoveryConfirmation %}', + }, + # JSONata syntax uses outputs instead of parameters and result path as used by JSONPath syntax + outputs={ + 'pitrBackupTime': '{% $states.input.pitrBackupTime %}', + 'destinationTableArn': table.table_arn, + }, + ) + + # ===================== + # Restore Branch (PITR) + # restore the table from the PITR backup using the provided timestamp. + # ===================== + restore_from_pitr_state_json = { + 'Type': 'Task', + 'Arguments': { + 'TargetTableName': '{% $restoreTableName %}', + 'RestoreDateTime': '{% $states.input.pitrBackupTime %}', + 'SourceTableArn': table.table_arn, + # ensure the restored table is encrypted with the same key as the original table. + 'SseSpecificationOverride': { + 'Enabled': True, + 'KmsMasterKeyId': persistent_stack_encryption_key.key_id, + 'SseType': 'KMS', + }, + }, + 'Resource': 'arn:aws:states:::aws-sdk:dynamodb:restoreTableToPointInTime', + 'QueryLanguage': 'JSONata', + } + + restore_task = CustomState(self, 'RestoreTableToPointInTime', state_json=restore_from_pitr_state_json) + + describe_table_state_json = { + 'Type': 'Task', + 'Arguments': {'TableName': '{% $restoreTableName %}'}, + 'Resource': 'arn:aws:states:::aws-sdk:dynamodb:describeTable', + 'QueryLanguage': 'JSONata', + } + + describe_table_task = CustomState(self, 'DescribeTable', state_json=describe_table_state_json) + # This parses the response from the describe table api call for a consistent input into the + # describe table step when looping + + wait_restore = Wait( + self, 'WaitForRestore', time=WaitTime.duration(Duration.seconds(60)), query_language=QueryLanguage.JSONATA + ) + + pitr_restore_ready = Succeed(self, 'RestoreReady') + pitr_restore_failed = Fail(self, 'RestoreFailed', cause='Restore failed', error='RestoreError') + + pitr_restore_choice = Choice(self, 'CheckRestoreStatus', query_language=QueryLanguage.JSONATA) + pitr_restore_choice.when( + Condition.jsonata("{% $states.input.Table.TableStatus = 'ACTIVE' %}"), + pitr_restore_ready, + ).when( + Condition.jsonata("{% $states.input.Table.TableStatus = 'CREATING' %}"), + wait_restore, + ).otherwise(pitr_restore_failed) + + restore_task.next(describe_table_task) + describe_table_task.next(pitr_restore_choice) + wait_restore.next(describe_table_task) + + # ================= + # Backup Branch + # create a backup of the existing table for post-recovery analysis. + # ================= + create_backup_state_json = { + 'Type': 'Task', + 'Arguments': { + 'BackupName': f"{{% $incidentId & '{table.table_name[0:32]}-BACKUP' %}}", + 'TableName': '{% $tableNameRecoveryConfirmation %}', + }, + 'Resource': 'arn:aws:states:::aws-sdk:dynamodb:createBackup', + 'QueryLanguage': 'JSONata', + 'Assign': {'backupArn': '{% $states.result.BackupDetails.BackupArn %}'}, + } + create_backup_for_existing_table = CustomState( + self, 'CreateOnDemandBackup', state_json=create_backup_state_json + ) + + describe_backup_state_json = { + 'Type': 'Task', + 'Arguments': {'BackupArn': '{% $backupArn %}'}, + 'Resource': 'arn:aws:states:::aws-sdk:dynamodb:describeBackup', + 'QueryLanguage': 'JSONata', + } + + describe_backup_task = CustomState(self, 'DescribeBackup', state_json=describe_backup_state_json) + + wait_backup = Wait(self, 'WaitForBackup', time=WaitTime.duration(Duration.seconds(10))) + backup_ready = Succeed(self, 'BackupReady') + backup_failed = Fail(self, 'BackupFailed', cause='Backup failed', error='BackupError') + + backup_choice = Choice(self, 'CheckBackupStatus') + backup_choice.when( + Condition.jsonata("{% $states.input.BackupDescription.BackupDetails.BackupStatus = 'AVAILABLE' %}"), + backup_ready, + ).when( + Condition.jsonata("{% $states.input.BackupDescription.BackupDetails.BackupStatus = 'CREATING' %}"), + wait_backup, + ).otherwise(backup_failed) + + create_backup_for_existing_table.next(describe_backup_task) + describe_backup_task.next(backup_choice) + wait_backup.next(describe_backup_task) + + # Run restore and backup in parallel + parallel_restore_and_backup = Parallel(self, 'RestoreAndBackupInParallel', outputs=None) + parallel_restore_and_backup.branch(restore_task) + # We don't create backups of the SSN table during the restore process to reduce the data footprint of this + # sensitive information + if not ssn_restore_workflow: + parallel_restore_and_backup.branch(create_backup_for_existing_table) + + # After both complete, start the sync step function using JSONata to perform a hard reset + # of the table to match the PITR backup table. + start_sync_table_state_json = { + 'Type': 'Task', + 'Resource': 'arn:aws:states:::states:startExecution.sync:2', + 'Arguments': { + 'StateMachineArn': sync_table_data_state_machine_arn, + 'Input': { + # Pass the source table arn and the table name that the admin confirmed to the sync step function + 'sourceTableArn': f"{{% 'arn:aws:dynamodb:{stack.region}:{stack.account}:table/' & $restoreTableName %}}", # noqa: E501 + 'tableNameRecoveryConfirmation': '{% $tableNameRecoveryConfirmation %}', + }, + 'Name': '{% $incidentId %}', + }, + 'QueryLanguage': 'JSONata', + 'End': True, + } + start_sync = CustomState(self, 'StartSyncTableData', state_json=start_sync_table_state_json) + + initialize_restore_step.next(parallel_restore_and_backup) + parallel_restore_and_backup.next(start_sync) + + return initialize_restore_step diff --git a/backend/social-work-app/stacks/disaster_recovery_stack/sync_table_step_function.py b/backend/social-work-app/stacks/disaster_recovery_stack/sync_table_step_function.py new file mode 100644 index 0000000000..dd26af587f --- /dev/null +++ b/backend/social-work-app/stacks/disaster_recovery_stack/sync_table_step_function.py @@ -0,0 +1,309 @@ +import os + +from aws_cdk import ArnFormat, Duration +from aws_cdk.aws_dynamodb import Table +from aws_cdk.aws_iam import PolicyStatement, Role +from aws_cdk.aws_kms import Key +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_stepfunctions import ( + Choice, + Condition, + DefinitionBody, + Fail, + IChainable, + LogLevel, + LogOptions, + Pass, + StateMachine, + Succeed, +) +from aws_cdk.aws_stepfunctions_tasks import LambdaInvoke +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.ssn_table import SSN_SYNC_STATE_MACHINE_NAME +from common_constructs.stack import Stack +from constructs import Construct + + +class SyncTableDataStepFunctionConstruct(Construct): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + table: Table, + source_table_name_prefix: str, + dr_shared_encryption_key: Key, + ssn_encryption_key: Key | None = None, + ssn_dr_lambda_role: Role | None = None, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + stack = Stack.of(self) + + # Create Lambda functions for delete and copy operations + self._create_lambda_functions( + table, + source_table_name_prefix=source_table_name_prefix, + ssn_encryption_key=ssn_encryption_key, + ssn_dr_lambda_role=ssn_dr_lambda_role, + ) + + # Build Step Function definition with separate delete and copy phases + definition = self._build_sync_table_data_state_machine_definition(destination_table=table) + + state_machine_log_group = LogGroup( + self, + f'{table.node.id}DRSyncTableDataStateMachineLogs', + retention=RetentionDays.ONE_MONTH, + encryption_key=dr_shared_encryption_key, + ) + + self.state_machine = StateMachine( + self, + f'{table.node.id}DRSyncTableDataStateMachine', + definition_body=DefinitionBody.from_chainable(definition), + timeout=Duration.hours(8), # Longer timeout for data operations + logs=LogOptions( + destination=state_machine_log_group, + level=LogLevel.ALL, + include_execution_data=True, + ), + tracing_enabled=True, + state_machine_name=SSN_SYNC_STATE_MACHINE_NAME if ssn_dr_lambda_role is not None else None, + ) + # allow step function to call these lambdas + self.cleanup_records_function.grant_invoke(self.state_machine) + self.copy_records_function.grant_invoke(self.state_machine) + + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{self.state_machine.node.path}/Role/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the specific + Lambda functions that this state machine needs access to. + """, + }, + ], + ) + + def _create_lambda_functions( + self, + table: Table, + source_table_name_prefix: str, + ssn_encryption_key: Key | None = None, + ssn_dr_lambda_role: Role | None = None, + ): + """Create Lambda functions for delete and copy operations.""" + stack = Stack.of(self) + self.cleanup_records_function = PythonFunction( + self, + f'DR-{table.node.id}-SyncCleanup', + description=f'Disaster Recovery cleanup sync step for {table.node.id}', + lambda_dir='disaster-recovery', + index=os.path.join('handlers', 'cleanup_records.py'), + handler='cleanup_records', + timeout=Duration.minutes(15), + environment={ + **stack.common_env_vars, + }, + # Setting this memory size higher than others because these will not be used frequently, and if they are + # used we want to process this recovery process quickly. Increasing the memory for these + # also increases the performance. + memory_size=3008, + # Use ssn role if provided, otherwise PythonFunction creates default + role=ssn_dr_lambda_role if ssn_dr_lambda_role else None, + ) + + if not ssn_dr_lambda_role: + table.grant_read_write_data(self.cleanup_records_function) + + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{self.cleanup_records_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific table that this lambda needs access to. + """, + }, + ], + ) + + # Prepare environment variables for copy_records_function + copy_env_vars = {**stack.common_env_vars} + if ssn_encryption_key is not None: + copy_env_vars['SSN_ENCRYPTION_KMS_KEY_ID'] = ssn_encryption_key.key_id + + self.copy_records_function = PythonFunction( + self, + f'DR-{table.node.id}-SyncCopy', + description=f'Disaster Recovery copy sync step for {table.node.id}', + lambda_dir='disaster-recovery', + index=os.path.join('handlers', 'copy_records.py'), + handler='copy_records', + timeout=Duration.minutes(15), + environment=copy_env_vars, + # Setting this memory size higher than others because these will not be used frequently, and if they are + # used we want to process this recovery process quickly. Increasing the memory for these + # also increases the performance. + memory_size=3008, + # Use ssn role if provided, otherwise PythonFunction creates default + role=ssn_dr_lambda_role, + ) + # Grant permissions to copy_records_function + # If using a custom SSN DR Lambda role, permissions should already be configured on that role + # Otherwise grant standard permissions for other tables + if not ssn_dr_lambda_role: + table.grant_write_data(self.copy_records_function) + # The source table name for these will be determined when the DR is actually run, + # so we grant a policy to allow this lambda to read from any table prefixed with the + # name prefix defined in CDK. The parent step function to this will name the restored table + # to follow this prefix. + self.copy_records_function.add_to_role_policy( + PolicyStatement( + actions=['dynamodb:Scan'], + resources=[ + stack.format_arn( + partition=stack.partition, + service='dynamodb', + region=stack.region, + account=stack.account, + resource='table', + resource_name=f'{source_table_name_prefix}*', + arn_format=ArnFormat.SLASH_RESOURCE_NAME, + ) + ], + ) + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{self.copy_records_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific destination table and prefixed source tables this lambda needs access to. + """, + }, + ], + ) + + def _build_sync_table_data_state_machine_definition(self, destination_table: Table) -> IChainable: + """ + Builds Step Function with separate delete and copy phases. + + Delete Phase Flow: + 1. DeleteRecords (Lambda) - Delete batch from destination table + 2. Choice: deleteStatus + - IN_PROGRESS: Loop back to DeleteRecords + - COMPLETE: Move to Copy Phase + - default step: fail + + Copy Phase Flow: + 1. CopyRecords (Lambda) - Copy batch from source to destination + 2. Choice: copyStatus + - IN_PROGRESS: Loop back to CopyRecords with lastEvaluatedKey + - COMPLETE: End step function in success state + - default step: fail + """ + + # Initialize state - prepare input for delete phase + initialize_sync = Pass( + self, + 'InitializeSync', + parameters={ + # get the source table ARN from the event input. + # the destination table is hardcoded to what is created during the deployment + 'sourceTableArn.$': '$.sourceTableArn', + 'destinationTableArn': destination_table.table_arn, + # Used by the lambdas to ensure the execution guard flag is present and matches the expected table name + 'tableNameRecoveryConfirmation.$': '$.tableNameRecoveryConfirmation', + }, + comment='Initialize sync operation with input parameters', + result_path='$', + ) + + # === DELETE PHASE === + + # Delete records from destination table + delete_records_task = LambdaInvoke( + self, + # step function names are limited to 80 characters + f'{destination_table.node.id}-DeleteRecords', + lambda_function=self.cleanup_records_function, + comment='Delete all records from destination table', + payload_response_only=True, + result_path='$', + retry_on_service_exceptions=True, + ) + + # Check delete operation status + delete_status_choice = Choice( + self, 'CheckDeleteStatus', comment='Check if deletion is complete or needs continuation' + ) + + # Delete failed state + delete_failed = Fail( + self, + 'DeleteFailed', + comment='Delete operation failed', + cause='Delete records operation encountered an error', + error='DeleteRecordsError', + ) + + # === COPY PHASE === + # Copy records from source to destination table + copy_records_task = LambdaInvoke( + self, + f'{destination_table.node.id}-CopyRecords', + lambda_function=self.copy_records_function, + comment='Copy records from source to destination table', + payload_response_only=True, + result_path='$', + retry_on_service_exceptions=True, + ) + + # Check copy operation status + copy_status_choice = Choice(self, 'CheckCopyStatus', comment='Check if copy is complete or needs continuation') + + # Copy failed state + copy_failed = Fail( + self, + 'CopyFailed', + comment='Copy operation failed', + cause='Copy records operation encountered an error', + error='CopyRecordsError', + ) + + # Success state + sync_complete = Succeed(self, 'SyncComplete', comment='Table data synchronization completed successfully') + + # === DEFINE FLOW LOGIC === + # Connect the phases + initialize_sync.next(delete_records_task) + delete_records_task.next(delete_status_choice) + # Delete phase flow + delete_status_choice.when(Condition.string_equals('$.deleteStatus', 'COMPLETE'), copy_records_task).when( + Condition.string_equals('$.deleteStatus', 'IN_PROGRESS'), + delete_records_task, # Loop back to continue deletion + ).otherwise(delete_failed) + + copy_records_task.next(copy_status_choice) + # Copy phase flow with pagination support + copy_status_choice.when(Condition.string_equals('$.copyStatus', 'COMPLETE'), sync_complete).when( + Condition.string_equals('$.copyStatus', 'IN_PROGRESS'), + # loop back to copy task + copy_records_task, + ).otherwise(copy_failed) + + # Start with initialization + return initialize_sync diff --git a/backend/social-work-app/stacks/event_state_stack/__init__.py b/backend/social-work-app/stacks/event_state_stack/__init__.py new file mode 100644 index 0000000000..382207989b --- /dev/null +++ b/backend/social-work-app/stacks/event_state_stack/__init__.py @@ -0,0 +1,36 @@ +from aws_cdk import RemovalPolicy +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack as ps +from stacks.event_state_stack.event_state_table import EventStateTable + + +class EventStateStack(AppStack): + """ + Stack for event processing state management. + + This stack contains resources for tracking the state of event-driven operations, + particularly for maintaining idempotency across SQS message retries. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + + # Use same removal policy as persistent stack resources + removal_policy = RemovalPolicy.RETAIN if environment_name == 'prod' else RemovalPolicy.DESTROY + + self.event_state_table = EventStateTable( + self, + 'EventStateTable', + encryption_key=persistent_stack.shared_encryption_key, + removal_policy=removal_policy, + ) diff --git a/backend/social-work-app/stacks/event_state_stack/event_state_table.py b/backend/social-work-app/stacks/event_state_stack/event_state_table.py new file mode 100644 index 0000000000..c87d2418fe --- /dev/null +++ b/backend/social-work-app/stacks/event_state_stack/event_state_table.py @@ -0,0 +1,63 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_dynamodb import ( + Attribute, + AttributeType, + BillingMode, + PointInTimeRecoverySpecification, + ProjectionType, + Table, + TableEncryption, +) +from aws_cdk.aws_kms import IKey +from cdk_nag import NagSuppressions +from constructs import Construct + + +class EventStateTable(Table): + """ + DynamoDB table for tracking event processing state across SQS message retries. + + This table is used to maintain idempotency and track the success/failure state + of various operations performed during event processing, particularly for + notification delivery tracking. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + encryption_key: IKey, + removal_policy: RemovalPolicy, + ) -> None: + super().__init__( + scope, + construct_id, + billing_mode=BillingMode.PAY_PER_REQUEST, + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=encryption_key, + partition_key={'name': 'pk', 'type': AttributeType.STRING}, + sort_key={'name': 'sk', 'type': AttributeType.STRING}, + point_in_time_recovery_specification=PointInTimeRecoverySpecification(point_in_time_recovery_enabled=True), + removal_policy=removal_policy, + deletion_protection=True if removal_policy == RemovalPolicy.RETAIN else False, + time_to_live_attribute='ttl', + ) + + self.provider_event_time_index_name = 'providerId-eventTime-index' + self.add_global_secondary_index( + index_name=self.provider_event_time_index_name, + partition_key=Attribute(name='providerId', type=AttributeType.STRING), + sort_key=Attribute(name='eventTime', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'These records are not intended to be backed up. This table is only for temporary event ' + 'state tracking for retries and all records expire after several weeks.', + }, + ], + ) diff --git a/backend/social-work-app/stacks/feature_flag_stack/__init__.py b/backend/social-work-app/stacks/feature_flag_stack/__init__.py new file mode 100644 index 0000000000..098d260988 --- /dev/null +++ b/backend/social-work-app/stacks/feature_flag_stack/__init__.py @@ -0,0 +1,226 @@ +""" +Feature Flag Management Stack + +This stack manages feature flags through CloudFormation custom resources. +Feature flags enable/disable functionality dynamically across environments without code deployments. + +While we initially create the flag through CDK deployments, updates to the flag configuration are managed through +the respective StatSig account console. + +When a flag is no longer used, removing it from this stack should result in cleaning up all the environment based rules +for the flag and deleting it from StatSig once it has been removed from all environments. + +NOTE: Flags are only currently supported if the environment has a domain name configured. + +Feature Flag Lifecycle: +----------------------- +1. **Creation** (on_create): + - Creates a new StatSig feature gate if it doesn't exist + - Adds an environment-specific rule (e.g., '-rule') to the gate + - If auto_enable=True: passPercentage=100 (enabled) + - If auto_enable=False: passPercentage=0 (disabled) + +2. **Updates** (on_update): + - Feature flags are IMMUTABLE once created in an environment + - Updates are no-ops to prevent overwriting manual console changes + +3. **Deletion** (on_delete): + - Removes the environment-specific rule from the gate + - If it's the last rule, deletes the entire gate + - Other environments' rules remain untouched + +StatSig Environment Mapping: +------------------- +StatSig has three fixed environment names, so we must map our environments to one of the three environments +- test → development (StatSig tier) +- beta → staging (StatSig tier) +- prod → production (StatSig tier) +- sandbox/other → development (StatSig tier, default) + + +Checking Flags in Lambda: +------------------------- +There is a common feature flag client python module that can be used to check if a flag is enabled in StatSig + +```python +from cc_common.feature_flag_client import is_feature_enabled, FeatureFlagContext + +# Simple check +if is_feature_enabled('my-feature-flag-name'): + # run feature gated code if enabled + +# With targeting context +context = FeatureFlagContext( + user_id='user-123', + custom_attributes={'compact': 'socw', 'licenseType': 'cos'} +) +if is_feature_enabled('my-feature-flag-name', context=context): + # run feature gated code if enabled +``` + +Custom Attributes: +----------------- +- Values can be strings (converted to lists) or lists +- Used for targeting specific subsets of users/requests +- Examples: compact, jurisdiction, licenseType, etc. +""" + +from __future__ import annotations + +import os + +from aws_cdk import Duration +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_secretsmanager import Secret +from aws_cdk.custom_resources import Provider +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks.feature_flag_stack.feature_flag_resource import FeatureFlagEnvironmentName, FeatureFlagResource + + +class FeatureFlagStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + + self.provider = self._create_common_provider(environment_name) + + # Feature Flags are deployed through custom resources + # All flags share the same custom resource provider defined above + self.example_flag = FeatureFlagResource( + self, + 'SocialWorkExampleFlag', + provider=self.provider, # Shared provider + flag_name='socialwork-example-flag', + # This causes the flag to automatically be set to enabled for every environment in the list + auto_enable_envs=[ + FeatureFlagEnvironmentName.TEST, + FeatureFlagEnvironmentName.BETA, + FeatureFlagEnvironmentName.PROD, + FeatureFlagEnvironmentName.SANDBOX, + ], + # Note that flags are not updated once set and must be manually updated through the console + custom_attributes={'compact': ['socw']}, + environment_name=environment_name, + ) + + def _create_common_provider(self, environment_name: str) -> Provider: + # Create shared Lambda function for managing all feature flags + # This function is reused across all FeatureFlagResource instances + self.manage_function = PythonFunction( + self, + 'ManageFunction', + index=os.path.join('handlers', 'manage_feature_flag.py'), + lambda_dir='feature-flag', + handler='on_event', + log_retention=RetentionDays.ONE_MONTH, + environment={'ENVIRONMENT_NAME': environment_name}, + timeout=Duration.minutes(5), + memory_size=256, + ) + + # Grant permissions to read secrets + self.statsig_secret = Secret.from_secret_name_v2( + self, + 'StatsigSecret', + f'compact-connect/env/{environment_name}/statsig/credentials', + ) + self.statsig_secret.grant_read(self.manage_function) + + # Add CDK Nag suppressions for the Lambda function + NagSuppressions.add_resource_suppressions_by_path( + self, + path=f'{self.manage_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy contain a wildcard specifically to access the feature flag ' + 'client credentials secret and all of its versions.', + }, + ], + ) + + provider_log_group = LogGroup( + self, + 'ProviderLogGroup', + retention=RetentionDays.ONE_DAY, + ) + NagSuppressions.add_resource_suppressions( + provider_log_group, + suppressions=[ + { + 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', + 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' + ' logs to operators with credentials for the AWS account is desired. Encryption is not' + ' appropriate here.', + }, + ], + ) + + # Create shared custom resource provider + # This provider is reused across all FeatureFlagResource instances + provider = Provider( + self, + 'Provider', + on_event_handler=self.manage_function, + log_group=provider_log_group, + ) + + # Add CDK Nag suppressions for the provider framework + NagSuppressions.add_resource_suppressions_by_path( + self, + f'{provider.node.path}/framework-onEvent/Resource', + [ + {'id': 'AwsSolutions-L1', 'reason': 'We do not control this runtime'}, + { + 'id': 'HIPAA.Security-LambdaConcurrency', + 'reason': 'This function is only run at deploy time, by CloudFormation and has no need for ' + 'concurrency limits.', + }, + { + 'id': 'HIPAA.Security-LambdaDLQ', + 'reason': 'This is a synchronous function run at deploy time. It does not need a DLQ', + }, + { + 'id': 'HIPAA.Security-LambdaInsideVPC', + 'reason': 'We may choose to move our lambdas into private VPC subnets in a future enhancement', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + self, + path=f'{provider.node.path}/framework-onEvent/ServiceRole/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy contain a wildcard specifically to access the feature flag ' + 'client credentials secret and all of its versions.', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + self, + path=f'{provider.node.path}/framework-onEvent/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], + 'reason': 'This policy is appropriate for the custom resource lambda', + }, + ], + ) + + return provider diff --git a/backend/social-work-app/stacks/feature_flag_stack/feature_flag_resource.py b/backend/social-work-app/stacks/feature_flag_stack/feature_flag_resource.py new file mode 100644 index 0000000000..73840f7b3b --- /dev/null +++ b/backend/social-work-app/stacks/feature_flag_stack/feature_flag_resource.py @@ -0,0 +1,72 @@ +""" +CDK construct for managing StatSig feature flags as custom resources. + +This construct creates a CloudFormation custom resource that manages the lifecycle +of StatSig feature flags across different environments. +""" + +from enum import StrEnum + +from aws_cdk import CustomResource +from aws_cdk.custom_resources import Provider +from constructs import Construct + + +class FeatureFlagEnvironmentName(StrEnum): + TEST = 'test' + BETA = 'beta' + PROD = 'prod' + # add sandbox environment names here if needed + SANDBOX = 'sandbox' + + +class FeatureFlagResource(Construct): + """ + Custom resource for managing StatSig feature flags. + + This construct creates a CloudFormation custom resource that manages + the lifecycle of a single feature flag in StatSig. The Lambda function + and provider are shared across all flags and passed in as parameters. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + provider: Provider, + flag_name: str, + auto_enable_envs: list[FeatureFlagEnvironmentName], + custom_attributes: dict[str, str | list[str]] | None = None, + environment_name: str, + ): + """ + Initialize the FeatureFlagResource construct. + + :param provider: Shared CloudFormation custom resource provider + :param flag_name: Name of the feature flag to manage + :param auto_enable_envs: List of environments to automatically enable the flag for + :param custom_attributes: Optional custom attributes for feature flag targeting + :param environment_name: The environment name (test, beta, prod) + """ + super().__init__(scope, construct_id) + + if not flag_name or not environment_name: + raise ValueError('flag_name and environment_name are required') + + self.provider = provider + + # Build custom resource properties + properties = {'flagName': flag_name, 'autoEnable': environment_name in auto_enable_envs} + + if custom_attributes: + properties['customAttributes'] = custom_attributes + + # Create the custom resource + self.custom_resource = CustomResource( + self, + 'CustomResource', + resource_type='Custom::FeatureFlag', + service_token=self.provider.service_token, + properties=properties, + ) diff --git a/backend/social-work-app/stacks/ingest_stack.py b/backend/social-work-app/stacks/ingest_stack.py new file mode 100644 index 0000000000..5391597b19 --- /dev/null +++ b/backend/social-work-app/stacks/ingest_stack.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Metric, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import EventBus, EventPattern, Rule +from aws_cdk.aws_events_targets import SqsQueue +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack, Stack +from constructs import Construct + +from stacks import persistent_stack as ps + + +class IngestStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + # We explicitly get the event bus arn from parameter store, to avoid issues with cross stack updates + data_event_bus = SSMParameterUtility.load_data_event_bus_from_ssm_parameter(self) + self._add_v1_ingest_chain(persistent_stack, data_event_bus) + + def _add_v1_ingest_chain(self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus): + ingest_handler = PythonFunction( + self, + 'V1IngestHandler', + description='Ingest license data handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'ingest.py'), + handler='ingest_license_message', + timeout=Duration.minutes(1), + environment={ + 'EVENT_BUS_NAME': data_event_bus.event_bus_name, + 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + **self.common_env_vars, + }, + alarm_topic=persistent_stack.alarm_topic, + ) + persistent_stack.provider_table.grant_read_write_data(ingest_handler) + data_event_bus.grant_put_events_to(ingest_handler) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(ingest_handler.role), + f'{ingest_handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, KMS key and Table that this lambda specifically needs access to. + """, + }, + ], + ) + # We should specifically set an alarm for any failures of this handler, since it could otherwise go unnoticed. + Alarm( + self, + 'V1IngestFailureAlarm', + metric=ingest_handler.metric_errors(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{ingest_handler.node.path} failed to process a message batch', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + processor = QueuedLambdaProcessor( + self, + 'V1Ingest', + process_function=ingest_handler, + visibility_timeout=Duration.minutes(5), + retention_period=Duration.hours(12), + max_batching_window=Duration.minutes(5), + max_receive_count=3, + batch_size=50, + encryption_key=persistent_stack.shared_encryption_key, + alarm_topic=persistent_stack.alarm_topic, + ) + + ingest_rule = Rule( + self, + 'V1IngestEventRule', + event_bus=data_event_bus, + event_pattern=EventPattern(detail_type=['license.ingest']), + targets=[SqsQueue(processor.queue, dead_letter_queue=processor.dlq)], + ) + + # We will want to alert on failure of this rule to deliver events to the ingest queue + Alarm( + self, + 'V1IngestRuleFailedInvocations', + metric=Metric( + namespace='AWS/Events', + metric_name='FailedInvocations', + dimensions_map={ + 'EventBusName': data_event_bus.event_bus_name, + 'RuleName': ingest_rule.rule_name, + }, + period=Duration.minutes(5), + statistic='Sum', + ), + evaluation_periods=1, + threshold=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) diff --git a/backend/social-work-app/stacks/managed_login_stack.py b/backend/social-work-app/stacks/managed_login_stack.py new file mode 100644 index 0000000000..0e2cfc2520 --- /dev/null +++ b/backend/social-work-app/stacks/managed_login_stack.py @@ -0,0 +1,59 @@ +import json + +from aws_cdk.aws_cognito import CfnManagedLoginBranding +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks.persistent_stack import PersistentStack + + +class ManagedLoginStack(AppStack): + """ + Stack for managing Cognito managed login branding assets. + + This stack isolates the base64-encoded assets from the persistent stack + to avoid hitting CloudFormation template size limits. + + The style settings json data can be obtained by styling the user pool in the + console and then running the following CLI command: + aws cognito-idp describe-managed-login-branding --managed-login-branding-id + "" --user-pool-id "" --region + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + persistent_stack: PersistentStack, + **kwargs, + ) -> None: + super().__init__(scope, construct_id, **kwargs) + + # Create managed login branding for staff users + self._create_managed_login_for_staff_users(persistent_stack) + + def _create_managed_login_for_staff_users(self, persistent_stack: PersistentStack): + """Create managed login branding for staff users""" + # Load the style settings + with open('resources/staff_managed_login_style_settings.json') as f: + branding_settings = json.load(f) + + # Prepare the assets + branding_assets = persistent_stack.staff_users.prepare_assets_for_managed_login_ui( + ico_filepath='resources/assets/favicon.ico', + logo_filepath='resources/assets/compact-connect-logo.png', + background_file_path='resources/assets/staff-background.png', + ) + + # Create the managed login branding + CfnManagedLoginBranding( + self, + 'StaffManagedLoginBranding', + user_pool_id=persistent_stack.staff_users.user_pool_id, + assets=branding_assets, + client_id=persistent_stack.staff_users.ui_client.user_pool_client_id, + return_merged_resources=False, + settings=branding_settings, + use_cognito_provided_values=False, + ) diff --git a/backend/social-work-app/stacks/notification_stack.py b/backend/social-work-app/stacks/notification_stack.py new file mode 100644 index 0000000000..bcebca0888 --- /dev/null +++ b/backend/social-work-app/stacks/notification_stack.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration +from aws_cdk.aws_events import EventBus +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.queue_event_listener import QueueEventListener +from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import event_state_stack as ess +from stacks import persistent_stack as ps + + +class NotificationStack(AppStack): + """ + This stack defines resources that listen for events from the data event bus and sends notifications to the + appropriate recipients. + + Note: The resources in this stack are dependent on the presence of a domain name, due to their integration with + SES. If a domain name is not configured, the stack will not be created. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + event_state_stack: ess.EventStateStack, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + data_event_bus = SSMParameterUtility.load_data_event_bus_from_ssm_parameter(self) + self.event_processors = {} + self.event_state_stack = event_state_stack + self._add_license_encumbrance_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_license_encumbrance_lifting_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_privilege_encumbrance_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_privilege_encumbrance_lifting_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_license_investigation_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_license_investigation_closed_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_privilege_investigation_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_privilege_investigation_closed_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + self._add_provider_home_state_change_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) + + def _add_emailer_event_listener( + self, + construct_id_prefix: str, + *, + index: str, + handler: str, + listener_detail_type: str, + persistent_stack: ps.PersistentStack, + data_event_bus: EventBus, + event_state_stack: ess.EventStateStack, + ): + """ + Add a listener lambda, queues, and event rules, that listens for events from the data event bus and sends + emails. + """ + # Create the Lambda function handler that listens for events and sends notifications + emailer_event_listener_handler = PythonFunction( + self, + f'{construct_id_prefix}Handler', + description=f'{construct_id_prefix} Emailer Event Listener Handler', + lambda_dir='data-events', + index=os.path.join('handlers', index), + handler=handler, + timeout=Duration.minutes(1), + environment={ + 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': persistent_stack.email_notification_service_lambda.function_name, # noqa: E501 line-too-long + 'EVENT_STATE_TABLE_NAME': event_state_stack.event_state_table.table_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + **self.common_env_vars, + }, + alarm_topic=persistent_stack.alarm_topic, + ) + + # Grant necessary permissions + persistent_stack.provider_table.grant_read_data(emailer_event_listener_handler) + persistent_stack.compact_configuration_table.grant_read_data(emailer_event_listener_handler) + persistent_stack.email_notification_service_lambda.grant_invoke(emailer_event_listener_handler) + event_state_stack.event_state_table.grant_read_write_data(emailer_event_listener_handler) + + NagSuppressions.add_resource_suppressions_by_path( + self, + f'{emailer_event_listener_handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, KMS key, Tables, and Email Service Lambda that this lambda specifically + needs access to. + """, + }, + ], + ) + + self.event_processors[construct_id_prefix] = QueueEventListener( + self, + construct_id=construct_id_prefix, + data_event_bus=data_event_bus, + listener_function=emailer_event_listener_handler, + listener_detail_type=listener_detail_type, + encryption_key=persistent_stack.shared_encryption_key, + alarm_topic=persistent_stack.alarm_topic, + ) + + def _add_license_encumbrance_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the license encumbrance notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='LicenseEncumbranceNotificationListener', + index='encumbrance_events.py', + handler='license_encumbrance_notification_listener', + listener_detail_type='license.encumbrance', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_license_encumbrance_lifting_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the license encumbrance lifting notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='LicenseEncumbranceLiftingNotificationListener', + index='encumbrance_events.py', + handler='license_encumbrance_lifting_notification_listener', + listener_detail_type='license.encumbranceLifted', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_privilege_encumbrance_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the privilege encumbrance notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='PrivilegeEncumbranceNotificationListener', + index='encumbrance_events.py', + handler='privilege_encumbrance_notification_listener', + listener_detail_type='privilege.encumbrance', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_privilege_encumbrance_lifting_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the privilege encumbrance lifting notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='PrivilegeEncumbranceLiftingNotificationListener', + index='encumbrance_events.py', + handler='privilege_encumbrance_lifting_notification_listener', + listener_detail_type='privilege.encumbranceLifted', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_license_investigation_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the license investigation notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='LicenseInvestigationNotificationListener', + index='investigation_events.py', + handler='license_investigation_notification_listener', + listener_detail_type='license.investigation', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_license_investigation_closed_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the license investigation closed notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='LicenseInvestigationClosedNotificationListener', + index='investigation_events.py', + handler='license_investigation_closed_notification_listener', + listener_detail_type='license.investigationClosed', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_privilege_investigation_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the privilege investigation notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='PrivilegeInvestigationNotificationListener', + index='investigation_events.py', + handler='privilege_investigation_notification_listener', + listener_detail_type='privilege.investigation', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_privilege_investigation_closed_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the privilege investigation closed notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='PrivilegeInvestigationClosedNotificationListener', + index='investigation_events.py', + handler='privilege_investigation_closed_notification_listener', + listener_detail_type='privilege.investigationClosed', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) + + def _add_provider_home_state_change_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the provider home state change listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='ProviderHomeJurisdictionChangeNotificationListener', + index='home_state_change_events.py', + handler='home_state_change_notification_listener', + listener_detail_type='provider.homeStateChange', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) diff --git a/backend/social-work-app/stacks/persistent_stack/__init__.py b/backend/social-work-app/stacks/persistent_stack/__init__.py new file mode 100644 index 0000000000..cdd783b62a --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/__init__.py @@ -0,0 +1,456 @@ +from aws_cdk import Duration, RemovalPolicy +from aws_cdk.aws_cloudwatch import Alarm +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_cognito import UserPoolEmail +from aws_cdk.aws_iam import Effect, PolicyStatement +from aws_cdk.aws_kms import Key +from aws_cdk.aws_lambda import Runtime +from aws_cdk.aws_logs import QueryDefinition, QueryString +from cdk_nag import NagSuppressions +from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.alarm_topic import AlarmTopic +from common_constructs.frontend_app_config_utility import ( + COGNITO_AUTH_DOMAIN_SUFFIX, + AppId, + PersistentStackFrontendAppConfigUtility, +) +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.python_common_layer_versions import PythonCommonLayerVersions +from common_constructs.security_profile import SecurityProfile +from common_constructs.ssm_parameter_utility import SSMParameterUtility +from common_constructs.ssn_table import SSNTable +from common_constructs.stack import AppStack +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack +from constructs import Construct + +from stacks.persistent_stack.bulk_uploads_bucket import BulkUploadsBucket +from stacks.persistent_stack.compact_configuration_table import CompactConfigurationTable +from stacks.persistent_stack.compact_configuration_upload import CompactConfigurationUpload +from stacks.persistent_stack.data_event_table import DataEventTable +from stacks.persistent_stack.event_bus import EventBus +from stacks.persistent_stack.provider_table import ProviderTable +from stacks.persistent_stack.rate_limiting_table import RateLimitingTable +from stacks.persistent_stack.staff_users import StaffUsers +from stacks.persistent_stack.user_email_notifications import UserEmailNotifications + + +# cdk leverages instance attributes to make resource exports accessible to other stacks +class PersistentStack(AppStack): + """ + The stack that holds long-lived resources such as license data and other things that should probably never + be destroyed in production + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + app_name: str, + environment_name: str, + environment_context: dict, + backup_config: dict, + **kwargs, + ) -> None: + super().__init__( + scope, construct_id, environment_context=environment_context, environment_name=environment_name, **kwargs + ) + # If we delete this stack, retain the resource (orphan but prevent data loss) or destroy it (clean up)? + removal_policy = RemovalPolicy.RETAIN if environment_name == 'prod' else RemovalPolicy.DESTROY + + # Keep this first in the stack - it needs to be in place before we build any PythonFunctions + self.python_common_layer_versions = PythonCommonLayerVersions( + self, + 'PythonCommonLayerVersions', + compatible_runtimes=[Runtime.PYTHON_3_14], + ) + + self.shared_encryption_key = Key( + self, + 'SharedEncryptionKey', + enable_key_rotation=True, + alias=f'{self.stack_name}-shared-encryption-key', + removal_policy=removal_policy, + ) + + notifications = environment_context.get('notifications', {}) + self.alarm_topic = AlarmTopic( + self, + 'AlarmTopic', + master_key=self.shared_encryption_key, + email_subscriptions=notifications.get('email', []), + slack_subscriptions=notifications.get('slack', []), + ) + + # Check if backups are enabled for this environment + backup_enabled = environment_context['backup_enabled'] + + if backup_enabled: + # Create backup infrastructure as a nested stack + self.backup_infrastructure_stack = BackupInfrastructureStack( + self, + 'BackupInfrastructureStack', + environment_name=environment_name, + backup_config=backup_config, + alarm_topic=self.alarm_topic, + ) + else: + self.backup_infrastructure_stack = None + + self.access_logs_bucket = AccessLogsBucket( + self, + 'AccessLogsBucket', + removal_policy=removal_policy, + auto_delete_objects=removal_policy == RemovalPolicy.DESTROY, + ) + + # This resource should not be referenced directly as a cross stack reference, any reference should + # be made through the SSM parameter + # IMPORTANT NOTE: changing the name of the event bus will result in a BREAKING CHANGE (ie downtime). If the name + # must be changed for whatever reason, you must create another event bus and SSM parameter and perform a + # blue/green cut over to safely migrate consumers to the new event bus before deleting the original one in order + # to prevent downtime. + self._data_event_bus = EventBus(self, 'DataEventBus', event_bus_name=f'{environment_name}-dataEventBus') + # We Store the data event bus name in SSM Parameter Store + # to avoid issues with cross stack references due to the fact that + # you can't update a CloudFormation exported value that is being referenced by a resource in another stack. + self.data_event_bus_arn_ssm_parameter = SSMParameterUtility.set_data_event_bus_arn_ssm_parameter( + self, self._data_event_bus + ) + + self._add_data_resources( + removal_policy=removal_policy, backup_infrastructure_stack=self.backup_infrastructure_stack + ) + + self.compact_configuration_upload = CompactConfigurationUpload( + self, + 'CompactConfigurationUpload', + table=self.compact_configuration_table, + master_key=self.shared_encryption_key, + ) + + if self.hosted_zone: + self.user_email_notifications = UserEmailNotifications( + self, + 'UserEmailNotifications', + environment_context=environment_context, + hosted_zone=self.hosted_zone, + master_key=self.shared_encryption_key, + ) + notification_from_email = f'no-reply@{self.hosted_zone.zone_name}' + user_pool_email_settings = UserPoolEmail.with_ses( + from_email=notification_from_email, + ses_verified_domain=self.hosted_zone.zone_name, + configuration_set_name=self.user_email_notifications.config_set.configuration_set_name, + ) + else: + # if domain name is not provided, use the default cognito email settings + notification_from_email = None + user_pool_email_settings = UserPoolEmail.with_cognito() + + self._create_email_notification_service() + + security_profile = SecurityProfile[environment_context.get('security_profile', 'RECOMMENDED')] + + self.staff_users = StaffUsers( + self, + 'StaffUsersGreen', + app_name=app_name, + environment_name=environment_name, + environment_context=environment_context, + encryption_key=self.shared_encryption_key, + user_pool_email=user_pool_email_settings, + notification_from_email=notification_from_email, + ses_identity_arn=self.user_email_notifications.email_identity.email_identity_arn + if self.hosted_zone + else None, + security_profile=security_profile, + removal_policy=removal_policy, + backup_infrastructure_stack=self.backup_infrastructure_stack, + ) + + QueryDefinition( + self, + 'StaffUserCustomEmails', + query_definition_name='StaffUserCustomEmails/Lambdas', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'message', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=[ + self.staff_users.custom_message_lambda.log_group, + ], + ) + + if self.hosted_zone: + # The SES email identity needs to be created before the user pools + # so that the domain address will be verified before being referenced + # by the user pool email settings + self.staff_users.node.add_dependency(self.user_email_notifications.email_identity) + self.staff_users.node.add_dependency(self.user_email_notifications.dmarc_record) + # the verification custom resource needs to be completed before the user pools are created + # so that the user pools will be created after the SES identity is verified + self.staff_users.node.add_dependency(self.user_email_notifications.verification_custom_resource) + + # This parameter is used to store the frontend app config values for use in the frontend deployment stack + self._create_frontend_app_config_parameter() + + def _add_data_resources( + self, removal_policy: RemovalPolicy, backup_infrastructure_stack: BackupInfrastructureStack | None + ): + # Create the ssn related resources before other resources which are dependent on them + self.ssn_table = SSNTable( + self, + 'SSNTable', + removal_policy=removal_policy, + data_event_bus=self._data_event_bus, + alarm_topic=self.alarm_topic, + backup_infrastructure_stack=backup_infrastructure_stack, + environment_context=self.environment_context, + ) + + self.bulk_uploads_bucket = BulkUploadsBucket( + self, + 'BulkUploadsBucket', + access_logs_bucket=self.access_logs_bucket, + # Note that we're using the ssn key here, which has a much more restrictive policy. + # The messages in this bucket include SSN, so we want it just as locked down as our + # permanent storage of SSN data. + bucket_encryption_key=self.ssn_table.key, + removal_policy=removal_policy, + auto_delete_objects=removal_policy == RemovalPolicy.DESTROY, + event_bus=self._data_event_bus, + license_preprocessing_queue=self.ssn_table.preprocessor_queue.queue, + license_upload_role=self.ssn_table.license_upload_role, + ) + + self.rate_limiting_table = RateLimitingTable( + self, + 'RateLimitingTable', + encryption_key=self.shared_encryption_key, + removal_policy=removal_policy, + ) + + self.provider_table = ProviderTable( + self, + 'ProviderTable', + encryption_key=self.shared_encryption_key, + removal_policy=removal_policy, + backup_infrastructure_stack=backup_infrastructure_stack, + environment_context=self.environment_context, + ) + + self.data_event_table = DataEventTable( + scope=self, + construct_id='DataEventTable', + encryption_key=self.shared_encryption_key, + event_bus=self._data_event_bus, + alarm_topic=self.alarm_topic, + removal_policy=removal_policy, + backup_infrastructure_stack=backup_infrastructure_stack, + environment_context=self.environment_context, + ) + + self.compact_configuration_table = CompactConfigurationTable( + scope=self, + construct_id='CompactConfigurationTable', + encryption_key=self.shared_encryption_key, + removal_policy=removal_policy, + backup_infrastructure_stack=backup_infrastructure_stack, + environment_context=self.environment_context, + ) + + def _create_email_notification_service(self) -> None: + """This lambda is intended to be a general purpose email notification service. + + It can be invoked directly to send an email if the lambda is deployed in an environment that has a domain name. + If the lambda is deployed in an environment that does not have a domain name, it will perform a no-op as there + is no FROM address to use. + """ + # If there is no hosted zone, we don't have a domain name to send from + # so we'll use a placeholder value which will cause the lambda to perform a no-op + from_address = 'NONE' + if self.hosted_zone: + from_address = f'noreply@{self.user_email_notifications.email_identity.email_identity_name}' + + self.email_notification_service_lambda = NodejsFunction( + self, + 'EmailNotificationService', + description='Generic email notification service', + lambda_dir='email-notification-service', + handler='sendEmail', + timeout=Duration.minutes(5), + memory_size=1024, + environment={ + 'FROM_ADDRESS': from_address, + 'COMPACT_CONFIGURATION_TABLE_NAME': self.compact_configuration_table.table_name, + 'UI_BASE_PATH_URL': self.get_ui_base_path_url(), + **self.common_env_vars, + }, + ) + self.email_notification_service_failure_alarm = Alarm( + self, + 'EmailNotificationServiceFailureAlarm', + metric=self.email_notification_service_lambda.metric_errors(), + evaluation_periods=1, + threshold=1, + alarm_description='Email notification service has failed to send an email.', + ) + self.email_notification_service_failure_alarm.add_alarm_action(SnsAction(self.alarm_topic)) + + # Grant permissions to read compact configurations + self.compact_configuration_table.grant_read_data(self.email_notification_service_lambda) + # if there is no domain name, we can't set up SES permissions + # in this case the lambda will perform a no-op when invoked. + if self.hosted_zone: + self.setup_ses_permissions_for_lambda(self.email_notification_service_lambda) + + NagSuppressions.add_resource_suppressions_by_path( + self, + f'{self.email_notification_service_lambda.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, Table, and Email Identity that this lambda + specifically needs access to. + """, + }, + ], + ) + + def setup_ses_permissions_for_lambda(self, lambda_function: NodejsFunction): + """Used to allow a lambda to send emails using the user email notification SES identity.""" + ses_resources = [ + self.user_email_notifications.email_identity.email_identity_arn, + self.format_arn( + partition=self.partition, + service='ses', + region=self.region, + account=self.account, + resource='configuration-set', + resource_name=self.user_email_notifications.config_set.configuration_set_name, + ), + ] + + # We'll assume that, if it is a sandbox environment, they're in the Simple Email Service (SES) sandbox + if self.node.try_get_context('sandbox'): + # SES Sandboxed accounts require that the sending principal also be explicitly granted permission to send + # emails to the SES identity they configured for testing. Because we don't know that identity in advance, + # we'll have to allow the principal to use any SES identity configured in the account. + # arn:aws:ses:{region}:{account}:identity/* + ses_resources.append( + self.format_arn( + partition=self.partition, + service='ses', + region=self.region, + account=self.account, + resource='identity', + resource_name='*', + ), + ) + + lambda_function.role.add_to_principal_policy( + PolicyStatement( + actions=['ses:SendEmail', 'ses:SendRawEmail'], + resources=ses_resources, + effect=Effect.ALLOW, + conditions={ + # To mitigate the pretty open resources section for sandbox environments, we'll restrict the + # use of this action by specifying what From address and display name the principal must use. + 'StringEquals': { + 'ses:FromAddress': f'noreply@{self.user_email_notifications.email_identity.email_identity_name}', # noqa: E501 line too long + 'ses:FromDisplayName': 'CompactConnect', + } + }, + ) + ) + + def get_ui_base_path_url(self) -> str: + """Returns the base URL for the UI.""" + if self.ui_domain_name is not None: + return f'https://{self.ui_domain_name}' + + # default to csg test environment + return 'https://app.test.compactconnect.org' + + def get_list_of_compact_abbreviations(self) -> list[str]: + """ + Get the list of all compact abbreviations for compacts configured in the cdk.json file + """ + return self.node.get_context('compacts') + + def get_list_of_active_jurisdictions_for_compact_environment(self, compact: str) -> list[str]: + """ + Get the list of jurisdiction postal abbreviations which are active within a compact. + + This reads the active_compact_member_jurisdictions from the context in cdk.json and returns + the list of jurisdiction postal abbreviations for the specified compact. + + For sandbox environments, it will use sandbox_active_compact_member_jurisdictions. This is because + We have more than 25 active jurisdictions, but Cognito has a default limit of 25 resource servers per user pool, + and we need to create a resource server for each active jurisdiction. + We set the number of active jurisdictions to less than 25 in the sandbox environment so developers don't have + to request a quota increase in order to deploy the sandbox environment. + """ + # Check if this is a sandbox environment + is_sandbox = self.node.try_get_context('sandbox') + + if is_sandbox: + # Try to get sandbox-specific configuration + active_member_jurisdictions = self.node.get_context('sandbox_active_compact_member_jurisdictions') + else: + # Use regular configuration for non-sandbox environments + active_member_jurisdictions = self.node.get_context('active_compact_member_jurisdictions') + + if not active_member_jurisdictions: + raise ValueError( + f'No active member jurisdictions found in context for compact {compact}. ' + 'If this is a sandbox environment, make sure to set the ' + 'sandbox_active_compact_member_jurisdictions context variable in your cdk.context.json file.' + ) + + # Get the jurisdictions for the specified compact and ensure all are lowercase + jurisdictions = active_member_jurisdictions[compact] + return [j.lower() for j in jurisdictions] + + def _create_frontend_app_config_parameter(self): + """ + Creates and stores UI application configuration in SSM Parameter Store for use in the UI stack and + frontend deployment stack. + + NOTE:: These parameters represent Frontend dependencies on the backend app. If any values are changed + or new parameters are introduced, be sure to explicitly plan the deploy sequencing between back- and + front-ends so that these dependencies are properly resolved. + """ + # Create and store UI application configuration in SSM Parameter Store for use in the UI stack + frontend_app_config = PersistentStackFrontendAppConfigUtility(app_id=AppId.SOCIAL_WORK) + + # Add staff user pool Cognito configuration + auth_domain_name = '' + if self.hosted_zone: + auth_domain_name = self.staff_users.app_client_custom_domain.domain_name + else: + auth_domain_name = f'{self.staff_users.default_user_pool_domain.domain_name}{COGNITO_AUTH_DOMAIN_SUFFIX}' + + frontend_app_config.set_staff_cognito_values( + domain_name=auth_domain_name, + client_id=self.staff_users.ui_client.user_pool_client_id, + ) + + # Add UI and API domain names + frontend_app_config.set_domain_names( + ui_domain_name=self.ui_domain_name, + api_domain_name=self.api_domain_name, + search_api_domain_name=self.search_api_domain_name, + ) + + # Add bucket names needed for CSP Lambda + frontend_app_config.set_license_bulk_uploads_bucket_name(bucket_name=self.bulk_uploads_bucket.bucket_name) + + # Generate the SSM parameter + self.frontend_app_config_parameter = frontend_app_config.generate_ssm_parameter( + self, 'FrontendAppConfigParameter' + ) diff --git a/backend/social-work-app/stacks/persistent_stack/bulk_uploads_bucket.py b/backend/social-work-app/stacks/persistent_stack/bulk_uploads_bucket.py new file mode 100644 index 0000000000..76f8155e41 --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/bulk_uploads_bucket.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import EventBus +from aws_cdk.aws_iam import IRole +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_logs import QueryDefinition, QueryString +from aws_cdk.aws_s3 import BucketEncryption, CorsRule, EventType, HttpMethods +from aws_cdk.aws_s3_notifications import LambdaDestination +from aws_cdk.aws_sqs import IQueue +from cdk_nag import NagSuppressions +from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.bucket import Bucket +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from constructs import Construct + +import stacks.persistent_stack as ps + + +class BulkUploadsBucket(Bucket): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + access_logs_bucket: AccessLogsBucket, + bucket_encryption_key: IKey, + event_bus: EventBus, + license_preprocessing_queue: IQueue, + license_upload_role: IRole, + **kwargs, + ): + super().__init__( + scope, + construct_id, + encryption=BucketEncryption.KMS, + encryption_key=bucket_encryption_key, + server_access_logs_bucket=access_logs_bucket, + versioned=False, + cors=[ + CorsRule( + allowed_methods=[HttpMethods.GET, HttpMethods.POST], + allowed_origins=['*'], + allowed_headers=['*'], + ), + ], + **kwargs, + ) + self.log_groups = [] + + self._add_v1_ingest_object_events(event_bus, license_preprocessing_queue, license_upload_role) + + QueryDefinition( + self, + 'RuntimeQuery', + query_definition_name=f'{construct_id}/Lambdas', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'status', 'message', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=self.log_groups, + ) + + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-S3BucketReplicationEnabled', + 'reason': 'This bucket houses transitory data only, so replication to a backup bucket is' + ' unhelpful.', + }, + { + 'id': 'HIPAA.Security-S3BucketVersioningEnabled', + 'reason': 'This bucket houses transitory data only, so storing of version history is unhelpful.', + }, + ], + ) + + def _add_v1_ingest_object_events( + self, event_bus: EventBus, license_preprocessing_queue: IQueue, license_upload_role: IRole + ): + """Read any objects that get uploaded and trigger ingest events""" + stack: ps.PersistentStack = ps.PersistentStack.of(self) + parse_objects_handler = PythonFunction( + self, + 'V1ParseObjectsHandler', + description='Parse s3 objects handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'bulk_upload.py'), + handler='parse_bulk_upload_file', + role=license_upload_role, + timeout=Duration.minutes(15), + alarm_topic=stack.alarm_topic, + memory_size=1024, + environment={ + 'EVENT_BUS_NAME': event_bus.event_bus_name, + 'LICENSE_PREPROCESSING_QUEUE_URL': license_preprocessing_queue.queue_url, + **stack.common_env_vars, + }, + ) + self.grant_delete(parse_objects_handler) + self.grant_read(parse_objects_handler) + # Grant permission to send messages to the preprocessing queue + license_preprocessing_queue.grant_send_messages(parse_objects_handler) + # We still need event bus permissions for failure events + event_bus.grant_put_events_to(parse_objects_handler) + self.log_groups.append(parse_objects_handler.log_group) + + # We should specifically set an alarm for any failures of this handler, since it could otherwise go unnoticed. + Alarm( + self, + 'V1ParserFailureAlarm', + metric=parse_objects_handler.metric_errors(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{parse_objects_handler.node.path} failed to process a bulk upload', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(stack.alarm_topic)) + + self.add_event_notification(event=EventType.OBJECT_CREATED, dest=LambdaDestination(parse_objects_handler)) + stack = ps.PersistentStack.of(self) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(parse_objects_handler.role), + f'{parse_objects_handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but are still scoped to this bucket + and specific actions, KMS key and SQS queue that this lambda specifically needs access to. + """, + }, + ], + ) + # Per-bucket notification permissions are attached as inline HandlerPolicy on the bucket's + # `Notifications` construct as of CDK v2.252.0 (not Role/DefaultPolicy on the stack singleton) so we only + # need to suppress the handler role. See https://github.com/aws/aws-cdk/issues/37667. + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{stack.node.path}/BucketNotificationsHandler050a0587b7544547bf325f094a3db834/Role/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + ], + 'reason': 'The BasicExecutionRole policy is appropriate for this lambda', + }, + ], + ) diff --git a/backend/social-work-app/stacks/persistent_stack/compact_configuration_table.py b/backend/social-work-app/stacks/persistent_stack/compact_configuration_table.py new file mode 100644 index 0000000000..4b9dbcf3d4 --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/compact_configuration_table.py @@ -0,0 +1,69 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_backup import BackupResource +from aws_cdk.aws_dynamodb import ( + Attribute, + AttributeType, + BillingMode, + PointInTimeRecoverySpecification, + Table, + TableEncryption, +) +from aws_cdk.aws_kms import IKey +from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack +from constructs import Construct + + +class CompactConfigurationTable(Table): + """DynamoDB table to house compact configuration data""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + encryption_key: IKey, + removal_policy: RemovalPolicy, + backup_infrastructure_stack: BackupInfrastructureStack, + environment_context: dict, + **kwargs, + ): + super().__init__( + scope, + construct_id, + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=encryption_key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery_specification=PointInTimeRecoverySpecification(point_in_time_recovery_enabled=True), + deletion_protection=True if removal_policy == RemovalPolicy.RETAIN else False, + partition_key=Attribute(name='pk', type=AttributeType.STRING), + sort_key=Attribute(name='sk', type=AttributeType.STRING), + **kwargs, + ) + + # Set up backup plan + backup_enabled = environment_context['backup_enabled'] + if backup_enabled and backup_infrastructure_stack is not None: + self.backup_plan = CCBackupPlan( + self, + 'CompactConfigurationTableBackup', + backup_plan_name_prefix=self.table_name, + backup_resources=[BackupResource.from_dynamo_db_table(self)], + backup_vault=backup_infrastructure_stack.local_backup_vault, + backup_service_role=backup_infrastructure_stack.backup_service_role, + cross_account_backup_vault=backup_infrastructure_stack.cross_account_backup_vault, + backup_policy=environment_context['backup_policies']['general_data'], + ) + else: + self.backup_plan = None + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'This non-production environment has backups disabled intentionally', + }, + ], + ) diff --git a/backend/social-work-app/stacks/persistent_stack/compact_configuration_upload.py b/backend/social-work-app/stacks/persistent_stack/compact_configuration_upload.py new file mode 100644 index 0000000000..57e29792ed --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/compact_configuration_upload.py @@ -0,0 +1,140 @@ +import json +import os + +from aws_cdk import CustomResource, Duration +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.custom_resources import Provider +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from constructs import Construct + +from .compact_configuration_table import CompactConfigurationTable + + +class CompactConfigurationUpload(Construct): + """Custom resource to upload active member jurisdictions data to the compact configuration table.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + table: CompactConfigurationTable, + master_key: IKey, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + stack: Stack = Stack.of(self) + + self.compact_configuration_upload_function = PythonFunction( + scope, + 'CompactConfigurationUploadFunction', + lambda_dir='custom-resources', + index=os.path.join('handlers', 'compact_config_uploader.py'), + handler='on_event', + description='Uploads active member jurisdictions to the compact configuration Dynamo table', + timeout=Duration.minutes(5), + log_retention=RetentionDays.THREE_MONTHS, + environment={'COMPACT_CONFIGURATION_TABLE_NAME': table.table_name, **stack.common_env_vars}, + ) + + # grant lambda access to the compact configuration table + table.grant_read_write_data(self.compact_configuration_upload_function) + # grant lambda access to the KMS key + master_key.grant_encrypt_decrypt(self.compact_configuration_upload_function) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(scope), + path=f'{self.compact_configuration_upload_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + + compact_configuration_upload_provider_log_group = LogGroup( + scope, + 'CompactConfigurationUploadProviderLogGroup', + retention=RetentionDays.ONE_DAY, + ) + NagSuppressions.add_resource_suppressions( + compact_configuration_upload_provider_log_group, + suppressions=[ + { + 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', + 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' + ' logs to operators with credentials for the AWS account is desired. Encryption is not appropriate' + ' here.', + }, + ], + ) + self.compact_configuration_upload_provider = Provider( + scope, + 'CompactConfigurationUploadProvider', + on_event_handler=self.compact_configuration_upload_function, + log_group=compact_configuration_upload_provider_log_group, + ) + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(scope), + f'{self.compact_configuration_upload_provider.node.path}/framework-onEvent/Resource', + [ + {'id': 'AwsSolutions-L1', 'reason': 'We do not control this runtime'}, + { + 'id': 'HIPAA.Security-LambdaConcurrency', + 'reason': 'This function is only run at deploy time, by CloudFormation and has no need for ' + 'concurrency limits.', + }, + { + 'id': 'HIPAA.Security-LambdaDLQ', + 'reason': 'This is a synchronous function run at deploy time. It does not need a DLQ', + }, + { + 'id': 'HIPAA.Security-LambdaInsideVPC', + 'reason': 'We may choose to move our lambdas into private VPC subnets in a future enhancement', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(scope), + path=f'{self.compact_configuration_upload_provider.node.path}' + f'/framework-onEvent/ServiceRole/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what this lambda needs to read ' + 'and is scoped to one table and encryption key.', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + Stack.of(scope), + path=f'{self.compact_configuration_upload_provider.node.path}/framework-onEvent/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], # noqa: E501 line-too-long + 'reason': 'This policy is appropriate for the log retention lambda', + }, + ], + ) + + self.compact_configuration_uploader_custom_resource = CustomResource( + scope, + 'CompactConfigurationUploadCustomResource', + resource_type='Custom::CompactConfigurationUpload', + service_token=self.compact_configuration_upload_provider.service_token, + properties={ + 'active_compact_member_jurisdictions': json.dumps( + self.node.get_context('active_compact_member_jurisdictions') + ), + }, + ) diff --git a/backend/social-work-app/stacks/persistent_stack/data_event_table.py b/backend/social-work-app/stacks/persistent_stack/data_event_table.py new file mode 100644 index 0000000000..e4df370b53 --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/data_event_table.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration, RemovalPolicy +from aws_cdk.aws_backup import BackupResource +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Metric, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_dynamodb import ( + Attribute, + AttributeType, + BillingMode, + PointInTimeRecoverySpecification, + Table, + TableEncryption, +) +from aws_cdk.aws_events import EventPattern, IEventBus, Match, Rule +from aws_cdk.aws_events_targets import SqsQueue +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan +from common_constructs.python_function import PythonFunction +from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack +from constructs import Construct + +from stacks import persistent_stack as ps + + +class DataEventTable(Table): + """ + DynamoDB table to house events related to license data + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + encryption_key: IKey, + removal_policy: RemovalPolicy, + event_bus: IEventBus, + alarm_topic: ITopic, + backup_infrastructure_stack: BackupInfrastructureStack, + environment_context: dict, + **kwargs, + ): + super().__init__( + scope, + construct_id, + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=encryption_key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery_specification=PointInTimeRecoverySpecification(point_in_time_recovery_enabled=True), + deletion_protection=True if removal_policy == RemovalPolicy.RETAIN else False, + partition_key=Attribute(name='pk', type=AttributeType.STRING), + sort_key=Attribute(name='sk', type=AttributeType.STRING), + **kwargs, + ) + stack: ps.PersistentStack = ps.PersistentStack.of(self) + + self.event_handler = PythonFunction( + self, + 'EventHandler', + description='License data event handler', + lambda_dir='data-events', + index=os.path.join('handlers', 'data_events.py'), + handler='handle_data_events', + environment={'DATA_EVENT_TABLE_NAME': self.table_name, **stack.common_env_vars}, + alarm_topic=alarm_topic, + ) + self.grant_read_write_data(self.event_handler) + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.event_handler.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, KMS key and Table that this lambda specifically needs access to. + """, + }, + ], + ) + # We should specifically set an alarm for any failures of this handler, since it could otherwise go unnoticed. + Alarm( + self, + 'EventRecieptFailureAlarm', + metric=self.event_handler.metric_errors(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{self.event_handler.node.path} failed to process a message batch', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(alarm_topic)) + + self.event_processor = QueuedLambdaProcessor( + self, + 'DataSource', + process_function=self.event_handler, + visibility_timeout=Duration.minutes(1), + retention_period=Duration.hours(1), + max_batching_window=Duration.minutes(5), + max_receive_count=3, + batch_size=10, + encryption_key=encryption_key, + alarm_topic=alarm_topic, + ) + + event_receiver_rule = Rule( + self, + 'EventReceiverRule', + event_bus=event_bus, + # match any event detail_type + # https://stackoverflow.com/a/62407802 + event_pattern=EventPattern(detail_type=Match.prefix('')), + targets=[SqsQueue(self.event_processor.queue, dead_letter_queue=self.event_processor.dlq)], + ) + + # We will want to alert on failure of this rule to deliver events to the data events queue + Alarm( + self, + 'DataSourceRuleFailedInvocations', + metric=Metric( + namespace='AWS/Events', + metric_name='FailedInvocations', + dimensions_map={ + 'EventBusName': event_bus.event_bus_name, + 'RuleName': event_receiver_rule.rule_name, + }, + period=Duration.minutes(5), + statistic='Sum', + ), + evaluation_periods=1, + threshold=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(stack.alarm_topic)) + + # Set up backup plan + backup_enabled = environment_context['backup_enabled'] + if backup_enabled and backup_infrastructure_stack is not None: + self.backup_plan = CCBackupPlan( + self, + 'DataEventTableBackup', + backup_plan_name_prefix=self.table_name, + backup_resources=[BackupResource.from_dynamo_db_table(self)], + backup_vault=backup_infrastructure_stack.local_backup_vault, + backup_service_role=backup_infrastructure_stack.backup_service_role, + cross_account_backup_vault=backup_infrastructure_stack.cross_account_backup_vault, + backup_policy=environment_context['backup_policies']['frequent_updates'], + ) + else: + self.backup_plan = None + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'This non-production environment has backups disabled intentionally', + }, + ], + ) diff --git a/backend/social-work-app/stacks/persistent_stack/event_bus.py b/backend/social-work-app/stacks/persistent_stack/event_bus.py new file mode 100644 index 0000000000..fcedd5e0f3 --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/event_bus.py @@ -0,0 +1,25 @@ +from aws_cdk import Duration, Stack +from aws_cdk.aws_events import EventBus as CdkEventBus +from aws_cdk.aws_events import EventPattern +from constructs import Construct + +DEFAULT_ARCHIVE_RETENTION_DURATION = Duration.days(180) + + +class EventBus(CdkEventBus): + def __init__( + self, + scope: Construct, + construct_id: str, + event_bus_name: str, + archive_retention: Duration = DEFAULT_ARCHIVE_RETENTION_DURATION, + **kwargs, + ): + # we explicitly name this resource, so that any future pipeline migrations will not change the namespace + super().__init__(scope, construct_id, event_bus_name=event_bus_name, **kwargs) + self.archive( + f'{construct_id}Archive', + description=f'{construct_id} event archive', + retention=archive_retention, + event_pattern=EventPattern(account=[Stack.of(self).account]), + ) diff --git a/backend/social-work-app/stacks/persistent_stack/provider_table.py b/backend/social-work-app/stacks/persistent_stack/provider_table.py new file mode 100644 index 0000000000..c601cfe03b --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/provider_table.py @@ -0,0 +1,105 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_backup import BackupResource +from aws_cdk.aws_dynamodb import ( + Attribute, + AttributeType, + BillingMode, + PointInTimeRecoverySpecification, + ProjectionType, + StreamViewType, + Table, + TableEncryption, +) +from aws_cdk.aws_kms import Key +from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack +from constructs import Construct + + +class ProviderTable(Table): + """DynamoDB table to house provider information""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + encryption_key: Key, + removal_policy: RemovalPolicy, + backup_infrastructure_stack: BackupInfrastructureStack | None, + environment_context: dict, + **kwargs, + ): + super().__init__( + scope, + construct_id, + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=encryption_key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery_specification=PointInTimeRecoverySpecification(point_in_time_recovery_enabled=True), + deletion_protection=True if removal_policy == RemovalPolicy.RETAIN else False, + partition_key=Attribute(name='pk', type=AttributeType.STRING), + sort_key=Attribute(name='sk', type=AttributeType.STRING), + stream=StreamViewType.NEW_AND_OLD_IMAGES, + **kwargs, + ) + self.provider_fam_giv_mid_index_name = 'providerFamGivMid' + self.provider_date_of_update_index_name = 'providerDateOfUpdate' + self.license_gsi_name = 'licenseGSI' + self.license_upload_date_gsi_name = 'licenseUploadDateGSI' + + self.add_global_secondary_index( + index_name=self.provider_fam_giv_mid_index_name, + partition_key=Attribute(name='sk', type=AttributeType.STRING), + sort_key=Attribute(name='providerFamGivMid', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + self.add_global_secondary_index( + index_name=self.provider_date_of_update_index_name, + partition_key=Attribute(name='sk', type=AttributeType.STRING), + sort_key=Attribute(name='providerDateOfUpdate', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + self.add_global_secondary_index( + index_name=self.license_gsi_name, + partition_key=Attribute(name='licenseGSIPK', type=AttributeType.STRING), + sort_key=Attribute(name='licenseGSISK', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + # in this case, we only need to include the provider id since this GSI is used to + # determine which providers were associated with a particular license upload time + self.add_global_secondary_index( + index_name=self.license_upload_date_gsi_name, + partition_key=Attribute(name='licenseUploadDateGSIPK', type=AttributeType.STRING), + sort_key=Attribute(name='licenseUploadDateGSISK', type=AttributeType.STRING), + projection_type=ProjectionType.INCLUDE, + non_key_attributes=[ + 'providerId', + ], + ) + # Set up backup plan + backup_enabled = environment_context['backup_enabled'] + if backup_enabled and backup_infrastructure_stack is not None: + self.backup_plan = CCBackupPlan( + self, + 'ProviderTableBackup', + backup_plan_name_prefix=self.table_name, + backup_resources=[BackupResource.from_dynamo_db_table(self)], + backup_vault=backup_infrastructure_stack.local_backup_vault, + backup_service_role=backup_infrastructure_stack.backup_service_role, + cross_account_backup_vault=backup_infrastructure_stack.cross_account_backup_vault, + backup_policy=environment_context['backup_policies']['general_data'], + ) + else: + self.backup_plan = None + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'This non-production environment has backups disabled intentionally', + }, + ], + ) diff --git a/backend/social-work-app/stacks/persistent_stack/rate_limiting_table.py b/backend/social-work-app/stacks/persistent_stack/rate_limiting_table.py new file mode 100644 index 0000000000..95b91d457e --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/rate_limiting_table.py @@ -0,0 +1,48 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_dynamodb import AttributeType, BillingMode, PointInTimeRecoverySpecification, Table +from aws_cdk.aws_kms import IKey +from cdk_nag import NagSuppressions +from constructs import Construct + + +class RateLimitingTable(Table): + """DynamoDB table for rate limiting API requests.""" + + def __init__( + self, + scope: Construct, + construct_id: str, + encryption_key: IKey, + removal_policy: RemovalPolicy, + ) -> None: + super().__init__( + scope, + construct_id, + billing_mode=BillingMode.PAY_PER_REQUEST, + encryption_key=encryption_key, + partition_key={'name': 'pk', 'type': AttributeType.STRING}, + sort_key={'name': 'sk', 'type': AttributeType.STRING}, + point_in_time_recovery_specification=PointInTimeRecoverySpecification(point_in_time_recovery_enabled=False), + removal_policy=removal_policy, + time_to_live_attribute='ttl', + ) + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'These records are not intended to be backed up. This table is only for api rate limiting' + ' and all records expire after several days.', + }, + { + 'id': 'HIPAA.Security-DynamoDBPITREnabled', + 'reason': 'These records do not need to be recovered. This table is only for api rate limiting and ' + 'all records expire after several days.', + }, + { + 'id': 'AwsSolutions-DDB3', + 'reason': 'This table does not need Point-in-time Recovery enabled. It is only for api rate ' + 'limiting and all records expire after several days.', + }, + ], + ) diff --git a/backend/social-work-app/stacks/persistent_stack/staff_users.py b/backend/social-work-app/stacks/persistent_stack/staff_users.py new file mode 100644 index 0000000000..7769c11c92 --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/staff_users.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import json + +from aws_cdk import Duration +from aws_cdk.aws_cognito import ( + ClientAttributes, + LambdaVersion, + SignInAliases, + StandardAttribute, + StandardAttributes, + UserPoolEmail, + UserPoolOperation, +) +from aws_cdk.aws_kms import IKey +from cdk_nag import NagSuppressions +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.python_function import PythonFunction +from common_constructs.user_pool import UserPool +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack +from constructs import Construct + +from common_constructs.cognito_user_backup import CognitoUserBackup +from common_constructs.resource_scope_mixin import ResourceScopeMixin +from stacks import persistent_stack as ps +from stacks.persistent_stack.users_table import UsersTable + + +class StaffUsers(UserPool, ResourceScopeMixin): + """User pool for Compact, Board, and CSG staff""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + app_name: str, + environment_name: str, + environment_context: dict, + encryption_key: IKey, + user_pool_email: UserPoolEmail, + notification_from_email: str | None, + ses_identity_arn: str | None, + removal_policy, + backup_infrastructure_stack: BackupInfrastructureStack | None, + **kwargs, + ): + super().__init__( + scope, + construct_id, + environment_name=environment_name, + encryption_key=encryption_key, + sign_in_aliases=SignInAliases(email=True, username=False), + standard_attributes=StandardAttributes(email=StandardAttribute(required=True, mutable=True)), + removal_policy=removal_policy, + email=user_pool_email, + notification_from_email=notification_from_email, + ses_identity_arn=ses_identity_arn, + **kwargs, + ) + stack: ps.PersistentStack = ps.PersistentStack.of(self) + + self.user_table = UsersTable( + self, + 'UsersTable', + encryption_key=encryption_key, + removal_policy=removal_policy, + backup_infrastructure_stack=backup_infrastructure_stack, + environment_context=environment_context, + ) + self._add_resource_servers(stack=stack) + self._add_scope_customization(stack=stack) + self._add_custom_message_lambda(stack=stack) + + # Create a custom domain for the cognito app client + + if stack.hosted_zone: + self.add_custom_app_client_domain( + app_client_domain_prefix='Staff', + scope=self, + hosted_zone=stack.hosted_zone, + ) + else: + staff_prefix = f'{app_name}-staff' + non_custom_domain_prefix = ( + staff_prefix if environment_name == 'prod' else f'{staff_prefix}-{environment_name}' + ) + self.add_default_app_client_domain(non_custom_domain_prefix=non_custom_domain_prefix) + + # Do not allow resource server scopes via the client - they are assigned via token customization + # to allow for user attribute-based access + self.ui_client = self.add_ui_client( + ui_domain_name=stack.ui_domain_name, + environment_context=environment_context, + # We have to provide one True value or CFn will make every attribute writeable + write_attributes=ClientAttributes().with_standard_attributes(email=True), + # We want to limit the attributes that this app can read and write so only email is visible. + read_attributes=ClientAttributes().with_standard_attributes(email=True), + ) + + # Check if backups are enabled for this environment + backup_enabled = environment_context['backup_enabled'] + + if backup_enabled and backup_infrastructure_stack is not None: + # Set up Cognito backup system for this user pool + self.backup_system = CognitoUserBackup( + self, + 'StaffUserBackup', + user_pool_id=self.user_pool_id, + access_logs_bucket=stack.access_logs_bucket, + encryption_key=encryption_key, + removal_policy=removal_policy, + backup_infrastructure_stack=backup_infrastructure_stack, + alarm_topic=stack.alarm_topic, + environment_context=environment_context, + ) + else: + # Create placeholder attribute for disabled state + self.backup_system = None + + def _add_scope_customization(self, stack: ps.PersistentStack): + """Add scopes to access tokens based on the Users table""" + compacts = self.node.get_context('compacts') + jurisdictions = self.node.get_context('jurisdictions') + + scope_customization_handler = PythonFunction( + self, + 'ScopeCustomizationHandler', + description='Auth scope customization handler', + lambda_dir='staff-user-pre-token', + index='main.py', + handler='customize_scopes', + alarm_topic=stack.alarm_topic, + environment={ + 'DEBUG': 'true', + 'USERS_TABLE_NAME': self.user_table.table_name, + 'COMPACTS': json.dumps(compacts), + 'JURISDICTIONS': json.dumps(jurisdictions), + **stack.common_env_vars, + }, + ) + self.user_table.grant_read_write_data(scope_customization_handler) + + NagSuppressions.add_resource_suppressions( + scope_customization_handler.role, + apply_to_children=True, + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This lambda role policy contains wildcards in its statements, but all of its actions + are limited specifically to the actions and the Table it needs read access to. + """, + }, + ], + ) + self.add_trigger( + UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG, + scope_customization_handler, + lambda_version=LambdaVersion.V2_0, + ) + + def _add_custom_message_lambda(self, stack: ps.PersistentStack): + """Add a custom message lambda to the user pool""" + + from_address = 'NONE' + if stack.hosted_zone: + from_address = f'noreply@{stack.user_email_notifications.email_identity.email_identity_name}' + + self.custom_message_lambda = NodejsFunction( + self, + 'CustomMessageLambda', + description='Cognito custom message lambda', + lambda_dir='cognito-emails', + handler='customMessage', + timeout=Duration.minutes(1), + environment={ + 'FROM_ADDRESS': from_address, + 'COMPACT_CONFIGURATION_TABLE_NAME': stack.compact_configuration_table.table_name, + 'UI_BASE_PATH_URL': stack.get_ui_base_path_url(), + 'USER_POOL_TYPE': 'staff', + **stack.common_env_vars, + }, + ) + + self.add_trigger( + UserPoolOperation.CUSTOM_MESSAGE, + self.custom_message_lambda, + ) diff --git a/backend/social-work-app/stacks/persistent_stack/user_email_notifications.py b/backend/social-work-app/stacks/persistent_stack/user_email_notifications.py new file mode 100644 index 0000000000..edf490cd66 --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/user_email_notifications.py @@ -0,0 +1,230 @@ +import os + +from aws_cdk import CustomResource, Duration +from aws_cdk.aws_iam import PolicyStatement, ServicePrincipal +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_route53 import IHostedZone, TxtRecord +from aws_cdk.aws_ses import ConfigurationSet, EmailIdentity, EmailSendingEvent, EventDestination, Identity +from aws_cdk.aws_sns import Subscription, SubscriptionProtocol, Topic +from aws_cdk.custom_resources import Provider +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from constructs import Construct + + +class UserEmailNotifications(Construct): + """This Construct leverages SES to set up an email notification system to send cognito user events from our custom + domain with necessary SPF, DKIM, and DMARC verification records. + + The topic is set up to forward all bounce and complaint events to the provided email address. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + hosted_zone: IHostedZone, + environment_context: dict, + master_key: IKey, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + domain_name = hosted_zone.zone_name + operation_email = environment_context['notifications']['ses_operations_support_email'] + + self.email_feedback_topic = Topic( + self, + 'FeedbackTopic', + display_name='Email Feedback Forwarding Topic', + master_key=master_key, + enforce_ssl=True, + ) + Subscription( + self, + 'FeedbackSubscription', + topic=self.email_feedback_topic, + protocol=SubscriptionProtocol.EMAIL, + endpoint=operation_email, + ) + + self.config_set = ConfigurationSet(self, 'ConfigSet') + + self.config_set.add_event_destination( + id='EmailFeedbackEventDestination', + destination=EventDestination.sns_topic(self.email_feedback_topic), + enabled=True, + events=[EmailSendingEvent.BOUNCE, EmailSendingEvent.COMPLAINT], + ) + + ses_principal = ServicePrincipal('ses.amazonaws.com') + self.email_feedback_topic.grant_publish(ses_principal) + # grant SES the ability to encrypt bounce and complaint notifications using the KMS key + master_key.grant_encrypt_decrypt(ses_principal) + + # Create SES Email Identity with DKIM enabled + self.email_identity = EmailIdentity( + self, + 'EmailIdentity', + # by using the hosted zone resource, cdk will automatically + # create all the necessary DNS records for DKIM and SPF authentication + identity=Identity.public_hosted_zone(hosted_zone), + mail_from_domain=f'no-reply.{domain_name}', + configuration_set=self.config_set, + ) + # grant cognito the ability to send email from this identity + self.email_identity.grant_send_email(ServicePrincipal('cognito-idp.amazonaws.com')) + + # Add SPF record for root domain + self.spf_record = TxtRecord( + self, + 'SPFRecord', + zone=hosted_zone, + record_name=f'{domain_name}', + values=['v=spf1 include:amazonses.com ~all'], + ) + + # Add DMARC record to Route 53 with policy set to 'reject' + # this will cause email servers to reject emails that do not pass SPF and DKIM checks + # see https://docs.aws.amazon.com/ses/latest/dg/send-email-authentication-dmarc.html + self.dmarc_record = TxtRecord( + self, + 'DMARCRecord', + zone=hosted_zone, + record_name=f'_dmarc.{domain_name}', + values=[f'v=DMARC1;p=reject;rua=mailto:{operation_email}'], + ) + + # Create a custom resource that verifies the SES identity is verified + self.verification_custom_resource = self._create_verification_custom_resource(domain_name) + + # Add dependencies to ensure the verification custom resource is created after the SES identity + self.verification_custom_resource.node.add_dependency(self.email_identity) + self.verification_custom_resource.node.add_dependency(self.dmarc_record) + + def _create_verification_custom_resource(self, domain_name: str) -> CustomResource: + """Create a custom resource that verifies the SES identity is verified.""" + stack = Stack.of(self) + + # Create a Lambda function that checks the verification status of the SES identity + verification_function = PythonFunction( + self, + 'DomainVerificationFunction', + lambda_dir='custom-resources', + index=os.path.join('handlers', 'ses_email_identity_verification_handler.py'), + handler='on_event', + description='Verifies that a SES email identity is verified', + timeout=Duration.minutes(15), # Long timeout to allow for verification + memory_size=128, + log_retention=RetentionDays.ONE_DAY, + environment={ + 'DOMAIN_NAME': domain_name, + **stack.common_env_vars, + }, + ) + + # Grant the Lambda function permission to check the verification status + verification_function.add_to_role_policy( + PolicyStatement( + actions=['ses:GetIdentityVerificationAttributes'], + resources=['*'], # SES doesn't support resource-level permissions for this action + ) + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{verification_function.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically what ' + 'this lambda needs to check SES identity verification status.', + }, + ], + ) + + # Create a provider for the custom resource + verification_provider_log_group = LogGroup( + self, + 'VerificationProviderLogGroup', + retention=RetentionDays.ONE_DAY, + ) + NagSuppressions.add_resource_suppressions( + verification_provider_log_group, + suppressions=[ + { + 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', + 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' + ' logs to operators with credentials for the AWS account is desired. Encryption is not appropriate' + ' here.', + }, + ], + ) + verification_provider = Provider( + self, + 'VerificationProvider', + on_event_handler=verification_function, + log_group=verification_provider_log_group, + ) + + # Add suppressions for the provider + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{verification_provider.node.path}/framework-onEvent/Resource', + [ + {'id': 'AwsSolutions-L1', 'reason': 'We do not control this runtime'}, + { + 'id': 'HIPAA.Security-LambdaConcurrency', + 'reason': 'This function is only run at deploy time, ' + 'by CloudFormation and has no need for concurrency limits.', + }, + { + 'id': 'HIPAA.Security-LambdaDLQ', + 'reason': 'This is a synchronous function run at deploy time. It does not need a DLQ', + }, + { + 'id': 'HIPAA.Security-LambdaInsideVPC', + 'reason': 'We may choose to move our lambdas into private VPC subnets in a future enhancement', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{verification_provider.node.path}/framework-onEvent/ServiceRole/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The actions in this policy are specifically ' + 'what this lambda needs to check SES identity verification status.', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{verification_provider.node.path}/framework-onEvent/ServiceRole/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], + 'reason': 'This policy is needed for the custom resource provider to manage the SES verification.', + }, + ], + ) + + # Create the custom resource + return CustomResource( + self, + 'VerificationResource', + resource_type='Custom::SESIdentityVerification', + service_token=verification_provider.service_token, + properties={ + 'DomainName': domain_name, + }, + ) diff --git a/backend/social-work-app/stacks/persistent_stack/users_table.py b/backend/social-work-app/stacks/persistent_stack/users_table.py new file mode 100644 index 0000000000..ce0ab823fb --- /dev/null +++ b/backend/social-work-app/stacks/persistent_stack/users_table.py @@ -0,0 +1,81 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_backup import BackupResource +from aws_cdk.aws_dynamodb import ( + Attribute, + AttributeType, + BillingMode, + PointInTimeRecoverySpecification, + ProjectionType, + Table, + TableEncryption, +) +from aws_cdk.aws_kms import IKey +from cdk_nag import NagSuppressions +from common_constructs.backup_plan import CCBackupPlan +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack +from constructs import Construct + + +class UsersTable(Table): + """DynamoDB table to house staff user permissions data""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + encryption_key: IKey, + removal_policy: RemovalPolicy, + backup_infrastructure_stack: BackupInfrastructureStack | None, + environment_context: dict, + **kwargs, + ): + super().__init__( + scope, + construct_id, + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=encryption_key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery_specification=PointInTimeRecoverySpecification(point_in_time_recovery_enabled=True), + deletion_protection=True if removal_policy == RemovalPolicy.RETAIN else False, + partition_key=Attribute(name='pk', type=AttributeType.STRING), + sort_key=Attribute(name='sk', type=AttributeType.STRING), + **kwargs, + ) + self.family_given_index_name = 'famGiv' + + self.add_global_secondary_index( + index_name=self.family_given_index_name, + partition_key=Attribute(name='sk', type=AttributeType.STRING), + sort_key=Attribute(name='famGiv', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + + # Check if backups are enabled for this environment + backup_enabled = environment_context['backup_enabled'] + + if backup_enabled and backup_infrastructure_stack is not None: + # Set up backup plan + self.backup_plan = CCBackupPlan( + self, + 'UsersTableBackup', + backup_plan_name_prefix=self.table_name, + backup_resources=[BackupResource.from_dynamo_db_table(self)], + backup_vault=backup_infrastructure_stack.local_backup_vault, + backup_service_role=backup_infrastructure_stack.backup_service_role, + cross_account_backup_vault=backup_infrastructure_stack.cross_account_backup_vault, + backup_policy=environment_context['backup_policies']['general_data'], + ) + else: + # Create placeholder attribute for disabled state + self.backup_plan = None + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'This non-production environment has backups disabled intentionally', + }, + ], + ) diff --git a/backend/social-work-app/stacks/reporting_stack.py b/backend/social-work-app/stacks/reporting_stack.py new file mode 100644 index 0000000000..5979c7142e --- /dev/null +++ b/backend/social-work-app/stacks/reporting_stack.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import Rule, RuleTargetInput, Schedule +from aws_cdk.aws_events_targets import LambdaFunction +from aws_cdk.aws_logs import QueryDefinition, QueryString +from cdk_nag import NagSuppressions +from common_constructs.nodejs_function import NodejsFunction +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack as ps + + +class ReportingStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + self._add_ingest_event_reporting_chain(persistent_stack) + + def _add_ingest_event_reporting_chain(self, persistent_stack: ps.PersistentStack): + from_address = f'noreply@{persistent_stack.user_email_notifications.email_identity.email_identity_name}' + # we host email image assets in the UI bucket, so we'll use the UI domain name if it's available + ui_base_path_url = self._get_ui_base_path_url() + + # We use a Node.js function in this case because the tool we identified for email report generation, + # EmailBuilderJS, is in Node.js. To make utilizing the tool as simple as possible, we opted to not mix + # languages in the Lambda. + event_collector = NodejsFunction( + self, + 'IngestEventCollector', + description='Ingest event collector', + lambda_dir='ingest-event-reporter', + handler='collectEvents', + timeout=Duration.minutes(15), + memory_size=2048, + environment={ + 'FROM_ADDRESS': from_address, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + 'DATA_EVENT_TABLE_NAME': persistent_stack.data_event_table.table_name, + 'UI_BASE_PATH_URL': ui_base_path_url, + **self.common_env_vars, + }, + ) + persistent_stack.data_event_table.grant_read_data(event_collector) + persistent_stack.compact_configuration_table.grant_read_data(event_collector) + persistent_stack.setup_ses_permissions_for_lambda(event_collector) + + NagSuppressions.add_resource_suppressions_by_path( + self, + f'{event_collector.role.node.path}/DefaultPolicy/Resource', + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': """ + This policy contains wild-carded actions and resources but they are scoped to the + specific actions, KMS key, Table, and Email Identity that this lambda specifically needs access to. + """, + }, + ], + ) + # We should specifically set an alarm for any failures of this handler, since it could otherwise go unnoticed. + Alarm( + self, + 'EventCollectorFailure', + metric=event_collector.metric_errors(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{event_collector.node.path} failed to process an event', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + # This will report any ingest errors to the configured operational contact, every 15 minutes + Rule( + self, + 'FrequentlyRule', + schedule=Schedule.cron(week_day='*', hour='*', minute='*/15', month='*', year='*'), + targets=[ + LambdaFunction(handler=event_collector, event=RuleTargetInput.from_object({'eventType': 'frequent'})) + ], + ) + + # This will send an "alls well" , a "you haven't uploaded anything" email or nothing + Rule( + self, + 'WeeklyRule', + schedule=Schedule.cron(week_day='7', hour='1', minute='0', month='*', year='*'), + targets=[ + LambdaFunction(handler=event_collector, event=RuleTargetInput.from_object({'eventType': 'weekly'})) + ], + ) + + # If the max function execution time is approaching its max timeout + Alarm( + self, + 'DurationAlarm', + metric=event_collector.metric_duration(statistic=Stats.MAXIMUM, period=Duration.days(1)), + evaluation_periods=1, + threshold=600_000, # 10 minutes + actions_enabled=True, + alarm_description=f'{self.node.path} Lambda Duration', + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + QueryDefinition( + self, + 'RuntimeQuery', + query_definition_name=f'{self.node.id}/Lambdas', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'message', 'compact', 'jurisdiction', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=[event_collector.log_group], + ) + + def _get_ui_base_path_url(self) -> str: + """Returns the base URL for the UI.""" + if self.ui_domain_name is not None: + return f'https://{self.ui_domain_name}' + + # default to csg test environment + return 'https://app.test.compactconnect.org' diff --git a/backend/social-work-app/stacks/search_api_stack/__init__.py b/backend/social-work-app/stacks/search_api_stack/__init__.py new file mode 100644 index 0000000000..b3f84e9b5e --- /dev/null +++ b/backend/social-work-app/stacks/search_api_stack/__init__.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from common_constructs.security_profile import SecurityProfile +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack, search_persistent_stack + +from .api import SearchApi + + +class SearchApiStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + environment_context: dict, + persistent_stack: persistent_stack.PersistentStack, + search_persistent_stack: search_persistent_stack.SearchPersistentStack, + **kwargs, + ): + super().__init__( + scope, construct_id, environment_context=environment_context, environment_name=environment_name, **kwargs + ) + + security_profile = SecurityProfile[environment_context.get('security_profile', 'RECOMMENDED')] + + self.api = SearchApi( + self, + 'SearchApi', + environment_name=environment_name, + security_profile=security_profile, + persistent_stack=persistent_stack, + search_persistent_stack=search_persistent_stack, + domain_name=self.search_api_domain_name, + ) diff --git a/backend/social-work-app/stacks/search_api_stack/api.py b/backend/social-work-app/stacks/search_api_stack/api.py new file mode 100644 index 0000000000..27e8f55336 --- /dev/null +++ b/backend/social-work-app/stacks/search_api_stack/api.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from functools import cached_property + +from common_constructs.compact_connect_api import CompactConnectApi +from constructs import Construct + +from stacks import persistent_stack, search_persistent_stack + + +class SearchApi(CompactConnectApi): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + persistent_stack: persistent_stack.PersistentStack, + search_persistent_stack: search_persistent_stack.SearchPersistentStack, + **kwargs, + ): + super().__init__( + scope, + construct_id, + alarm_topic=persistent_stack.alarm_topic, + staff_users_user_pool=persistent_stack.staff_users, + **kwargs, + ) + from stacks.search_api_stack.v1_api import V1Api + + self.v1_api = V1Api( + self.root, persistent_stack=persistent_stack, search_persistent_stack=search_persistent_stack + ) + + @cached_property + def staff_users_authorizer(self): + from aws_cdk.aws_apigateway import CognitoUserPoolsAuthorizer + + return CognitoUserPoolsAuthorizer(self, 'StaffUsersPoolAuthorizer', cognito_user_pools=[self.staff_users]) diff --git a/backend/social-work-app/stacks/search_api_stack/v1_api/__init__.py b/backend/social-work-app/stacks/search_api_stack/v1_api/__init__.py new file mode 100644 index 0000000000..e14e23d9ed --- /dev/null +++ b/backend/social-work-app/stacks/search_api_stack/v1_api/__init__.py @@ -0,0 +1,4 @@ +# ruff: noqa: F401 +# We place this import here so it can be referenced by other +# CDK resources +from .api import V1Api diff --git a/backend/social-work-app/stacks/search_api_stack/v1_api/api.py b/backend/social-work-app/stacks/search_api_stack/v1_api/api.py new file mode 100644 index 0000000000..72f6e63b59 --- /dev/null +++ b/backend/social-work-app/stacks/search_api_stack/v1_api/api.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from aws_cdk.aws_apigateway import AuthorizationType, IResource, MethodOptions + +from stacks import persistent_stack, search_persistent_stack +from stacks.search_api_stack.v1_api.provider_search import ProviderSearch + +from .api_model import ApiModel + + +class V1Api: + """v1 of the Search API""" + + def __init__( + self, + root: IResource, + persistent_stack: persistent_stack.PersistentStack, + search_persistent_stack: search_persistent_stack.SearchPersistentStack, + ): + super().__init__() + from stacks.search_api_stack.api import SearchApi + + self.root = root + self.resource = root.add_resource('v1') + self.api: SearchApi = root.api + self.api_model = ApiModel(api=self.api) + _active_compacts = persistent_stack.get_list_of_compact_abbreviations() + + read_scopes = [] + # set the compact level scopes + for compact in _active_compacts: + # We only set the readGeneral permission scope at the compact level, since users with any permissions + # within a compact are implicitly granted this scope + read_scopes.append(f'{compact}/readGeneral') + + read_auth_method_options = MethodOptions( + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.staff_users_authorizer, + authorization_scopes=read_scopes, + ) + + # /v1/compacts + self.compacts_resource = self.resource.add_resource('compacts') + # /v1/compacts/{compact} + self.compact_resource = self.compacts_resource.add_resource('{compact}') + + # POST /v1/compacts/{compact}/providers + providers_resource = self.compact_resource.add_resource('providers') + self.provider_search = ProviderSearch( + resource=providers_resource, + method_options=read_auth_method_options, + search_persistent_stack=search_persistent_stack, + api_model=self.api_model, + ) diff --git a/backend/social-work-app/stacks/search_api_stack/v1_api/api_model.py b/backend/social-work-app/stacks/search_api_stack/v1_api/api_model.py new file mode 100644 index 0000000000..7b72ec5956 --- /dev/null +++ b/backend/social-work-app/stacks/search_api_stack/v1_api/api_model.py @@ -0,0 +1,417 @@ +# ruff: noqa: SLF001 +# This class initializes the api models for the root api, which we then want to set as protected +# so other classes won't modify it. This is a valid use case for protected access to work with cdk. +from __future__ import annotations + +from aws_cdk.aws_apigateway import JsonSchema, JsonSchemaType, Model +from common_constructs.stack import AppStack + +# Importing module level to allow lazy loading for typing +from common_constructs import compact_connect_api + + +class ApiModel: + """This class is responsible for defining the model definitions used in the Search API endpoints.""" + + def __init__(self, api: compact_connect_api.CompactConnectApi): + self.stack: AppStack = AppStack.of(api) + self.api = api + + @property + def _common_search_request_schema(self) -> JsonSchema: + """ + Return the common search request schema used by both provider and privilege search endpoints. + + This schema closely mirrors OpenSearch DSL for pagination using search_after. + See: https://docs.opensearch.org/latest/search-plugins/searching-data/paginate/ + """ + return JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=False, + required=['query'], + properties={ + 'query': JsonSchema( + type=JsonSchemaType.OBJECT, + description='The OpenSearch query body', + ), + 'from': JsonSchema( + type=JsonSchemaType.INTEGER, + minimum=0, + description='Starting document offset for pagination', + ), + 'size': JsonSchema( + type=JsonSchemaType.INTEGER, + minimum=1, + # setting low limit for now, as this search endpoint is only used by the UI client, + # and we don't anticipate needing to support more than 100 records per request + maximum=100, + description='Number of results to return', + ), + 'sort': JsonSchema( + type=JsonSchemaType.ARRAY, + description='Sort order for results (required for search_after pagination)', + items=JsonSchema(type=JsonSchemaType.OBJECT), + ), + 'search_after': JsonSchema( + type=JsonSchemaType.ARRAY, + description='Sort values from the last hit of the previous page for cursor-based pagination', + ), + }, + ) + + @property + def search_providers_request_model(self) -> Model: + """ + Return the search providers request model, which should only be created once per API. + """ + if hasattr(self.api, '_v1_search_providers_request_model'): + return self.api._v1_search_providers_request_model + self.api._v1_search_providers_request_model = self.api.add_model( + 'V1SearchProvidersRequestModel', + description='Search providers request model following OpenSearch DSL', + schema=self._common_search_request_schema, + ) + return self.api._v1_search_providers_request_model + + @property + def _search_response_total_schema(self) -> JsonSchema: + """Return the common total hits schema used by search response models""" + return JsonSchema( + type=JsonSchemaType.OBJECT, + description='Total hits information from OpenSearch', + properties={ + 'value': JsonSchema(type=JsonSchemaType.INTEGER), + 'relation': JsonSchema(type=JsonSchemaType.STRING, enum=['eq', 'gte']), + }, + ) + + @property + def search_providers_response_model(self) -> Model: + """Return the search providers response model, which should only be created once per API""" + if hasattr(self.api, '_v1_search_providers_response_model'): + return self.api._v1_search_providers_response_model + self.api._v1_search_providers_response_model = self.api.add_model( + 'V1SearchProvidersResponseModel', + description='Search providers response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['providers', 'total'], + properties={ + 'providers': JsonSchema( + type=JsonSchemaType.ARRAY, + items=self._providers_response_schema, + ), + 'total': self._search_response_total_schema, + 'lastSort': JsonSchema( + type=JsonSchemaType.ARRAY, + description='Sort values from the last hit to use with search_after for the next page', + ), + }, + ), + ) + return self.api._v1_search_providers_response_model + + @property + def _providers_response_schema(self): + stack: AppStack = AppStack.of(self.api) + + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'givenName', + 'familyName', + 'licenseStatus', + 'compactEligibility', + 'jurisdictionUploadedLicenseStatus', + 'jurisdictionUploadedCompactEligibility', + 'compact', + 'licenseJurisdiction', + 'dateOfUpdate', + 'dateOfExpiration', + 'birthMonthDay', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['provider']), + 'providerId': JsonSchema( + type=JsonSchemaType.STRING, + pattern=compact_connect_api.UUID4_FORMAT, + ), + 'givenName': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + ), + 'middleName': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + ), + 'familyName': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + ), + 'suffix': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + ), + 'licenseStatus': JsonSchema( + type=JsonSchemaType.STRING, + enum=['active', 'inactive'], + ), + 'compactEligibility': JsonSchema( + type=JsonSchemaType.STRING, + enum=['eligible', 'ineligible'], + ), + 'jurisdictionUploadedLicenseStatus': JsonSchema( + type=JsonSchemaType.STRING, + enum=['active', 'inactive'], + ), + 'jurisdictionUploadedCompactEligibility': JsonSchema( + type=JsonSchemaType.STRING, + enum=['eligible', 'ineligible'], + ), + 'compact': JsonSchema( + type=JsonSchemaType.STRING, + enum=stack.node.get_context('compacts'), + ), + 'licenseJurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=stack.node.get_context('jurisdictions'), + ), + 'currentHomeJurisdiction': JsonSchema( + type=JsonSchemaType.STRING, + enum=stack.node.get_context('jurisdictions'), + ), + 'dateOfUpdate': JsonSchema( + type=JsonSchemaType.STRING, + format='date-time', + ), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, + format='date', + ), + 'birthMonthDay': JsonSchema( + type=JsonSchemaType.STRING, + pattern='^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}', + ), + 'compactConnectRegisteredEmailAddress': JsonSchema( + type=JsonSchemaType.STRING, + format='email', + ), + 'licenses': JsonSchema( + type=JsonSchemaType.ARRAY, + items=self._license_general_response_schema, + ), + 'privileges': JsonSchema( + type=JsonSchemaType.ARRAY, + items=self._privilege_general_response_schema, + ), + }, + ) + + @property + def _license_general_response_schema(self): + """ + Schema for LicenseGeneralResponseSchema - license fields visible to staff users + with 'readGeneral' permission. + """ + stack: AppStack = AppStack.of(self.api) + + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'providerId', + 'type', + 'dateOfUpdate', + 'compact', + 'jurisdiction', + 'licenseType', + 'licenseStatus', + 'jurisdictionUploadedLicenseStatus', + 'compactEligibility', + 'jurisdictionUploadedCompactEligibility', + 'givenName', + 'familyName', + 'dateOfIssuance', + 'dateOfExpiration', + 'homeAddressStreet1', + 'homeAddressCity', + 'homeAddressState', + 'homeAddressPostalCode', + 'licenseNumber', + ], + properties={ + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['license-home']), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'jurisdiction': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions')), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING), + 'licenseStatusName': JsonSchema(type=JsonSchemaType.STRING, max_length=100), + 'licenseStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'jurisdictionUploadedLicenseStatus': JsonSchema( + type=JsonSchemaType.STRING, enum=['active', 'inactive'] + ), + 'compactEligibility': JsonSchema(type=JsonSchemaType.STRING, enum=['eligible', 'ineligible']), + 'jurisdictionUploadedCompactEligibility': JsonSchema( + type=JsonSchemaType.STRING, enum=['eligible', 'ineligible'] + ), + 'licenseNumber': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'givenName': JsonSchema(type=JsonSchemaType.STRING, max_length=100), + 'middleName': JsonSchema(type=JsonSchemaType.STRING, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, max_length=100), + 'suffix': JsonSchema(type=JsonSchemaType.STRING, max_length=100), + 'dateOfIssuance': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfRenewal': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'homeAddressStreet1': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressStreet2': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'homeAddressCity': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressState': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressPostalCode': JsonSchema(type=JsonSchemaType.STRING, min_length=5, max_length=7), + 'emailAddress': JsonSchema(type=JsonSchemaType.STRING, format='email'), + 'phoneNumber': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.PHONE_NUMBER_FORMAT), + 'adverseActions': JsonSchema(type=JsonSchemaType.ARRAY, items=self._adverse_action_general_schema), + 'investigations': JsonSchema(type=JsonSchemaType.ARRAY, items=self._investigation_general_schema), + 'investigationStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['underInvestigation']), + }, + ) + + @property + def _privilege_general_response_schema(self): + """ + Schema for PrivilegeGeneralResponseSchema - privilege fields visible to staff users + with 'readGeneral' permission. + """ + stack: AppStack = AppStack.of(self.api) + + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'providerId', + 'compact', + 'jurisdiction', + 'licenseJurisdiction', + 'licenseType', + 'dateOfExpiration', + 'administratorSetStatus', + 'status', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['privilege']), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'jurisdiction': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions')), + 'licenseJurisdiction': JsonSchema( + type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions') + ), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'adverseActions': JsonSchema(type=JsonSchemaType.ARRAY, items=self._adverse_action_general_schema), + 'investigations': JsonSchema(type=JsonSchemaType.ARRAY, items=self._investigation_general_schema), + 'administratorSetStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'compactTransactionId': JsonSchema(type=JsonSchemaType.STRING), + 'status': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'investigationStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['underInvestigation']), + }, + ) + + @property + def _adverse_action_general_schema(self): + """ + Schema for AdverseActionGeneralResponseSchema - adverse action fields visible + to staff users with 'readGeneral' permission. + """ + stack: AppStack = AppStack.of(self.api) + + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'compact', + 'providerId', + 'jurisdiction', + 'licenseTypeAbbreviation', + 'licenseType', + 'actionAgainst', + 'effectiveStartDate', + 'creationDate', + 'adverseActionId', + 'dateOfUpdate', + 'encumbranceType', + 'submittingUser', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['adverseAction']), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'jurisdiction': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions')), + 'licenseTypeAbbreviation': JsonSchema(type=JsonSchemaType.STRING), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING), + 'actionAgainst': JsonSchema(type=JsonSchemaType.STRING, enum=['license', 'privilege']), + 'effectiveStartDate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'creationDate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'adverseActionId': JsonSchema(type=JsonSchemaType.STRING), + 'effectiveLiftDate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'encumbranceType': JsonSchema(type=JsonSchemaType.STRING), + 'clinicalPrivilegeActionCategories': JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.STRING, + enum=['fraud', 'consumer harm', 'other'], + ), + ), + 'liftingUser': JsonSchema(type=JsonSchemaType.STRING), + 'submittingUser': JsonSchema(type=JsonSchemaType.STRING), + }, + ) + + @property + def _investigation_general_schema(self): + """ + Schema for InvestigationGeneralResponseSchema - investigation fields visible + to staff users with 'readGeneral' permission. + """ + stack: AppStack = AppStack.of(self.api) + + return JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'type', + 'compact', + 'providerId', + 'investigationId', + 'jurisdiction', + 'licenseType', + 'dateOfUpdate', + 'creationDate', + 'submittingUser', + ], + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['investigation']), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.UUID4_FORMAT), + 'investigationId': JsonSchema(type=JsonSchemaType.STRING), + 'jurisdiction': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions')), + 'licenseType': JsonSchema(type=JsonSchemaType.STRING), + 'dateOfUpdate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'creationDate': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'submittingUser': JsonSchema(type=JsonSchemaType.STRING), + }, + ) diff --git a/backend/social-work-app/stacks/search_api_stack/v1_api/provider_search.py b/backend/social-work-app/stacks/search_api_stack/v1_api/provider_search.py new file mode 100644 index 0000000000..3d7b9bc28c --- /dev/null +++ b/backend/social-work-app/stacks/search_api_stack/v1_api/provider_search.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from aws_cdk import Duration +from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource +from common_constructs.compact_connect_api import CompactConnectApi + +from stacks import search_persistent_stack + +from .api_model import ApiModel + + +class ProviderSearch: + """ + Endpoint related to provider searching in the OpenSearch domain. + """ + + def __init__( + self, + *, + resource: Resource, + method_options: MethodOptions, + search_persistent_stack: search_persistent_stack.SearchPersistentStack, + api_model: ApiModel, + ): + super().__init__() + + self.resource = resource + self.api: CompactConnectApi = resource.api + self.api_model = api_model + + # Create the nested resources used by endpoints + self.provider_resource = self.resource.add_resource('{providerId}') + + self._add_search_providers( + method_options=method_options, + search_persistent_stack=search_persistent_stack, + ) + + def _add_search_providers( + self, + method_options: MethodOptions, + search_persistent_stack: search_persistent_stack.SearchPersistentStack, + ): + search_resource = self.resource.add_resource('search') + + # Get the search providers handler from the search persistent stack + handler = search_persistent_stack.search_handler.handler + + self.provider_search_endpoint = search_resource.add_method( + 'POST', + request_validator=self.api.parameter_body_validator, + request_models={'application/json': self.api_model.search_providers_request_model}, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.search_providers_response_model}, + ), + ], + integration=LambdaIntegration(handler, timeout=Duration.seconds(29)), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/__init__.py b/backend/social-work-app/stacks/search_persistent_stack/__init__.py new file mode 100644 index 0000000000..b214049e76 --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/__init__.py @@ -0,0 +1,183 @@ +from aws_cdk.aws_iam import Role, ServicePrincipal +from aws_cdk.aws_logs import QueryDefinition, QueryString +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks.persistent_stack import PersistentStack +from stacks.search_persistent_stack.export_results_bucket import ExportResultsBucket +from stacks.search_persistent_stack.index_manager import IndexManagerCustomResource +from stacks.search_persistent_stack.populate_provider_documents_handler import PopulateProviderDocumentsHandler +from stacks.search_persistent_stack.provider_search_domain import ProviderSearchDomain +from stacks.search_persistent_stack.provider_update_ingest_handler import ProviderUpdateIngestHandler +from stacks.search_persistent_stack.provider_update_ingest_pipe import ProviderUpdateIngestPipe +from stacks.search_persistent_stack.search_handler import SearchHandler +from stacks.vpc_stack import VpcStack + + +class SearchPersistentStack(AppStack): + """ + Stack for OpenSearch Domain and related search infrastructure. + + This stack provides the search capabilities for the advanced provider search feature: + - OpenSearch Domain deployed in VPC for network isolation + - KMS encryption for data at rest + - Node-to-node encryption and HTTPS enforcement + - Environment-specific instance sizing and cluster configuration + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + environment_context: dict, + vpc_stack: VpcStack, + persistent_stack: PersistentStack, + **kwargs, + ): + super().__init__( + scope, construct_id, environment_context=environment_context, environment_name=environment_name, **kwargs + ) + + # Create IAM roles for Lambda functions that need OpenSearch access + self.opensearch_ingest_lambda_role = Role( + self, + 'OpenSearchIngestLambdaRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='IAM role for Ingest Lambda function that needs write access to OpenSearch Domain', + ) + + self.opensearch_index_manager_lambda_role = Role( + self, + 'OpenSearchIndexManagerLambdaRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='IAM role for index manager Lambda function that needs read/write access to OpenSearch Domain', + ) + + # Create IAM role for Lambda functions that access OpenSearch through API + # this role only needs read access + self.search_api_lambda_role = Role( + self, + 'SearchApiLambdaRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='IAM role for Search API Lambda functions that need read access to OpenSearch Domain', + ) + + # Create the OpenSearch domain and associated resources + self.provider_search_domain = ProviderSearchDomain( + self, + 'ProviderSearchDomain', + environment_name=environment_name, + region=self.region, + vpc_stack=vpc_stack, + compact_abbreviations=persistent_stack.get_list_of_compact_abbreviations(), + alarm_topic=persistent_stack.alarm_topic, + ingest_lambda_role=self.opensearch_ingest_lambda_role, + index_manager_lambda_role=self.opensearch_index_manager_lambda_role, + search_api_lambda_role=self.search_api_lambda_role, + ) + + # Expose domain and encryption key for use by other constructs + self.domain = self.provider_search_domain.domain + self.opensearch_encryption_key = self.provider_search_domain.encryption_key + + # Create the export results bucket for temporary CSV files + self.export_results_bucket = ExportResultsBucket( + self, + 'ExportResultsBucket', + access_logs_bucket=persistent_stack.access_logs_bucket, + encryption_key=persistent_stack.shared_encryption_key, + ) + + # Create the index manager custom resource + self.index_manager_custom_resource = IndexManagerCustomResource( + self, + construct_id='indexManager', + opensearch_domain=self.provider_search_domain.domain, + vpc_stack=vpc_stack, + vpc_subnets=self.provider_search_domain.vpc_subnets, + lambda_role=self.opensearch_index_manager_lambda_role, + environment_name=environment_name, + ) + + # Create the search providers handler for API Gateway integration + self.search_handler = SearchHandler( + self, + construct_id='searchHandler', + opensearch_domain=self.provider_search_domain.domain, + vpc_stack=vpc_stack, + vpc_subnets=self.provider_search_domain.vpc_subnets, + lambda_role=self.search_api_lambda_role, + alarm_topic=persistent_stack.alarm_topic, + export_results_bucket=self.export_results_bucket, + ) + + # Create the populate provider documents handler for manual invocation + # This handler is used to bulk index provider documents from DynamoDB into OpenSearch + self.populate_provider_documents_handler = PopulateProviderDocumentsHandler( + self, + construct_id='populateProviderDocumentsHandler', + opensearch_domain=self.domain, + vpc_stack=vpc_stack, + vpc_subnets=self.provider_search_domain.vpc_subnets, + lambda_role=self.opensearch_ingest_lambda_role, + provider_table=persistent_stack.provider_table, + compact_configuration_table=persistent_stack.compact_configuration_table, + alarm_topic=persistent_stack.alarm_topic, + ) + + # Create the provider update ingest handler for SQS-based stream processing + # This handler processes real-time updates from the provider table stream via EventBridge Pipe -> SQS + self.provider_update_ingest_handler = ProviderUpdateIngestHandler( + self, + construct_id='providerUpdateIngestHandler', + opensearch_domain=self.domain, + vpc_stack=vpc_stack, + vpc_subnets=self.provider_search_domain.vpc_subnets, + lambda_role=self.opensearch_ingest_lambda_role, + provider_table=persistent_stack.provider_table, + compact_configuration_table=persistent_stack.compact_configuration_table, + encryption_key=self.opensearch_encryption_key, + alarm_topic=persistent_stack.alarm_topic, + ) + # don't deploy ingest resources until index manager has set proper index configuration + self.provider_update_ingest_handler.node.add_dependency(self.index_manager_custom_resource) + + # Create the EventBridge Pipe to connect DynamoDB stream to SQS queue + # This pipe reads from the provider table stream and sends events to the ingest handler's queue + self.provider_update_ingest_pipe = ProviderUpdateIngestPipe( + self, + construct_id='providerUpdateIngestPipe', + provider_table=persistent_stack.provider_table, + target_queue=self.provider_update_ingest_handler.queue, + encryption_key=self.opensearch_encryption_key, + ) + # don't deploy ingest resources until index manager has set proper index configuration + self.provider_update_ingest_pipe.node.add_dependency(self.index_manager_custom_resource) + + # add log insights for provider ingest + QueryDefinition( + self, + 'IngestQuery', + query_definition_name=f'{self.node.id}/ProviderUpdateIngest', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'message', 'compact', 'provider_id', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp asc', + ), + log_groups=[self.provider_update_ingest_handler.handler.log_group], + ) + + # add log insights for search requests + QueryDefinition( + self, + 'SearchLambdaQuery', + query_definition_name=f'{self.node.id}/SearchAPILambda', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'message', 'compact', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp asc', + ), + log_groups=[self.search_handler.handler.log_group], + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/export_results_bucket.py b/backend/social-work-app/stacks/search_persistent_stack/export_results_bucket.py new file mode 100644 index 0000000000..2f143a8739 --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/export_results_bucket.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from aws_cdk import Duration +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_s3 import BucketEncryption, CorsRule, HttpMethods, LifecycleRule +from cdk_nag import NagSuppressions +from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.bucket import Bucket +from constructs import Construct + + +class ExportResultsBucket(Bucket): + """ + S3 bucket to store temporary CSV export result files. + + Files stored in this bucket are automatically deleted after 1 day + since they are only needed for the duration of the download. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + access_logs_bucket: AccessLogsBucket, + encryption_key: IKey, + **kwargs, + ): + super().__init__( + scope, + construct_id, + encryption=BucketEncryption.KMS, + encryption_key=encryption_key, + server_access_logs_bucket=access_logs_bucket, + # Versioning is not needed for temporary export files + versioned=False, + cors=[ + CorsRule( + allowed_methods=[HttpMethods.GET], + allowed_origins=['*'], + allowed_headers=['*'], + ), + ], + # Automatically delete objects after 1 day + lifecycle_rules=[ + LifecycleRule( + id='DeleteExportFilesAfterOneDay', + enabled=True, + expiration=Duration.days(1), + ), + ], + **kwargs, + ) + + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-S3BucketReplicationEnabled', + 'reason': 'This bucket houses transitory export data only that is deleted after 1 day. ' + 'Replication to a backup bucket is unhelpful.', + }, + { + 'id': 'HIPAA.Security-S3BucketVersioningEnabled', + 'reason': 'This bucket houses transitory export data only. ' + 'Version history is not needed for temporary files.', + }, + ], + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/index_manager.py b/backend/social-work-app/stacks/search_persistent_stack/index_manager.py new file mode 100644 index 0000000000..21c8e1512b --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/index_manager.py @@ -0,0 +1,189 @@ +import os + +from aws_cdk import CustomResource, Duration +from aws_cdk.aws_ec2 import SubnetSelection +from aws_cdk.aws_iam import IRole +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_opensearchservice import Domain +from aws_cdk.custom_resources import Provider +from cdk_nag import NagSuppressions +from common_constructs.constants import PROD_ENV_NAME +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from constructs import Construct + +from stacks.vpc_stack import VpcStack + +# Index configuration constants +# Non-prod environments use a single data node, so no replicas are needed +NON_PROD_NUMBER_OF_SHARDS = 1 +NON_PROD_NUMBER_OF_REPLICAS = 0 +# Production uses 3 data nodes across 3 AZs, so 1 primary and 2 replica ensures data availability +# if this is updated, the total of primary + replica shards must be a multiple of 3 +PROD_NUMBER_OF_SHARDS = 1 +PROD_NUMBER_OF_REPLICAS = 2 + + +class IndexManagerCustomResource(Construct): + """ + Custom resource for managing OpenSearch indices. + + This construct creates a CloudFormation custom resource that populates the OpenSearch Domain with the needed + provider indices. Indices are created with versioned names (e.g., compact_aslp_providers_v1) and aliases + (e.g., compact_aslp_providers) to enable safe blue-green migrations in the future. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + opensearch_domain: Domain, + vpc_stack: VpcStack, + vpc_subnets: SubnetSelection, + lambda_role: IRole, + environment_name: str, + ): + """ + Initialize the IndexManagerCustomResource construct. + + :param scope: The scope of the construct + :param construct_id: The id of the construct + :param opensearch_domain: The reference to the OpenSearch domain resource + :param vpc_stack: The VPC stack + :param vpc_subnets: The VPC subnets + :param lambda_role: The IAM role for the Lambda function + :param environment_name: The deployment environment name (e.g., 'prod', 'test') + """ + super().__init__(scope, construct_id) + stack = Stack.of(scope) + + self._is_prod_environment = environment_name == PROD_ENV_NAME + + # Create Lambda function for managing OpenSearch indices + self.manage_function = PythonFunction( + self, + 'IndexManagerFunction', + index=os.path.join('handlers', 'manage_opensearch_indices.py'), + lambda_dir='search', + handler='on_event', + role=lambda_role, + log_retention=RetentionDays.ONE_MONTH, + environment={ + 'OPENSEARCH_HOST_ENDPOINT': opensearch_domain.domain_endpoint, + **stack.common_env_vars, + }, + timeout=Duration.minutes(10), + memory_size=256, + vpc=vpc_stack.vpc, + vpc_subnets=vpc_subnets, + security_groups=[vpc_stack.lambda_security_group], + ) + # grant resource ability to create and check indices + opensearch_domain.grant_read_write(self.manage_function) + + # Add CDK Nag suppressions for the Lambda function's IAM role + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.manage_function.role.node.path}/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The grant_read_write method requires wildcard permissions on the OpenSearch domain to ' + 'create, read, and manage indices. This is appropriate for an index management function ' + 'that needs to operate on all indices in the domain.', + }, + ], + ) + + provider_log_group = LogGroup( + self, + 'ProviderLogGroup', + retention=RetentionDays.ONE_DAY, + ) + NagSuppressions.add_resource_suppressions( + provider_log_group, + suppressions=[ + { + 'id': 'HIPAA.Security-CloudWatchLogGroupEncrypted', + 'reason': 'We do not log sensitive data to CloudWatch, and operational visibility of system' + ' logs to operators with credentials for the AWS account is desired. Encryption is not' + ' appropriate here.', + }, + ], + ) + + # Create custom resource provider + # Note: Provider framework Lambda does NOT need VPC access - it only needs to: + # 1. Invoke the Lambda (via Lambda service API, no VPC needed) + # 2. Respond to CloudFormation + provider = Provider( + self, + 'Provider', + on_event_handler=self.manage_function, + log_group=provider_log_group, + ) + + # Add CDK Nag suppressions for the provider framework + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{provider.node.path}/framework-onEvent/Resource', + [ + {'id': 'AwsSolutions-L1', 'reason': 'We do not control this runtime'}, + { + 'id': 'HIPAA.Security-LambdaConcurrency', + 'reason': 'This function is only run at deploy time, by CloudFormation and has no need for ' + 'concurrency limits.', + }, + { + 'id': 'HIPAA.Security-LambdaDLQ', + 'reason': 'This is a synchronous function that runs at deploy time. It does not need a DLQ', + }, + { + 'id': 'HIPAA.Security-LambdaInsideVPC', + 'reason': 'Provider framework lambda is managed by AWS and does not function inside a VPC', + }, + ], + ) + + # Add CDK Nag suppressions for the provider framework's IAM role + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{provider.node.path}/framework-onEvent/ServiceRole/Resource', + [ + { + 'id': 'AwsSolutions-IAM4', + 'reason': 'The Provider framework requires AWS managed policies (AWSLambdaBasicExecutionRole) ' + 'for its service role. We do not control these policies.', + }, + ], + ) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{provider.node.path}/framework-onEvent/ServiceRole/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The Provider framework requires wildcard permissions to invoke the Lambda function. ' + 'This is a standard pattern for custom resource providers and is necessary for the ' + 'framework to manage the custom resource lifecycle.', + }, + ], + ) + + # Create custom resource for managing indices + # This custom resource will create versioned indices (e.g., 'compact_aslp_providers_v1') + # with aliases (e.g., 'compact_aslp_providers') for each compact. + # The alias abstraction enables safe blue-green migrations for future mapping changes. + self.index_manager = CustomResource( + self, + 'IndexManagerCustomResource', + resource_type='Custom::IndexManager', + service_token=provider.service_token, + properties={ + 'numberOfShards': PROD_NUMBER_OF_SHARDS if self._is_prod_environment else NON_PROD_NUMBER_OF_SHARDS, + 'numberOfReplicas': PROD_NUMBER_OF_REPLICAS + if self._is_prod_environment + else NON_PROD_NUMBER_OF_REPLICAS, + }, + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/populate_provider_documents_handler.py b/backend/social-work-app/stacks/search_persistent_stack/populate_provider_documents_handler.py new file mode 100644 index 0000000000..cdcbc61921 --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/populate_provider_documents_handler.py @@ -0,0 +1,104 @@ +import os + +from aws_cdk import Duration +from aws_cdk.aws_dynamodb import ITable +from aws_cdk.aws_ec2 import SubnetSelection +from aws_cdk.aws_iam import IRole +from aws_cdk.aws_logs import RetentionDays +from aws_cdk.aws_opensearchservice import Domain +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from constructs import Construct + +from stacks.vpc_stack import VpcStack + + +class PopulateProviderDocumentsHandler(Construct): + """ + Construct for the Populate Provider Documents Lambda function. + + This construct creates the Lambda function that populates the OpenSearch + indices with provider documents by scanning the provider table and + bulk indexing the sanitized records. + + This Lambda is intended to be invoked manually through the AWS console + for initial data population or re-indexing operations. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + opensearch_domain: Domain, + vpc_stack: VpcStack, + vpc_subnets: SubnetSelection, + lambda_role: IRole, + provider_table: ITable, + compact_configuration_table: ITable, + alarm_topic: ITopic, + ): + """ + Initialize the PopulateProviderDocumentsHandler construct. + + :param scope: The scope of the construct + :param construct_id: The id of the construct + :param opensearch_domain: The reference to the OpenSearch domain resource + :param vpc_stack: The VPC stack + :param vpc_subnets: The VPC subnets for Lambda deployment + :param lambda_role: The IAM role for the Lambda function (OpenSearch read/write for indexing and index reset) + :param provider_table: The DynamoDB provider table + :param compact_configuration_table: The DynamoDB compact configuration table (for live jurisdictions) + :param alarm_topic: The SNS topic for alarms + """ + super().__init__(scope, construct_id) + stack = Stack.of(scope) + + # Create Lambda function for populating provider documents + self.handler = PythonFunction( + self, + 'PopulateProviderDocumentsFunction', + description='Populates OpenSearch indices with provider documents from DynamoDB', + index=os.path.join('handlers', 'populate_provider_documents.py'), + lambda_dir='search', + handler='populate_provider_documents', + role=lambda_role, + log_retention=RetentionDays.ONE_MONTH, + environment={ + 'OPENSEARCH_HOST_ENDPOINT': opensearch_domain.domain_endpoint, + 'PROVIDER_TABLE_NAME': provider_table.table_name, + 'PROV_DATE_OF_UPDATE_INDEX_NAME': provider_table.provider_date_of_update_index_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': compact_configuration_table.table_name, + **stack.common_env_vars, + }, + # Longer timeout for processing large datasets + timeout=Duration.minutes(15), + memory_size=512, + vpc=vpc_stack.vpc, + vpc_subnets=vpc_subnets, + security_groups=[vpc_stack.lambda_security_group], + alarm_topic=alarm_topic, + ) + + # Grant read/write HTTP to the domain (same as index manager). resetIndexes and normal indexing need + # HEAD/GET for alias and index existence checks, plus PUT/POST for bulk index and DELETE for index reset. + opensearch_domain.grant_read_write(self.handler) + + # Grant the handler read access to the provider table and compact configuration table + provider_table.grant_read_data(self.handler) + compact_configuration_table.grant_read_data(self.handler) + + # Add CDK Nag suppressions for the Lambda function's IAM role + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.handler.role.node.path}/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The grant_read_write method requires wildcard permissions on the OpenSearch domain to ' + 'create, read, delete, and manage indices and aliases (including optional resetIndexes). This ' + 'matches the index manager custom resource pattern in index_manager.py.', + }, + ], + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/provider_search_domain.py b/backend/social-work-app/stacks/search_persistent_stack/provider_search_domain.py new file mode 100644 index 0000000000..7de6832b1a --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/provider_search_domain.py @@ -0,0 +1,649 @@ +from aws_cdk import Duration, Fn, RemovalPolicy +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Metric, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_ec2 import EbsDeviceVolumeType, SubnetSelection, SubnetType +from aws_cdk.aws_iam import Effect, IRole, PolicyStatement, ServicePrincipal +from aws_cdk.aws_kms import Key +from aws_cdk.aws_logs import LogGroup, ResourcePolicy, RetentionDays +from aws_cdk.aws_opensearchservice import ( + CapacityConfig, + Domain, + EbsOptions, + EncryptionAtRestOptions, + EngineVersion, + LoggingOptions, + TLSSecurityPolicy, + WindowStartTime, + ZoneAwarenessConfig, +) +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.constants import PROD_ENV_NAME +from common_constructs.stack import Stack +from constructs import Construct + +from stacks.vpc_stack import PRIVATE_SUBNET_ONE_NAME, VpcStack + +PROD_EBS_VOLUME_SIZE = 25 +NON_PROD_EBS_VOLUME_SIZE = 10 + + +class ProviderSearchDomain(Construct): + """ + Construct for the OpenSearch Domain and related resources. + + This construct encapsulates: + - OpenSearch Domain with VPC deployment and encryption + - KMS encryption key for the domain + - CloudWatch log groups for OpenSearch logging + - Access policies restricting domain access to specific Lambda roles + - CloudWatch alarms for capacity monitoring + + Instance sizing by environment: + - Non-prod (sandbox/test/beta): t3.small.search, 1 node + - Prod: m7g.medium.search, 3 master + 3 data nodes (with standby) + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + region: str, + vpc_stack: VpcStack, + compact_abbreviations: list[str], + alarm_topic: ITopic, + ingest_lambda_role: IRole, + index_manager_lambda_role: IRole, + search_api_lambda_role: IRole, + ): + """ + Initialize the ProviderSearchDomain construct. + + :param scope: The scope of the construct + :param construct_id: The id of the construct + :param environment_name: The deployment environment name (e.g., 'prod', 'test') + :param region: The deployment region (e.g., 'us-east-1') + :param vpc_stack: The VPC stack containing network resources + :param compact_abbreviations: List of compact abbreviations for index access policies + :param alarm_topic: The SNS topic for capacity alarms + :param ingest_lambda_role: IAM role for ingest and populate-provider Lambdas (OpenSearch read/write on indices) + :param index_manager_lambda_role: IAM role for the index manager Lambda function (read/write access) + :param search_api_lambda_role: IAM role for the search API Lambda function (read access) + """ + super().__init__(scope, construct_id) + stack = Stack.of(self) + + # Store references to the Lambda roles for access policy configuration + self._ingest_lambda_role = ingest_lambda_role + self._index_manager_lambda_role = index_manager_lambda_role + self._search_api_lambda_role = search_api_lambda_role + + self._is_prod_environment = environment_name == PROD_ENV_NAME + + # Determine removal policy based on environment + removal_policy = RemovalPolicy.RETAIN if self._is_prod_environment else RemovalPolicy.DESTROY + + # Create dedicated KMS key for OpenSearch domain encryption + self.encryption_key = Key( + self, + 'EncryptionKey', + enable_key_rotation=True, + alias=f'{stack.stack_name}-opensearch-encryption-key', + removal_policy=removal_policy, + ) + + # Grant OpenSearch service principal permission to use the key + opensearch_principal = ServicePrincipal('es.amazonaws.com') + self.encryption_key.grant_encrypt_decrypt(opensearch_principal) + + # Grant cloudwatch service principal permission to use the key + log_principal = ServicePrincipal(f'logs.{region}.amazonaws.com') + self.encryption_key.grant_encrypt_decrypt(log_principal) + + # Create CloudWatch log groups for OpenSearch logging + app_log_group = LogGroup( + self, + 'AppLogGroup', + retention=RetentionDays.ONE_MONTH, + removal_policy=removal_policy, + encryption_key=self.encryption_key, + ) + slow_search_log_group = LogGroup( + self, + 'SlowSearchLogGroup', + retention=RetentionDays.ONE_MONTH, + removal_policy=removal_policy, + encryption_key=self.encryption_key, + ) + slow_index_log_group = LogGroup( + self, + 'SlowIndexLogGroup', + retention=RetentionDays.ONE_MONTH, + removal_policy=removal_policy, + encryption_key=self.encryption_key, + ) + + # Create CloudWatch Logs resource policy to allow OpenSearch to write logs + # This is set here to avoid CDK creating an auto-generated Lambda function + # The resource ARNs must include ':*' to grant permissions on log streams within the log groups + ResourcePolicy( + self, + 'LogsResourcePolicy', + policy_statements=[ + PolicyStatement( + effect=Effect.ALLOW, + principals=[ServicePrincipal('es.amazonaws.com')], + actions=[ + 'logs:PutLogEvents', + 'logs:CreateLogStream', + ], + resources=[ + f'{app_log_group.log_group_arn}:*', + f'{slow_search_log_group.log_group_arn}:*', + f'{slow_index_log_group.log_group_arn}:*', + ], + ), + ], + ) + + # Determine instance type and capacity based on environment + capacity_config = self._get_capacity_config() + # Determine AZ awareness based on environment + zone_awareness_config = self._get_zone_awareness_config() + # Determine subnet selection based on environment + self.vpc_subnets = self._get_vpc_subnets(vpc_stack) + + # Create OpenSearch Domain + self.domain = Domain( + self, + 'Domain', + # IMPORTANT NOTE: updating the engine version requires a blue/green deployment. + # During development, we found that if a blue/green deployment became stuck, the search endpoints were still + # able to serve data, but the CloudFormation deployment would fail waiting for the domain to become active. + # In such cases you may have to work with AWS support to get it out of that state. + # If you intend to update this field, or any other field that will require a blue/green deployment as + # described here: + # https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-configuration-changes.html + # consider working with stakeholders to schedule a maintenance window during low-traffic periods where + # advanced search may become inaccessible during the update, to give you time to verify changes. + version=EngineVersion.OPENSEARCH_3_3, + capacity=capacity_config, + enable_auto_software_update=True, + enable_version_upgrade=True, + # We set the off-peak window to 9AM UTC (1AM PST) + # this determines when automatic updates are performed on the domain. + off_peak_window_start=WindowStartTime(hours=9, minutes=0), + # VPC configuration for network isolation + vpc=vpc_stack.vpc, + vpc_subnets=[self.vpc_subnets], + security_groups=[vpc_stack.opensearch_security_group], + # EBS volume configuration + ebs=EbsOptions( + enabled=True, + volume_size=PROD_EBS_VOLUME_SIZE if self._is_prod_environment else NON_PROD_EBS_VOLUME_SIZE, + # this type is required for medium instances + volume_type=EbsDeviceVolumeType.GP3, + ), + # Encryption settings + encryption_at_rest=EncryptionAtRestOptions(enabled=True, kms_key=self.encryption_key), + node_to_node_encryption=True, + enforce_https=True, + tls_security_policy=TLSSecurityPolicy.TLS_1_2, + logging=LoggingOptions( + app_log_enabled=True, + app_log_group=app_log_group, + slow_search_log_enabled=True, + slow_search_log_group=slow_search_log_group, + slow_index_log_enabled=True, + slow_index_log_group=slow_index_log_group, + ), + # Suppress auto-generated Lambda for log resource policy (we created it manually above) + suppress_logs_resource_policy=True, + # Domain removal policy + removal_policy=removal_policy, + zone_awareness=zone_awareness_config, + ) + + # Configure access policies + self._configure_access_policies(compact_abbreviations) + + # Grant lambda roles access to domain + self.domain.grant_read(self._search_api_lambda_role) + # Ingest role is shared by stream ingest and populate-provider; populate resetIndexes needs GET/HEAD/DELETE + # on indices/aliases in addition to POST/PUT (see ingest_access_policy). + self.domain.grant_read_write(self._ingest_lambda_role) + self.domain.grant_read_write(self._index_manager_lambda_role) + + # Add CDK Nag suppressions + self._add_domain_suppressions() + self._add_access_policy_lambda_suppressions() + self._add_lambda_role_suppressions(self._search_api_lambda_role) + self._add_lambda_role_suppressions(self._ingest_lambda_role) + self._add_lambda_role_suppressions(self._index_manager_lambda_role) + + # Add capacity monitoring alarms + self._add_capacity_alarms(alarm_topic) + + def _configure_access_policies(self, compact_abbreviations: list[str]): + """ + Configure access policies for the OpenSearch domain. + + Creates IAM-based access policies that restrict access to specific Lambda roles: + - Ingest role: GET/HEAD/POST/PUT/DELETE on compact indices (bulk ingest, alias/index checks, index reset) + - Index manager role: GET/HEAD/POST/PUT access for index management + - Search API role: POST access restricted to _search endpoint only + + :param compact_abbreviations: List of compact abbreviations for index access policies + """ + ingest_access_policy = PolicyStatement( + effect=Effect.ALLOW, + principals=[self._ingest_lambda_role], + actions=[ + 'es:ESHttpGet', + 'es:ESHttpHead', + 'es:ESHttpPost', + 'es:ESHttpPut', + 'es:ESHttpDelete', + ], + resources=[Fn.join('', [self.domain.domain_arn, '/compact*'])], + ) + index_manager_access_policy = PolicyStatement( + effect=Effect.ALLOW, + principals=[self._index_manager_lambda_role], + actions=[ + 'es:ESHttpGet', + 'es:ESHttpHead', # Required for index_exists() checks + 'es:ESHttpPost', + 'es:ESHttpPut', + ], + resources=[Fn.join('', [self.domain.domain_arn, '/compact*'])], + ) + # Search API policy - restricted to _search endpoint only + # POST is required for _search queries even though they are read-only operations + # because OpenSearch's search API uses POST to send the query DSL body. + # By restricting the resource to /_search, we prevent POST from being used + # for document indexing or other write operations. + # See: https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html + search_api_policy = PolicyStatement( + effect=Effect.ALLOW, + principals=[self._search_api_lambda_role], + actions=[ + 'es:ESHttpPost', + ], + # define all compact indices to restrict the policy to the search operation + resources=[ + Fn.join(delimiter='', list_of_values=[self.domain.domain_arn, f'/compact_{compact}_providers/_search']) + for compact in compact_abbreviations + ], + ) + # Add access policy to restrict access to set of roles + self.domain.add_access_policies( + ingest_access_policy, + index_manager_access_policy, + search_api_policy, + ) + + def _get_capacity_config(self) -> CapacityConfig: + """ + Determine OpenSearch cluster capacity configuration based on environment. + + Non-prod (sandbox, test, beta, etc.): Single t3.small.search node + Prod: 3 dedicated master (r8g.medium.search) + 3 data nodes (m7g.medium.search) with standby + + :return: CapacityConfig with appropriate instance types and counts + """ + if self._is_prod_environment: + # Production configuration with high availability + # 3 dedicated master nodes + 3 data nodes across 3 AZs with standby + # Multi-AZ with standby does not support t3 instance types + return CapacityConfig( + # Data nodes - m7g.medium provides 1 vCPU and 4GB RAM + data_node_instance_type='m7g.medium.search', + # we require at least 3 data nodes and master nodes to support multi-az with standby + # for high availability + data_nodes=3, + # Dedicated master nodes for cluster management + # r8g.medium provides 8GB RAM, which the master nodes + # need based on our domain size + master_node_instance_type='r8g.medium.search', + master_nodes=3, + # Multi-AZ with standby for high availability + multi_az_with_standby_enabled=True, + ) + + # Single node configuration for all non-prod environments + # (test, beta, and developer sandboxes) + return CapacityConfig( + data_node_instance_type='t3.small.search', + data_nodes=1, + # No dedicated master nodes for single-node clusters + master_nodes=None, + # No multi-AZ for single node + multi_az_with_standby_enabled=False, + ) + + def _get_zone_awareness_config(self) -> ZoneAwarenessConfig: + """ + Determine OpenSearch cluster availability zone awareness based on environment. + + 3 for production, not enabled for all other non-prod environments + + :return: ZoneAwarenessConfig with appropriate settings + """ + if self._is_prod_environment: + return ZoneAwarenessConfig(enabled=True, availability_zone_count=3) + + # Non-prod environments only use one data node, hence we don't enable zone awareness + return ZoneAwarenessConfig(enabled=False) + + def _get_vpc_subnets(self, vpc_stack: VpcStack) -> SubnetSelection: + """ + Determine VPC subnet selection based on environment. + + Production: All private isolated subnets (3 AZs) for zone awareness and high availability + Non-prod: Single subnet (privateSubnet1 with CIDR 10.0.0.0/20) for single-node deployment + + :param vpc_stack: The VPC stack containing the private subnets + :return: SubnetSelection with appropriate subnet configuration + """ + if self._is_prod_environment: + # Production: Use all private isolated subnets from the VPC. + # VPC is configured with max_azs=3, so this will select exactly 3 subnets + return SubnetSelection(subnet_type=SubnetType.PRIVATE_ISOLATED) + + # Non-prod: Single-node deployment explicitly uses privateSubnet1 (CIDR 10.0.0.0/20) + # OpenSearch requires exactly one subnet for single-node deployments + # We explicitly find the subnet by its construct name to guarantee consistency + private_subnet1 = self._find_subnet_by_name(vpc_stack.vpc, PRIVATE_SUBNET_ONE_NAME) + return SubnetSelection(subnets=[private_subnet1]) + + def _find_subnet_by_name(self, vpc, subnet_name: str): + """ + Find a specific subnet by its logical construct name in the VPC. + + This provides a guaranteed, explicit reference to a specific subnet regardless of + CDK's internal list ordering, which is critical for stateful resources like OpenSearch. + + :param vpc: The VPC construct containing the subnet + :param subnet_name: The logical name of the subnet (e.g., 'privateSubnet1') + :return: The ISubnet instance + :raises ValueError: If the subnet cannot be found + """ + # Navigate the construct tree to find the subnet by name + subnet_construct = vpc.node.try_find_child(subnet_name) + if subnet_construct is None: + raise ValueError( + f'Subnet {subnet_name} not found in VPC construct tree. ' + f'Available children: {[c.node.id for c in vpc.node.children]}' + ) + + return subnet_construct + + def _add_capacity_alarms(self, alarm_topic: ITopic): + """ + Add CloudWatch alarms to monitor OpenSearch capacity and alert before hitting limits. + + These proactive thresholds give the DevOps team time to plan scaling activities: + - Free Storage Space < 50% of allocated capacity + - JVM Memory Pressure > 85% + - CPU Utilization > 70% + - Cluster Status (red/yellow) for critical and degraded states + - Automated Snapshot Failure for backup issues + + :param alarm_topic: The SNS topic to send alarm notifications to + """ + stack = Stack.of(self) + + # Get the volume size for calculating storage threshold + volume_size_gb = PROD_EBS_VOLUME_SIZE if self._is_prod_environment else NON_PROD_EBS_VOLUME_SIZE + # 50% threshold in MB (FreeStorageSpace metric is reported in megabytes) + # Formula: GB * 1024 MB/GB * 0.5 for 50% threshold + storage_threshold_mb = volume_size_gb * 1024 * 0.5 + + # Alarm: Free Storage Space < 50% + # This gives ample time to plan capacity increases before hitting critical levels + # Note: FreeStorageSpace metric is reported in megabytes (MB) + Alarm( + self, + 'FreeStorageSpaceAlarm', + metric=Metric( + namespace='AWS/ES', + metric_name='FreeStorageSpace', + dimensions_map={'DomainName': self.domain.domain_name, 'ClientId': stack.account}, + # check twice a day + period=Duration.hours(12), + statistic='Minimum', + ), + evaluation_periods=1, # Notify the moment the storage space is less than 50% + threshold=storage_threshold_mb, + comparison_operator=ComparisonOperator.LESS_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + f'OpenSearch Domain {self.domain.domain_name} free storage space has dropped below 50% ' + f'({storage_threshold_mb}MB of {volume_size_gb * 1024}MB allocated EBS volume). ' + 'Consider planning to increase EBS volume size or scaling the cluster.' + ), + ).add_alarm_action(SnsAction(alarm_topic)) + + # Alarm: JVM Memory Pressure > 85% + # Sustained high memory pressure indicates need for instance scaling + Alarm( + self, + 'JVMMemoryPressureAlarm', + metric=Metric( + namespace='AWS/ES', + metric_name='JVMMemoryPressure', + dimensions_map={'DomainName': self.domain.domain_name, 'ClientId': stack.account}, + period=Duration.minutes(5), + statistic='Maximum', + ), + evaluation_periods=3, # 15 minutes sustained + threshold=85, + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + f'OpenSearch Domain {self.domain.domain_name} JVM memory pressure is above 85%. ' + 'This indicates the cluster is using a significant portion of its heap memory. ' + 'Consider scaling to larger instance types if pressure continues to increase.' + ), + ).add_alarm_action(SnsAction(alarm_topic)) + + # Alarm: CPU Utilization > 70% + # Sustained high CPU indicates need for more compute capacity + Alarm( + self, + 'CPUUtilizationAlarm', + metric=Metric( + namespace='AWS/ES', + metric_name='CPUUtilization', + dimensions_map={'DomainName': self.domain.domain_name, 'ClientId': stack.account}, + period=Duration.minutes(5), + statistic='Average', + ), + evaluation_periods=3, # 15 minutes sustained + threshold=70, + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + f'OpenSearch Domain {self.domain.domain_name} CPU utilization has been above 70% for 15 minutes. ' + 'This indicates sustained high load. Review metrics and consider scaling to larger instance types ' + 'or adding more data nodes to distribute the load.' + ), + ).add_alarm_action(SnsAction(alarm_topic)) + + # Alarm: Cluster Status RED - Critical + # Red status indicates critical issues requiring immediate attention + Alarm( + self, + 'ClusterStatusRedAlarm', + metric=Metric( + namespace='AWS/ES', + metric_name='ClusterStatus.red', + dimensions_map={'DomainName': self.domain.domain_name, 'ClientId': stack.account}, + period=Duration.minutes(1), + statistic='Sum', + ), + evaluation_periods=1, # Alert immediately when red + threshold=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + f'OpenSearch Domain {self.domain.domain_name} cluster status is RED. ' + 'This indicates critical issues requiring immediate attention. ' + 'Check cluster health and consider scaling if resource-constrained.' + ), + ).add_alarm_action(SnsAction(alarm_topic)) + + # Alarm: Cluster Status YELLOW - Degraded + # Yellow status indicates degraded state that should be monitored + Alarm( + self, + 'ClusterStatusYellowAlarm', + metric=Metric( + namespace='AWS/ES', + metric_name='ClusterStatus.yellow', + dimensions_map={'DomainName': self.domain.domain_name, 'ClientId': stack.account}, + period=Duration.minutes(5), + statistic='Sum', + ), + evaluation_periods=1, # Alert when yellow status is detected + threshold=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + f'OpenSearch Domain {self.domain.domain_name} cluster status is YELLOW. ' + 'This indicates degraded state. Monitor closely and consider scaling if persistent.' + ), + ).add_alarm_action(SnsAction(alarm_topic)) + + # Alarm: Automated Snapshot Failure + # Snapshot failures may indicate resource constraints or other issues + Alarm( + self, + 'AutomatedSnapshotFailureAlarm', + metric=Metric( + namespace='AWS/ES', + metric_name='AutomatedSnapshotFailure', + dimensions_map={'DomainName': self.domain.domain_name, 'ClientId': stack.account}, + period=Duration.hours(1), + statistic='Sum', + ), + evaluation_periods=1, + threshold=1, + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + alarm_description=( + f'OpenSearch Domain {self.domain.domain_name} automated snapshot has failed. ' + 'This may indicate resource constraints or other issues requiring investigation.' + ), + ).add_alarm_action(SnsAction(alarm_topic)) + + def _add_domain_suppressions(self): + """ + Add CDK Nag suppressions for OpenSearch Domain configuration. + """ + NagSuppressions.add_resource_suppressions( + self.domain, + suppressions=[ + { + 'id': 'AwsSolutions-OS3', + 'reason': 'Access to this domain is restricted by Access Policies and VPC security groups. ' + 'The data in the domain is only accessible by the ingest lambda which indexes the ' + 'documents and the search API lambda which can only be accessed by authenticated staff ' + 'users in CompactConnect.', + }, + { + 'id': 'AwsSolutions-OS5', + 'reason': 'Access to this domain is restricted by Access Policies and VPC security groups. ' + 'The data in the domain is only accessible by the ingest lambda which indexes the ' + 'documents and the search API lambda which can only be accessed by authenticated staff ' + 'users in CompactConnect.', + }, + ], + apply_to_children=True, + ) + if not self._is_prod_environment: + NagSuppressions.add_resource_suppressions( + self.domain, + suppressions=[ + { + 'id': 'AwsSolutions-OS4', + 'reason': 'Dedicated master nodes are only used in production environments with multiple data ' + 'nodes. Single-node non-prod environments do not require dedicated master nodes.', + }, + { + 'id': 'AwsSolutions-OS7', + 'reason': 'Zone awareness with standby is only enabled for production environments with ' + 'multiple nodes. Single-node test environments do not require multi-AZ ' + 'configuration.', + }, + ], + apply_to_children=True, + ) + + def _add_access_policy_lambda_suppressions(self): + """ + Add CDK Nag suppressions for the auto-generated Lambda function created by add_access_policies. + """ + stack = Stack.of(self) + + # Suppress for the auto-generated Lambda function + # The construct ID is auto-generated by CDK, so we need to suppress at the stack level + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{stack.node.path}/AWS679f53fac002430cb0da5b7982bd2287', + suppressions=[ + { + 'id': 'AwsSolutions-L1', + 'reason': 'This is an AWS-managed custom resource Lambda created by CDK to manage ' + 'OpenSearch domain access policies. We cannot specify the runtime version.', + }, + { + 'id': 'AwsSolutions-IAM4', + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], + 'reason': 'This is an AWS-managed custom resource Lambda created by CDK to manage ' + 'OpenSearch domain access policies. It uses the standard execution role.', + }, + { + 'id': 'AwsSolutions-IAM5', + 'appliesTo': ['Action::kms:Describe*', 'Action::kms:List*'], + 'reason': 'This is an AWS-managed custom resource Lambda that requires KMS permissions to ' + 'access the encryption key used by the OpenSearch domain.', + }, + { + 'id': 'HIPAA.Security-LambdaDLQ', + 'reason': 'This is an AWS-managed custom resource Lambda used only during deployment to ' + 'manage OpenSearch access policies. A DLQ is not necessary for deployment-time ' + 'functions.', + }, + { + 'id': 'HIPAA.Security-LambdaInsideVPC', + 'reason': 'This is an AWS-managed custom resource Lambda that needs internet access to ' + 'manage OpenSearch domain access policies via AWS APIs. VPC placement is not ' + 'required.', + }, + ], + apply_to_children=True, + ) + + def _add_lambda_role_suppressions(self, lambda_role: IRole): + """ + Add CDK Nag suppressions for OpenSearch Lambda role configuration. + + :param lambda_role: The Lambda role to add suppressions for + """ + NagSuppressions.add_resource_suppressions( + lambda_role, + suppressions=[ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'This lambda role access is restricted to the specific ' + 'OpenSearch domain and its indices within the VPC.', + }, + ], + apply_to_children=True, + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/provider_update_ingest_handler.py b/backend/social-work-app/stacks/search_persistent_stack/provider_update_ingest_handler.py new file mode 100644 index 0000000000..3a52c5d263 --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/provider_update_ingest_handler.py @@ -0,0 +1,200 @@ +import os + +from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_ec2 import SubnetSelection +from aws_cdk.aws_iam import IRole +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_logs import FilterPattern, MetricFilter, RetentionDays +from aws_cdk.aws_opensearchservice import Domain +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.queued_lambda_processor import QueuedLambdaProcessor +from common_constructs.stack import Stack +from constructs import Construct + +from stacks.persistent_stack import CompactConfigurationTable, ProviderTable +from stacks.vpc_stack import VpcStack + + +class ProviderUpdateIngestHandler(Construct): + """ + Construct for the Provider Update Ingest Lambda function. + + This construct creates the Lambda function that processes SQS messages containing + DynamoDB stream events from the provider table and indexes the updated provider + documents into OpenSearch. + + The Lambda is triggered by SQS (fed by EventBridge Pipe from DynamoDB streams) + and processes events in batches, deduplicating provider IDs by compact before + bulk indexing into OpenSearch. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + opensearch_domain: Domain, + vpc_stack: VpcStack, + vpc_subnets: SubnetSelection, + lambda_role: IRole, + provider_table: ProviderTable, + compact_configuration_table: CompactConfigurationTable, + encryption_key: IKey, + alarm_topic: ITopic, + ): + """ + Initialize the ProviderUpdateIngestHandler construct. + + :param scope: The scope of the construct + :param construct_id: The id of the construct + :param opensearch_domain: The reference to the OpenSearch domain resource + :param vpc_stack: The VPC stack + :param vpc_subnets: The VPC subnets for Lambda deployment + :param lambda_role: The IAM role for the Lambda function (should have OpenSearch write access) + :param provider_table: The DynamoDB provider table (used for fetching full provider records) + :param compact_configuration_table: The DynamoDB compact configuration table (for live jurisdictions) + :param encryption_key: The KMS encryption key for the SQS queue + :param alarm_topic: The SNS topic for alarms + """ + super().__init__(scope, construct_id) + stack = Stack.of(scope) + + # Create Lambda function for processing provider updates from SQS + self.handler = PythonFunction( + self, + 'ProviderUpdateIngestFunction', + description='Processes SQS messages with DynamoDB stream events and indexes provider documents into ' + 'OpenSearch', + index=os.path.join('handlers', 'provider_update_ingest.py'), + lambda_dir='search', + handler='provider_update_ingest_handler', + role=lambda_role, + log_retention=RetentionDays.ONE_MONTH, + environment={ + 'OPENSEARCH_HOST_ENDPOINT': opensearch_domain.domain_endpoint, + 'PROVIDER_TABLE_NAME': provider_table.table_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': compact_configuration_table.table_name, + **stack.common_env_vars, + }, + # Allow enough time for processing large batches + timeout=Duration.minutes(10), + memory_size=1024, + vpc=vpc_stack.vpc, + vpc_subnets=vpc_subnets, + security_groups=[vpc_stack.lambda_security_group], + # We set a limit to the number of concurrent executions that can be started before being throttled. + # This protects us in several ways. First, it prevents ingest from taking concurrent execution count from + # our api lambdas, which if left unchecked could cause them to get throttled if we hit our account limit + # (currently at the default of 1000). It also prevents the OpenSearch Domain from getting slammed during + # high volume. This reserved limit can result in messages waiting a bit longer on the queue during high + # volume while the reserved executions complete their tasks before grabbing the next batch. We have an alert + # in place to fire if this lambda is ever throttled. This limit can be adjusted as needed, but based on + # initial load testing this seems like a reasonable limit. + reserved_concurrent_executions=25, + alarm_topic=alarm_topic, + ) + + # Create the QueuedLambdaProcessor for SQS-based event processing + # The queue receives DynamoDB stream events from EventBridge Pipe + self.queue_processor = QueuedLambdaProcessor( + self, + 'ProviderUpdateIngest', + process_function=self.handler, + # Visibility timeout controls when failed messages (in batchItemFailures) become visible for retry. + # Set to slightly longer than Lambda timeout (10 min) to prevent duplicate processing during + # Lambda execution. Failed messages will retry after this timeout expires (~15 minutes). + visibility_timeout=Duration.minutes(15), + # Retention period for the source queue (these should be processed fairly quickly, but setting this to + # account for retries) + retention_period=Duration.hours(2), + # OpenSearch recommends performing bulk indexing with sizes between 5 - 15 MB per operation. + # see https://www.elastic.co/guide/en/elasticsearch/guide/2.x/indexing-performance.html#_using_and_sizing_bulk_requests + # A basic provider document without any additional records (privileges, adverse actions, etc.) is + # around 2 KB on average. We expect these provider documents to grow over time as providers accumulate + # privileges and other records. Setting a batch size of 3000 places the initial bulk operations around + # 6 MB max size per request (2KB * 3000 = 6 MB). This puts us within that range and provides headroom for + # these documents to grow over time, while still processing license uploads in a timely manner. + batch_size=3000, + # Batching window to allow multiple events for the same provider to be processed together + max_batching_window=Duration.seconds(15), + # Max receive count = total attempts before DLQ (1 initial + 2 retries = 3 total) + # Failed messages retry after visibility_timeout expires (15 min between attempts) + max_receive_count=3, + encryption_key=encryption_key, + alarm_topic=alarm_topic, + # DLQ retention of 14 days for analysis and replay + dlq_retention_period=Duration.days(14), + # Alert immediately if any messages end up in the DLQ + dlq_count_alarm_threshold=0, + ) + + # Expose the queue and DLQ for use by the EventBridge Pipe + self.queue = self.queue_processor.queue + self.dlq = self.queue_processor.dlq + + # Grant the handler write access to the OpenSearch domain + opensearch_domain.grant_write(self.handler) + + # Grant the handler read access to the provider table and compact configuration table + provider_table.grant_read_data(self.handler) + compact_configuration_table.grant_read_data(self.handler) + + # Grant the handler permission to use the encryption key for SQS operations + encryption_key.grant_encrypt_decrypt(self.handler) + + # Add alarm for Lambda errors + Alarm( + self, + 'ProviderUpdateIngestErrorAlarm', + metric=self.handler.metric_errors(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{self.handler.node.path} failed to process an SQS message batch', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(alarm_topic)) + + # Create a metric filter to capture ERROR level logs from the provider update ingest Lambda + error_log_metric = MetricFilter( + self, + 'ProviderUpdateIngestErrorLogMetric', + log_group=self.handler.log_group, + metric_namespace='CompactConnect/Search', + metric_name='ProviderUpdateIngestErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + + # Create an alarm that triggers when ERROR logs are detected + error_log_alarm = Alarm( + self, + 'ProviderUpdateIngestErrorLogAlarm', + metric=error_log_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'The Provider Update Ingest Lambda logged an ERROR level message. Investigate ' + f'the logs for the {self.handler.function_name} lambda to determine the cause.', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ) + error_log_alarm.add_alarm_action(SnsAction(alarm_topic)) + + # Add CDK Nag suppressions for the Lambda function's IAM role + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.handler.role.node.path}/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The grant_write method requires wildcard permissions on the OpenSearch domain to ' + 'write to indices. This is appropriate for a function that needs to index ' + 'provider documents.', + }, + ], + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/provider_update_ingest_pipe.py b/backend/social-work-app/stacks/search_persistent_stack/provider_update_ingest_pipe.py new file mode 100644 index 0000000000..548d3f5259 --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/provider_update_ingest_pipe.py @@ -0,0 +1,109 @@ +from aws_cdk.aws_iam import Effect, PolicyStatement, Role, ServicePrincipal +from aws_cdk.aws_kms import IKey +from aws_cdk.aws_pipes import CfnPipe +from aws_cdk.aws_sqs import IQueue +from cdk_nag import NagSuppressions +from common_constructs.stack import Stack +from constructs import Construct + +from stacks.persistent_stack import ProviderTable + + +class ProviderUpdateIngestPipe(Construct): + """ + Construct for the EventBridge Pipe that connects DynamoDB stream to SQS. + + This construct creates an EventBridge Pipe that: + - Reads events from the DynamoDB provider table stream + - Sends events to an SQS queue for processing by the provider update ingest Lambda + + The Pipe enables decoupling the DynamoDB stream from the Lambda function, allowing + for better scalability and resilience through SQS-based message processing. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + provider_table: ProviderTable, + target_queue: IQueue, + encryption_key: IKey, + ): + """ + Initialize the ProviderUpdateIngestPipe construct. + + :param scope: The scope of the construct + :param construct_id: The id of the construct + :param provider_table: The DynamoDB provider table with stream enabled + :param target_queue: The SQS queue to send events to + :param encryption_key: The KMS encryption key used by the SQS queue + """ + super().__init__(scope, construct_id) + stack = Stack.of(scope) + + # Create IAM role for the EventBridge Pipe + self.pipe_role = Role( + self, + 'PipeRole', + assumed_by=ServicePrincipal('pipes.amazonaws.com'), + description='IAM role for EventBridge Pipe that reads from DynamoDB stream and sends to SQS', + ) + + # Grant permissions to read from DynamoDB stream + # The stream ARN is constructed from the table ARN + self.pipe_role.add_to_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'dynamodb:DescribeStream', + 'dynamodb:GetRecords', + 'dynamodb:GetShardIterator', + 'dynamodb:ListStreams', + ], + resources=[ + f'{provider_table.table_arn}/stream/*', + ], + ) + ) + + # Grant permissions to send messages to SQS + target_queue.grant_send_messages(self.pipe_role) + + # Grant permissions to use the KMS key for encrypting SQS messages + encryption_key.grant_encrypt_decrypt(self.pipe_role) + # Grant permission to decrypt stream records from provider table + provider_table.encryption_key.grant_decrypt(self.pipe_role) + + # Create the EventBridge Pipe + # Using CfnPipe (L1 construct) as there's no stable L2 construct available yet + self.pipe = CfnPipe( + self, + 'Pipe', + role_arn=self.pipe_role.role_arn, + source=provider_table.table_stream_arn, + target=target_queue.queue_arn, + source_parameters=CfnPipe.PipeSourceParametersProperty( + dynamo_db_stream_parameters=CfnPipe.PipeSourceDynamoDBStreamParametersProperty( + # 'LATEST' starts processing from the latest available stream record + # from the moment the pipe is created + starting_position='LATEST', + # send everything to SQS as it arrives + batch_size=1, + ), + ), + description='Pipe to send DynamoDB provider table stream events to SQS for OpenSearch indexing', + ) + + # Add CDK Nag suppressions + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.pipe_role.node.path}/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The DynamoDB stream permissions require wildcard access to stream resources ' + 'as the stream ARN includes a timestamp component that changes on table recreation. ' + 'The SQS grant_send_messages also adds appropriate permissions.', + }, + ], + ) diff --git a/backend/social-work-app/stacks/search_persistent_stack/search_handler.py b/backend/social-work-app/stacks/search_persistent_stack/search_handler.py new file mode 100644 index 0000000000..d13473118e --- /dev/null +++ b/backend/social-work-app/stacks/search_persistent_stack/search_handler.py @@ -0,0 +1,176 @@ +import os + +from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_ec2 import SubnetSelection +from aws_cdk.aws_iam import IRole +from aws_cdk.aws_logs import FilterPattern, MetricFilter, RetentionDays +from aws_cdk.aws_opensearchservice import Domain +from aws_cdk.aws_s3 import IBucket +from aws_cdk.aws_sns import ITopic +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack +from constructs import Construct + +from stacks.vpc_stack import VpcStack + + +class SearchHandler(Construct): + """ + Construct for the Search Lambda function. + + This construct creates the Lambda function that handles search requests + against the OpenSearch domain for both provider and privilege records. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + opensearch_domain: Domain, + vpc_stack: VpcStack, + vpc_subnets: SubnetSelection, + lambda_role: IRole, + alarm_topic: ITopic, + export_results_bucket: IBucket, + ): + """ + Initialize the SearchHandler construct. + + :param scope: The scope of the construct + :param construct_id: The id of the construct + :param opensearch_domain: The reference to the OpenSearch domain resource + :param vpc_stack: The VPC stack + :param vpc_subnets: The VPC subnets for Lambda deployment + :param lambda_role: The IAM role for the Lambda function + :param alarm_topic: The SNS topic for alarms + :param export_results_bucket: The S3 bucket for storing export result CSV files + """ + super().__init__(scope, construct_id) + stack = Stack.of(scope) + + # Create Lambda function for searching providers and privileges + self.handler = PythonFunction( + self, + 'SearchProvidersFunction', + description='Search handler for OpenSearch queries', + index=os.path.join('handlers', 'search.py'), + lambda_dir='search', + handler='search_api_handler', + role=lambda_role, + log_retention=RetentionDays.ONE_MONTH, + environment={ + 'OPENSEARCH_HOST_ENDPOINT': opensearch_domain.domain_endpoint, + 'EXPORT_RESULTS_BUCKET_NAME': export_results_bucket.bucket_name, + **stack.common_env_vars, + }, + timeout=Duration.seconds(29), + # memory slightly larger to manage pulling down privilege reports for CSV export + # and to improve performance of search in general + memory_size=2048, + vpc=vpc_stack.vpc, + vpc_subnets=vpc_subnets, + security_groups=[vpc_stack.lambda_security_group], + alarm_topic=alarm_topic, + ) + + # Create Lambda function for public query providers + self.public_handler = PythonFunction( + self, + 'PublicSearchProvidersFunction', + description='Public search handler for OpenSearch license queries', + index=os.path.join('handlers', 'public_search.py'), + lambda_dir='search', + handler='public_search_api_handler', + role=lambda_role, + log_retention=RetentionDays.ONE_MONTH, + environment={ + 'OPENSEARCH_HOST_ENDPOINT': opensearch_domain.domain_endpoint, + **stack.common_env_vars, + }, + timeout=Duration.seconds(29), + memory_size=2048, + vpc=vpc_stack.vpc, + vpc_subnets=vpc_subnets, + security_groups=[vpc_stack.lambda_security_group], + alarm_topic=alarm_topic, + ) + opensearch_domain.grant_read(self.public_handler) + + # Create metric filter and alarm for public handler errors + public_error_log_metric = MetricFilter( + self, + 'PublicSearchHandlerErrorLogMetric', + log_group=self.public_handler.log_group, + metric_namespace='CompactConnect/Search', + metric_name='PublicSearchHandlerErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + public_error_log_alarm = Alarm( + self, + 'PublicSearchHandlerErrorLogAlarm', + metric=public_error_log_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description='The Public Search Handler Lambda logged an ERROR level message.', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ) + public_error_log_alarm.add_alarm_action(SnsAction(alarm_topic)) + + # Grant the handler read access to the OpenSearch domain + opensearch_domain.grant_read(self.handler) + + # Grant the handler write access to the export results bucket + export_results_bucket.grant_write(self.handler) + + # Grant the handler permission to generate presigned URLs for the export results bucket + export_results_bucket.grant_read(self.handler) + + # Add CDK Nag suppressions for the Lambda function's IAM role + NagSuppressions.add_resource_suppressions_by_path( + stack, + f'{self.handler.role.node.path}/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'The grant_read method requires wildcard permissions on the OpenSearch domain to ' + 'read from indices. This is appropriate for a search function that needs to query ' + 'provider indices in the domain. Additionally, grant_write and grant_read on the S3 bucket ' + 'use wildcard permissions for object-level operations which is required for writing and ' + 'generating presigned URLs for export result CSV files.', + }, + ], + ) + + # Create a metric filter to capture ERROR level logs from the search handler Lambda + error_log_metric = MetricFilter( + self, + 'SearchHandlerErrorLogMetric', + log_group=self.handler.log_group, + metric_namespace='CompactConnect/Search', + metric_name='SearchHandlerErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + + # Create an alarm that triggers when ERROR logs are detected + error_log_alarm = Alarm( + self, + 'SearchHandlerErrorLogAlarm', + metric=error_log_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'The Search Handler Lambda logged an ERROR level message. Investigate ' + f'the logs for the {self.handler.function_name} lambda to determine the cause.', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ) + error_log_alarm.add_alarm_action(SnsAction(alarm_topic)) diff --git a/backend/social-work-app/stacks/state_api_stack/__init__.py b/backend/social-work-app/stacks/state_api_stack/__init__.py new file mode 100644 index 0000000000..35a2a2c184 --- /dev/null +++ b/backend/social-work-app/stacks/state_api_stack/__init__.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from common_constructs.security_profile import SecurityProfile +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack as ps +from stacks.state_auth import StateAuthStack + +from .api import StateApi + + +class StateApiStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + environment_context: dict, + persistent_stack: ps.PersistentStack, + state_auth_stack: StateAuthStack, + **kwargs, + ): + super().__init__( + scope, construct_id, environment_context=environment_context, environment_name=environment_name, **kwargs + ) + + security_profile = SecurityProfile[environment_context.get('security_profile', 'RECOMMENDED')] + + self.api = StateApi( + self, + 'StateApi', + environment_name=environment_name, + security_profile=security_profile, + persistent_stack=persistent_stack, + state_auth_stack=state_auth_stack, + domain_name=self.state_api_domain_name, + ) diff --git a/backend/social-work-app/stacks/state_api_stack/api.py b/backend/social-work-app/stacks/state_api_stack/api.py new file mode 100644 index 0000000000..2af1d3f0ce --- /dev/null +++ b/backend/social-work-app/stacks/state_api_stack/api.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from functools import cached_property + +from aws_cdk.aws_logs import QueryDefinition, QueryString +from common_constructs.compact_connect_api import CompactConnectApi +from constructs import Construct + +from stacks import persistent_stack as ps +from stacks.state_auth import StateAuthStack + + +class StateApi(CompactConnectApi): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + persistent_stack: ps.PersistentStack, + state_auth_stack: StateAuthStack, + **kwargs, + ): + super().__init__( + scope, + construct_id, + alarm_topic=persistent_stack.alarm_topic, + staff_users_user_pool=persistent_stack.staff_users, + **kwargs, + ) + from stacks.state_api_stack.v1_api import V1Api + + self._state_auth_stack = state_auth_stack + + # Initialize log groups list for QueryDefinition + self.log_groups = [] + + self.v1_api = V1Api(self.root, persistent_stack=persistent_stack) + + # Create the QueryDefinition after all API modules have been initialized and added their log groups + self._create_runtime_query_definition() + + @cached_property + def state_auth_authorizer(self): + from aws_cdk.aws_apigateway import CognitoUserPoolsAuthorizer + + return CognitoUserPoolsAuthorizer( + self, 'StateAuthAuthorizer', cognito_user_pools=[self._state_auth_stack.state_auth_users] + ) + + def _create_runtime_query_definition(self): + """Create the QueryDefinition for runtime logs after all API modules have been initialized.""" + QueryDefinition( + self, + 'RuntimeQuery', + query_definition_name=f'{self.node.id}/Lambdas', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'status', 'message', 'method', 'path', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=self.log_groups, + ) diff --git a/backend/social-work-app/stacks/state_api_stack/v1_api/__init__.py b/backend/social-work-app/stacks/state_api_stack/v1_api/__init__.py new file mode 100644 index 0000000000..e14e23d9ed --- /dev/null +++ b/backend/social-work-app/stacks/state_api_stack/v1_api/__init__.py @@ -0,0 +1,4 @@ +# ruff: noqa: F401 +# We place this import here so it can be referenced by other +# CDK resources +from .api import V1Api diff --git a/backend/social-work-app/stacks/state_api_stack/v1_api/api.py b/backend/social-work-app/stacks/state_api_stack/v1_api/api.py new file mode 100644 index 0000000000..6b0e430929 --- /dev/null +++ b/backend/social-work-app/stacks/state_api_stack/v1_api/api.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from aws_cdk.aws_apigateway import AuthorizationType, IResource, MethodOptions + +from stacks import persistent_stack as ps +from stacks.state_api_stack.v1_api.bulk_upload_url import BulkUploadUrl + +from .api_model import ApiModel +from .post_licenses import PostLicenses + + +class V1Api: + """v1 of the State API""" + + def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): + super().__init__() + from stacks.state_api_stack.api import StateApi + + self.root = root + self.resource = root.add_resource('v1') + self.api: StateApi = root.api + self.api_model = ApiModel(api=self.api) + _active_compacts = persistent_stack.get_list_of_compact_abbreviations() + + write_scopes = [] + # set the compact level scopes + for compact in _active_compacts: + write_scopes.append(f'{compact}/write') + + _active_compact_jurisdictions = persistent_stack.get_list_of_active_jurisdictions_for_compact_environment( + compact=compact + ) + + # We also include the jurisdiction level compact scopes for all jurisdictions active within the compact + # The one exception to this is the readPrivate scope, as this is exclusively checked in the runtime code + # to determine what data to return from the query related endpoints + for jurisdiction in _active_compact_jurisdictions: + write_scopes.append(f'{jurisdiction}/{compact}.write') + + write_auth_method_options = MethodOptions( + authorization_type=AuthorizationType.COGNITO, + authorizer=self.api.state_auth_authorizer, + authorization_scopes=write_scopes, + ) + + # /v1/compacts + self.compacts_resource = self.resource.add_resource('compacts') + # /v1/compacts/{compact} + self.compact_resource = self.compacts_resource.add_resource('{compact}') + + # /v1/compacts/{compact}/jurisdictions + self.compact_jurisdictions_resource = self.compact_resource.add_resource('jurisdictions') + # /v1/compacts/{compact}/jurisdictions/{jurisdiction} + self.compact_jurisdiction_resource = self.compact_jurisdictions_resource.add_resource('{jurisdiction}') + + # POST /v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses + # GET /v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses/bulk-upload + licenses_resource = self.compact_jurisdiction_resource.add_resource('licenses') + self.post_licenses = PostLicenses( + resource=licenses_resource, + method_options=write_auth_method_options, + persistent_stack=persistent_stack, + api_model=self.api_model, + ) + BulkUploadUrl( + resource=licenses_resource, + method_options=write_auth_method_options, + license_upload_role=persistent_stack.ssn_table.license_upload_role, + persistent_stack=persistent_stack, + api_model=self.api_model, + ) diff --git a/backend/social-work-app/stacks/state_api_stack/v1_api/api_model.py b/backend/social-work-app/stacks/state_api_stack/v1_api/api_model.py new file mode 100644 index 0000000000..adbb1bcfd6 --- /dev/null +++ b/backend/social-work-app/stacks/state_api_stack/v1_api/api_model.py @@ -0,0 +1,178 @@ +# ruff: noqa: SLF001 +# This class initializes the api models for the root api, which we then want to set as protected +# so other classes won't modify it. This is a valid use case for protected access to work with cdk. +from __future__ import annotations + +from aws_cdk.aws_apigateway import JsonSchema, JsonSchemaType, Model +from common_constructs.stack import AppStack + +# Importing module level to allow lazy loading for typing +from common_constructs import compact_connect_api + + +class ApiModel: + """This class is responsible for defining the model definitions used in the API endpoints.""" + + def __init__(self, api: compact_connect_api.CompactConnectApi): + self.stack: AppStack = AppStack.of(api) + self.api = api + + @property + def message_response_model(self) -> Model: + """Basic response that returns a string message""" + if hasattr(self.api, '_v1_message_response_model'): + return self.api._v1_message_response_model + self.api._v1_message_response_model = self.api.add_model( + 'V1MessageResponseModel', + description='Simple message response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['message'], + properties={ + 'message': JsonSchema( + type=JsonSchemaType.STRING, + description='A message about the request', + ), + }, + ), + ) + return self.api._v1_message_response_model + + @property + def post_licenses_error_response_model(self) -> Model: + """Response model for POST licenses which specifies error responses""" + if hasattr(self.api, '_v1_post_licenses_response_model'): + return self.api._v1_post_licenses_response_model + self.api._v1_post_licenses_response_model = self.api.add_model( + 'V1PostLicensesResponseModel', + description='POST licenses response model supporting both success and error responses', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'message': JsonSchema( + type=JsonSchemaType.STRING, + description='Message indicating success or failure', + ), + 'errors': JsonSchema( + type=JsonSchemaType.OBJECT, + description='Validation errors by record index', + additional_properties=JsonSchema( + type=JsonSchemaType.OBJECT, + description='Errors for a specific record', + additional_properties=JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema(type=JsonSchemaType.STRING), + description='List of error messages for a field', + ), + ), + ), + }, + ), + ) + return self.api._v1_post_licenses_response_model + + @property + def bulk_upload_response_model(self) -> Model: + """Return the Bulk Upload Response Model, which should only be created once per API""" + if hasattr(self.api, '_v1_bulk_upload_response_model'): + return self.api._v1_bulk_upload_response_model + + self.api._v1_bulk_upload_response_model = self.api.add_model( + 'BulkUploadResponseModel', + description='Bulk upload url response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['upload'], + properties={ + 'upload': JsonSchema( + type=JsonSchemaType.OBJECT, + required=['url', 'fields'], + properties={ + 'url': JsonSchema(type=JsonSchemaType.STRING), + 'fields': JsonSchema( + type=JsonSchemaType.OBJECT, + additional_properties=JsonSchema(type=JsonSchemaType.STRING), + ), + }, + ) + }, + ), + ) + return self.api._v1_bulk_upload_response_model + + @property + def post_license_model(self) -> Model: + """Return the Post License Model, which should only be created once per API""" + if hasattr(self.api, '_v1_post_license_model'): + return self.api._v1_post_license_model + + self.api._v1_post_license_model = self.api.add_model( + 'V1PostLicenseModel', + description='POST licenses request model', + schema=JsonSchema( + type=JsonSchemaType.ARRAY, + max_items=100, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=[ + 'ssn', + 'licenseNumber', + 'givenName', + 'familyName', + 'dateOfBirth', + 'homeAddressStreet1', + 'homeAddressCity', + 'homeAddressState', + 'homeAddressPostalCode', + 'licenseType', + 'dateOfIssuance', + 'dateOfExpiration', + 'licenseStatus', + 'compactEligibility', + ], + additional_properties=False, + properties={ + 'licenseType': JsonSchema(type=JsonSchemaType.STRING, enum=self.stack.license_type_names), + 'ssn': JsonSchema( + type=JsonSchemaType.STRING, + description="The provider's social security number", + pattern=compact_connect_api.SSN_FORMAT, + ), + **self._common_license_properties, + }, + ), + ), + ) + return self.api._v1_post_license_model + + @property + def _common_license_properties(self) -> dict: + return { + 'licenseNumber': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'middleName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'dateOfBirth': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'homeAddressStreet1': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressStreet2': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'homeAddressCity': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressState': JsonSchema(type=JsonSchemaType.STRING, min_length=2, max_length=100), + 'homeAddressPostalCode': JsonSchema(type=JsonSchemaType.STRING, min_length=5, max_length=7), + 'dateOfIssuance': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfRenewal': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'dateOfExpiration': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=compact_connect_api.YMD_FORMAT + ), + 'licenseStatus': JsonSchema(type=JsonSchemaType.STRING, enum=['active', 'inactive']), + 'licenseStatusName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + 'compactEligibility': JsonSchema(type=JsonSchemaType.STRING, enum=['eligible', 'ineligible']), + 'emailAddress': JsonSchema(type=JsonSchemaType.STRING, format='email', min_length=5, max_length=100), + 'phoneNumber': JsonSchema(type=JsonSchemaType.STRING, pattern=compact_connect_api.PHONE_NUMBER_FORMAT), + 'suffix': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100), + } diff --git a/backend/social-work-app/stacks/state_api_stack/v1_api/bulk_upload_url.py b/backend/social-work-app/stacks/state_api_stack/v1_api/bulk_upload_url.py new file mode 100644 index 0000000000..661572d07c --- /dev/null +++ b/backend/social-work-app/stacks/state_api_stack/v1_api/bulk_upload_url.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration +from aws_cdk.aws_apigateway import AuthorizationType, LambdaIntegration, MethodOptions, MethodResponse, Resource +from aws_cdk.aws_iam import IRole +from common_constructs.compact_connect_api import CompactConnectApi +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack + +from stacks import persistent_stack as ps + +from .api_model import ApiModel + + +class BulkUploadUrl: + def __init__( + self, + *, + resource: Resource, + method_options: MethodOptions, + license_upload_role: IRole, + persistent_stack: ps.PersistentStack, + api_model: ApiModel, + ): + super().__init__() + + self.resource = resource.add_resource('bulk-upload') + self.api: CompactConnectApi = resource.api + self.api_model = api_model + self.log_groups = [] + self._add_bulk_upload_url( + method_options=method_options, + license_upload_role=license_upload_role, + persistent_stack=persistent_stack, + ) + self.api.log_groups.extend(self.log_groups) + + def _get_bulk_upload_url_handler( + self, + *, + license_upload_role: IRole, + persistent_stack: ps.PersistentStack, + ) -> PythonFunction: + stack: Stack = Stack.of(self.resource) + handler = PythonFunction( + self.api, + 'V1BulkUrlHandler', + description='Get upload url handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'state_api.py'), + handler='bulk_upload_url_handler', + role=license_upload_role, + environment={ + 'BULK_BUCKET_NAME': persistent_stack.bulk_uploads_bucket.bucket_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + 'RATE_LIMITING_TABLE_NAME': persistent_stack.rate_limiting_table.table_name, + **stack.common_env_vars, + }, + alarm_topic=self.api.alarm_topic, + ) + # Grant the handler permissions to write to the bulk bucket + persistent_stack.bulk_uploads_bucket.grant_write(handler) + persistent_stack.compact_configuration_table.grant_read_data(handler) + persistent_stack.rate_limiting_table.grant_read_write_data(handler) + self.log_groups.append(handler.log_group) + + return handler + + def _add_bulk_upload_url( + self, + *, + method_options: MethodOptions, + license_upload_role: IRole, + persistent_stack: ps.PersistentStack, + ): + self.resource.add_method( + 'GET', + request_validator=self.api.parameter_body_validator, + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.bulk_upload_response_model}, + ), + ], + integration=LambdaIntegration( + self._get_bulk_upload_url_handler( + license_upload_role=license_upload_role, + persistent_stack=persistent_stack, + ), + timeout=Duration.seconds(29), + ), + request_parameters={'method.request.header.Authorization': True} + if method_options.authorization_type != AuthorizationType.NONE + else {}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) diff --git a/backend/social-work-app/stacks/state_api_stack/v1_api/post_licenses.py b/backend/social-work-app/stacks/state_api_stack/v1_api/post_licenses.py new file mode 100644 index 0000000000..03b76e9156 --- /dev/null +++ b/backend/social-work-app/stacks/state_api_stack/v1_api/post_licenses.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import os + +from aws_cdk import Duration +from aws_cdk.aws_apigateway import LambdaIntegration, MethodOptions, MethodResponse, Resource +from aws_cdk.aws_iam import IRole +from common_constructs.compact_connect_api import CompactConnectApi +from common_constructs.python_function import PythonFunction +from common_constructs.stack import Stack + +from stacks import persistent_stack as ps + +from .api_model import ApiModel + + +class PostLicenses: + def __init__( + self, + *, + resource: Resource, + method_options: MethodOptions, + persistent_stack: ps.PersistentStack, + api_model: ApiModel, + ): + super().__init__() + + self.resource = resource + self.api: CompactConnectApi = resource.api + self.api_model = api_model + self.log_groups = [] + + self._add_post_license( + method_options=method_options, + license_upload_role=persistent_stack.ssn_table.license_upload_role, + persistent_stack=persistent_stack, + ) + self.api.log_groups.extend(self.log_groups) + + def _add_post_license( + self, + method_options: MethodOptions, + license_upload_role: IRole, + persistent_stack: ps.PersistentStack, + ): + self.post_license_handler = self._post_licenses_handler( + license_upload_role=license_upload_role, + persistent_stack=persistent_stack, + ) + + # Normally, we have two layers of request body schema validation: one at the API gateway level, + # and one in the lambda handler logic. + # + # However, in this case, the API gateway request validation is insufficient for two core reasons: + # 1. It doesn't identify the row in which the validation error occurred, making it really + # difficult for state IT staff to triage which license record is invalid. + # 2. It doesn't always specify the field name where the validation error occurred which, + # combined with the missing row number, will create a miserable developer experience. + # + # For these reasons, we are not validating these requests at the API gateway level for this endpoint. + # The schema validation performed at the lambda layer provides a much clearer error message for the caller + # when validation errors occur. + self.post_license_endpoint = self.resource.add_method( + 'POST', + request_validator=self.api.parameter_only_validator, + request_models={'application/json': self.api_model.post_license_model}, + method_responses=[ + MethodResponse( + status_code='200', response_models={'application/json': self.api_model.message_response_model} + ), + MethodResponse( + status_code='400', + response_models={'application/json': self.api_model.post_licenses_error_response_model}, + ), + ], + integration=LambdaIntegration( + handler=self.post_license_handler, + timeout=Duration.seconds(29), + ), + request_parameters={'method.request.header.Authorization': True}, + authorization_type=method_options.authorization_type, + authorizer=method_options.authorizer, + authorization_scopes=method_options.authorization_scopes, + ) + + def _post_licenses_handler( + self, + license_upload_role: IRole, + persistent_stack: ps.PersistentStack, + ) -> PythonFunction: + stack: Stack = Stack.of(self.resource) + handler = PythonFunction( + self.api, + 'V1PostLicensesHandler', + description='Post licenses handler', + lambda_dir='provider-data-v1', + index=os.path.join('handlers', 'licenses.py'), + handler='post_licenses', + role=license_upload_role, + environment={ + 'LICENSE_PREPROCESSING_QUEUE_URL': persistent_stack.ssn_table.preprocessor_queue.queue.queue_url, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + 'RATE_LIMITING_TABLE_NAME': persistent_stack.rate_limiting_table.table_name, + **stack.common_env_vars, + }, + alarm_topic=self.api.alarm_topic, + ) + + # Grant permissions to put messages on the preprocessing queue + persistent_stack.ssn_table.preprocessor_queue.queue.grant_send_messages(handler) + persistent_stack.compact_configuration_table.grant_read_data(handler) + persistent_stack.rate_limiting_table.grant_read_write_data(handler) + + self.log_groups.append(handler.log_group) + return handler diff --git a/backend/social-work-app/stacks/state_auth/__init__.py b/backend/social-work-app/stacks/state_auth/__init__.py new file mode 100644 index 0000000000..65e8a5235e --- /dev/null +++ b/backend/social-work-app/stacks/state_auth/__init__.py @@ -0,0 +1,50 @@ +from aws_cdk import RemovalPolicy +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks.persistent_stack import PersistentStack +from stacks.state_auth.state_auth_users import StateAuthUsers + + +class StateAuthStack(AppStack): + """ + Stack containing the state API authentication resources (machine-to-machine user pool). + + This stack is separate from the persistent stack to allow for easier management + and reduce risk of cognito putting our persistent stack in an irrecoverable state. + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + app_name: str, + environment_name: str, + environment_context: dict, + persistent_stack: PersistentStack, + **kwargs, + ) -> None: + super().__init__( + scope, construct_id, environment_context=environment_context, environment_name=environment_name, **kwargs + ) + + self.persistent_stack = persistent_stack + + # If we delete this stack, retain the resource (orphan but prevent data loss) or destroy it (clean up)? + removal_policy = RemovalPolicy.RETAIN if environment_name == 'prod' else RemovalPolicy.DESTROY + + # Set up state auth domain prefix + state_auth_prefix = f'{app_name}-state-auth' + state_auth_prefix = ( + state_auth_prefix if environment_name == 'prod' else f'{state_auth_prefix}-{environment_name}' + ) + + # Create the state auth user pool for machine-to-machine authentication + self.state_auth_users = StateAuthUsers( + self, + 'StateAuthUsers', + cognito_domain_prefix=state_auth_prefix, + persistent_stack=persistent_stack, + removal_policy=removal_policy, + ) diff --git a/backend/social-work-app/stacks/state_auth/state_auth_users.py b/backend/social-work-app/stacks/state_auth/state_auth_users.py new file mode 100644 index 0000000000..116d04f25b --- /dev/null +++ b/backend/social-work-app/stacks/state_auth/state_auth_users.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from aws_cdk import Duration, RemovalPolicy +from aws_cdk.aws_cognito import ( + CognitoDomainOptions, + FeaturePlan, + Mfa, + MfaSecondFactor, + PasswordPolicy, + StandardThreatProtectionMode, + UserPool, +) +from cdk_nag import NagSuppressions +from constructs import Construct + +from common_constructs.resource_scope_mixin import ResourceScopeMixin +from stacks import persistent_stack as ps + + +class StateAuthUsers(UserPool, ResourceScopeMixin): + """ + User pool for state API machine-to-machine authentication + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + cognito_domain_prefix: str, + persistent_stack: ps.PersistentStack, + removal_policy: RemovalPolicy, + **kwargs, + ): + super().__init__( + scope, + construct_id, + removal_policy=removal_policy, + # These features are useless without actual users + feature_plan=FeaturePlan.LITE, + standard_threat_protection_mode=StandardThreatProtectionMode.NO_ENFORCEMENT, + # We don't intend this pool for actual users, so we might as well just set some strict policies + password_policy=PasswordPolicy( + min_length=32, + require_digits=True, + require_lowercase=True, + require_uppercase=True, + require_symbols=True, + temp_password_validity=Duration.days(1), + ), + mfa=Mfa.REQUIRED, + mfa_second_factor=MfaSecondFactor(otp=True, sms=False), + self_sign_up_enabled=False, + **kwargs, + ) + + self._add_resource_servers(stack=persistent_stack) + self.user_pool_domain = self.add_domain( + f'{construct_id}Domain', + cognito_domain=CognitoDomainOptions(domain_prefix=cognito_domain_prefix), + ) + + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'AwsSolutions-COG3', + 'reason': 'Threat protection mode enforcement offers no benefit when there are no users.', + }, + { + 'id': 'AwsSolutions-COG8', + 'reason': 'This pool is for state API machine-to-machine auth only; ' + 'Cognito Plus features are not relevant for a user pool with no users.', + }, + ], + ) diff --git a/backend/social-work-app/stacks/vpc_stack/__init__.py b/backend/social-work-app/stacks/vpc_stack/__init__.py new file mode 100644 index 0000000000..650889e9f9 --- /dev/null +++ b/backend/social-work-app/stacks/vpc_stack/__init__.py @@ -0,0 +1,228 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_ec2 import ( + FlowLogDestination, + FlowLogTrafficType, + GatewayVpcEndpointAwsService, + InterfaceVpcEndpointAwsService, + IpAddresses, + Port, + SecurityGroup, + SubnetConfiguration, + SubnetType, + Vpc, +) +from aws_cdk.aws_iam import ServicePrincipal +from aws_cdk.aws_kms import Key +from aws_cdk.aws_logs import LogGroup, RetentionDays +from cdk_nag import NagSuppressions +from common_constructs.stack import AppStack +from constructs import Construct + +PRIVATE_SUBNET_ONE_NAME = 'privateSubnet1' +PRIVATE_SUBNET_TWO_NAME = 'privateSubnet2' +PRIVATE_SUBNET_THREE_NAME = 'privateSubnet3' + + +class VpcStack(AppStack): + """ + Stack for VPC resources needed for OpenSearch Domain and Lambda functions. + + This stack provides network infrastructure including: + - VPC with private subnets across multiple availability zones + - VPC endpoints for AWS services (CloudWatch Logs, DynamoDB) + - Security groups for OpenSearch and Lambda functions + - VPC Flow Logs for network monitoring + + IMPORTANT - VPC Subnet CIDR Allocation Strategy: + ================================================= + This VPC uses explicit CIDR block overrides to prevent conflicts when expanding. + Each subnet CIDR is locked in using CloudFormation property overrides, which + allows safe addition of more AZs/subnets in the future without deployment failures. + + Current allocation from 10.0.0.0/16 VPC CIDR: + - Private subnets (3 AZs): 10.0.0.0/20, 10.0.16.0/20, 10.0.32.0/20 (4096 IPs each) + - Reserved for future expansion: 10.0.48.0/20, 10.0.64.0/20, etc. + + To add more subnets in the future: + 1. Increase max_azs (e.g., from 3 to 4) + 2. Add new CIDR blocks to the private_cidrs list (e.g., '10.0.48.0/20') + 3. Deploy - existing subnets won't be modified due to explicit CIDR overrides + + Solution reference: https://github.com/aws/aws-cdk/issues/24708#issuecomment-1665795316 + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + environment_context: dict, + **kwargs, + ): + super().__init__( + scope, construct_id, environment_context=environment_context, environment_name=environment_name, **kwargs + ) + + # Determine removal policy based on environment + removal_policy = RemovalPolicy.RETAIN if environment_name == 'prod' else RemovalPolicy.DESTROY + + self.vpc_encryption_key = Key( + self, + 'VpcEncryptionKey', + enable_key_rotation=True, + alias=f'{self.stack_name}-vpc-encryption-key', + removal_policy=removal_policy, + ) + + # Create VPC with private subnets across multiple availability zones + # Using explicit CIDR allocation to allow future expansion without conflicts + self.vpc = Vpc( + self, + 'CompactConnectVpc', + # No Internet or NAT Gateway needed - using VPC endpoints for AWS service access + create_internet_gateway=False, + nat_gateways=0, + ip_addresses=IpAddresses.cidr('10.0.0.0/16'), + # Use 3 AZs for high availability + # CDK will automatically select 3 AZs from the region + max_azs=3, + subnet_configuration=[ + SubnetConfiguration( + name='private', + subnet_type=SubnetType.PRIVATE_ISOLATED, + # cidr_mask is set to 20 to provide /20 subnets (4096 IPs each) + # However, we explicitly override the CIDR blocks below to lock them in + cidr_mask=20, + ), + ], + enable_dns_hostnames=True, + enable_dns_support=True, + ) + + # Explicitly set CIDR blocks for each subnet to prevent conflicts when expanding VPC + # This follows the solution from: https://github.com/aws/aws-cdk/issues/24708#issuecomment-1665795316 + # By locking in the CIDR blocks, we can safely add more AZs or public subnets in the future without + # CloudFormation errors. + private_cidrs = ['10.0.0.0/20', '10.0.16.0/20', '10.0.32.0/20'] + self._assign_subnet_cidr(PRIVATE_SUBNET_ONE_NAME, private_cidrs[0]) + self._assign_subnet_cidr(PRIVATE_SUBNET_TWO_NAME, private_cidrs[1]) + self._assign_subnet_cidr(PRIVATE_SUBNET_THREE_NAME, private_cidrs[2]) + + # grant access to Cloudwatch logs for vpc encryption key + logs_principal = ServicePrincipal('logs.amazonaws.com') + self.vpc_encryption_key.grant_encrypt_decrypt(logs_principal) + + # Create VPC Flow Logs for monitoring network traffic + flow_log_group = LogGroup( + self, + 'VpcFlowLogGroup', + retention=RetentionDays.ONE_MONTH, + removal_policy=removal_policy, + encryption_key=self.vpc_encryption_key, + ) + + self.vpc.add_flow_log( + 'VpcFlowLog', + destination=FlowLogDestination.to_cloud_watch_logs(flow_log_group), + traffic_type=FlowLogTrafficType.ALL, + ) + + # VPC Endpoint for CloudWatch Logs + # This allows Lambda functions in the VPC to send logs to CloudWatch without internet access + self.logs_vpc_endpoint = self.vpc.add_interface_endpoint( + 'LogsVpcEndpoint', + service=InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, + ) + + # Suppress CdkNag warnings for the auto-generated VPC endpoint security group + # These warnings occur because CDK creates security group rules with intrinsic functions + # that CdkNag cannot fully evaluate at synthesis time + NagSuppressions.add_resource_suppressions_by_path( + self, + path=self.logs_vpc_endpoint.node.path, + suppressions=[ + { + 'id': 'AwsSolutions-EC23', + 'reason': 'VPC endpoint security groups are automatically managed by CDK. Inbound rules are ' + 'appropriately restricted to HTTPS (port 443) from VPC CIDR block.', + }, + { + 'id': 'HIPAA.Security-EC2RestrictedCommonPorts', + 'reason': 'VPC endpoint security groups are automatically managed by CDK. Only HTTPS (port 443) ' + 'is allowed for CloudWatch Logs communication.', + }, + { + 'id': 'HIPAA.Security-EC2RestrictedSSH', + 'reason': 'VPC endpoint security groups are automatically managed by CDK. SSH is not enabled on ' + 'this security group.', + }, + ], + apply_to_children=True, + ) + + # VPC Endpoint for DynamoDB + # This allows Lambda functions to access DynamoDB without internet access + self.dynamodb_vpc_endpoint = self.vpc.add_gateway_endpoint( + 'DynamoDbVpcEndpoint', + service=GatewayVpcEndpointAwsService.DYNAMODB, + ) + + # VPC Endpoint for S3 + # This is needed for our custom resource which manages OpenSearch indices to access + # the CloudFormation S3 bucket without internet access + self.s3_vpc_endpoint = self.vpc.add_gateway_endpoint( + 'S3VpcEndpoint', + service=GatewayVpcEndpointAwsService.S3, + ) + + # Security Group for Lambda Functions + # This will control inbound and outbound traffic for Lambda functions that interact with OpenSearch + self.lambda_security_group = SecurityGroup( + self, + 'LambdaSecurityGroup', + vpc=self.vpc, + description='Security group for Lambda functions within VPC', + allow_all_outbound=True, # Allow Lambda to make outbound connections + ) + + # Security Group for OpenSearch Domain + # This will control inbound and outbound traffic for the OpenSearch cluster + self.opensearch_security_group = SecurityGroup( + self, + 'OpenSearchSecurityGroup', + vpc=self.vpc, + description='Security group for OpenSearch Domain', + allow_all_outbound=True, # Allow OpenSearch to make outbound connections + ) + # Allow Lambda functions to communicate with OpenSearch on port 443 (HTTPS) + self.opensearch_security_group.add_ingress_rule( + peer=self.lambda_security_group, + connection=Port.tcp(443), + description='Allow HTTPS traffic from Lambda functions', + ) + + def _assign_subnet_cidr(self, subnet_name: str, cidr: str): + """ + Explicitly assign a CIDR block to a subnet by overriding the CloudFormation property. + + This prevents CIDR conflicts when adding more AZs to the VPC in the future. + Without this override, CloudFormation attempts to reassign CIDR blocks when subnets/AZs are added, + causing deployment failures with "CIDR conflict" errors. See https://github.com/aws/aws-cdk/issues/24708 + + param subnet_name: The logical name of the subnet (e.g., 'privateSubnet1') + param cidr: The CIDR block to assign (e.g., '10.0.0.0/20') + """ + + # Navigate the construct tree to find the subnet + subnet_construct = self.vpc.node.try_find_child(subnet_name) + if subnet_construct is None: + raise ValueError(f'Subnet {subnet_name} not found in VPC') + + # Get the underlying CloudFormation subnet resource + cfn_subnet = subnet_construct.node.try_find_child('Subnet') + if cfn_subnet is None: + raise ValueError(f'CloudFormation Subnet resource not found for {subnet_name}') + + # Override the CIDR block property + cfn_subnet.add_property_override('CidrBlock', cidr) diff --git a/backend/social-work-app/tests/__init__.py b/backend/social-work-app/tests/__init__.py new file mode 100644 index 0000000000..f56f03fdc5 --- /dev/null +++ b/backend/social-work-app/tests/__init__.py @@ -0,0 +1,5 @@ +import os +import sys + +# Make the `common_constructs` namespace package under `common-cdk` available to Python +sys.path.insert(0, os.path.abspath(os.path.join('..', 'common-cdk'))) diff --git a/backend/social-work-app/tests/app/__init__.py b/backend/social-work-app/tests/app/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/tests/app/base.py b/backend/social-work-app/tests/app/base.py new file mode 100644 index 0000000000..77717fcb9d --- /dev/null +++ b/backend/social-work-app/tests/app/base.py @@ -0,0 +1,621 @@ +import json +import os +import sys +from abc import ABC, abstractmethod +from collections.abc import Mapping +from unittest.mock import patch + +from aws_cdk.assertions import Annotations, Match, Template +from aws_cdk.aws_apigateway import CfnGatewayResponse, CfnMethod +from aws_cdk.aws_cognito import CfnUserPool, CfnUserPoolClient, CfnUserPoolDomain, CfnUserPoolResourceServer +from aws_cdk.aws_dynamodb import CfnTable +from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_kms import CfnKey +from aws_cdk.aws_lambda import CfnEventSourceMapping +from aws_cdk.aws_sqs import CfnQueue +from common_constructs.backup_plan import CCBackupPlan +from common_constructs.stack import Stack + +from app import CompactConnectApp +from pipeline import BackendStage +from stacks.api_stack import ApiStack +from stacks.persistent_stack import PersistentStack +from stacks.state_auth import StateAuthStack + + +class _AppSynthesizer: + """ + A helper class to cache apps based on context. + This is useful to avoid re-synthesizing the app for each test. + """ + + def __init__(self): + super().__init__() + self._cached_apps: dict[int, CompactConnectApp] = {} + + def get_app(self, context: Mapping) -> CompactConnectApp: + context_hash = self._get_context_hash(context) + if context_hash not in self._cached_apps.keys(): + self._cached_apps[context_hash] = CompactConnectApp(context=context) + return self._cached_apps[context_hash] + + def _get_context_hash(self, context: Mapping) -> int: + return hash(json.dumps(context, sort_keys=True)) + + +_app_synthesizer = _AppSynthesizer() + + +class TstAppABC(ABC): + """ + Base class for common test elements across configurations. + + Note: Concrete classes must also inherit from TestCase + """ + + @classmethod + @abstractmethod + def get_context(cls) -> Mapping: + pass + + @classmethod + @patch.dict(os.environ, {'CDK_DEFAULT_ACCOUNT': '000000000000', 'CDK_DEFAULT_REGION': 'us-east-1'}) + def setUpClass(cls): # pylint: disable=invalid-name + """ + We build the app once per TestCase, to save compute time in the test suite + """ + cls._overwrite_snapshots = False + cls.set_overwrite_snapshots() + + cls.context = cls.get_context() + cls.app = _app_synthesizer.get_app(cls.context) + + @classmethod + def set_overwrite_snapshots(cls): + """ + Allow environment variable to force snapshot comparisons to overwrite the snapshot + + ``` + OVERWRITE_SNAPSHOTS=true pytest tests + ``` + """ + cls._overwrite_snapshots = os.environ.get('OVERWRITE_SNAPSHOTS', 'false').lower() == 'true' + + def test_no_compact_jurisdiction_name_clash(self): + """ + Because compact and jurisdiction abbreviations share space in access token scopes, we need to ensure that + there are no naming clashes between the two. + """ + jurisdictions = set(self.context['jurisdictions']) + compacts = set(self.context['compacts']) + # The '#' character is used in the composite identifiers in the database. In order to prevent confusion in + # parsing the identifiers, we either have to carefully escape all '#' characters that might show up in compact + # or jurisdiction abbreviations or simply not allow them. Since the abbreviations seem unlikely to include a # + # character, the latter seems reasonable. + for jurisdiction in jurisdictions: + self.assertNotIn('#', jurisdiction, "'#' not allowed in jurisdiction abbreviations!") + for compact in compacts: + self.assertNotIn('#', compact, "'#' not allowed in compact abbreviations!") + self.assertFalse(jurisdictions.intersection(compacts), 'Compact vs jurisdiction name clash!') + + @staticmethod + def get_resource_properties_by_logical_id(logical_id: str, resources: Mapping[str, Mapping]) -> Mapping: + """ + Helper function to retrieve a resource from a CloudFormation template by its logical ID. + """ + try: + return resources[logical_id]['Properties'] + except KeyError as exc: + raise RuntimeError(f'{logical_id} not found in resources!') from exc + + def _inspect_state_auth_stack( + self, + state_auth_stack: StateAuthStack, + ): + with self.subTest(state_auth_stack.stack_name): + state_auth_stack_template = Template.from_stack(state_auth_stack) + + # Basic resource count validation + state_auth_stack_template.resource_count_is(CfnUserPool.CFN_RESOURCE_TYPE_NAME, 1) + state_auth_stack_template.resource_count_is( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, 0 + ) # Manual provisioning + state_auth_stack_template.resource_count_is('AWS::Cognito::UserPoolDomain', 1) + + # Fundamental security configuration + state_auth_stack_template.has_resource_properties( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + { + 'AdminCreateUserConfig': { + 'AllowAdminCreateUserOnly': True, + }, + 'Policies': { + 'PasswordPolicy': { + 'MinimumLength': 32, + 'RequireNumbers': True, + 'RequireLowercase': True, + 'RequireUppercase': True, + 'RequireSymbols': True, + 'TemporaryPasswordValidityDays': 1, + }, + }, + }, + ) + state_auth_stack_template.has_resource(CfnUserPoolDomain.CFN_RESOURCE_TYPE_NAME, {}) + state_auth_stack_template.has_resource(CfnUserPoolResourceServer.CFN_RESOURCE_TYPE_NAME, {}) + + def _inspect_persistent_stack( + self, + persistent_stack: PersistentStack, + *, + ui_domain_name: str = None, + allow_local_ui: bool = False, + local_ui_port: str = None, + ): + with self.subTest(persistent_stack.stack_name): + # Make sure our local port ui setting overrides the default + persistent_stack_template = Template.from_stack(persistent_stack) + + callbacks = [] + if ui_domain_name is not None: + callbacks.append(f'https://{ui_domain_name}/auth/callback') + if allow_local_ui: + # 3018 is default + local_ui_port = '3018' if not local_ui_port else local_ui_port + callbacks.append(f'http://localhost:{local_ui_port}/auth/callback') + + # ensure we have one user pool defined in persistent stack for staff users + persistent_stack_template.resource_count_is(CfnUserPool.CFN_RESOURCE_TYPE_NAME, 1) + + # Ensure our Staff user pool app client is configured with the expected callbacks and read/write attributes + staff_users_user_pool_app_client = self.get_resource_properties_by_logical_id( + persistent_stack.get_logical_id(persistent_stack.staff_users.ui_client.node.default_child), + persistent_stack_template.find_resources(CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual(staff_users_user_pool_app_client['CallbackURLs'], callbacks) + self.assertEqual(staff_users_user_pool_app_client['ReadAttributes'], ['email']) + self.assertEqual(staff_users_user_pool_app_client['WriteAttributes'], ['email']) + + self._inspect_data_events_table(persistent_stack, persistent_stack_template) + self._inspect_ssn_table(persistent_stack, persistent_stack_template) + self._inspect_backup_resources(persistent_stack, persistent_stack_template) + + def _inspect_ssn_table(self, persistent_stack: PersistentStack, persistent_stack_template: Template): + ssn_key_logical_id = persistent_stack.get_logical_id(persistent_stack.ssn_table.key.node.default_child) + ingest_role_logical_id = persistent_stack.get_logical_id( + persistent_stack.ssn_table.ingest_role.node.default_child + ) + license_upload_role_logical_id = persistent_stack.get_logical_id( + persistent_stack.ssn_table.license_upload_role.node.default_child + ) + disaster_recovery_lambda_role_logical_id = persistent_stack.get_logical_id( + persistent_stack.ssn_table.disaster_recovery_lambda_role.node.default_child + ) + disaster_recovery_step_function_role_logical_id = persistent_stack.get_logical_id( + persistent_stack.ssn_table.disaster_recovery_step_function_role.node.default_child + ) + + # Build the expected PrincipalArn array - always includes 4 roles, plus optional backup role + # Note: SSN backup role reference may be a nested stack output, so we use Match.any_value() for flexibility + principal_arn_array = [ + {'Fn::GetAtt': [ingest_role_logical_id, 'Arn']}, + {'Fn::GetAtt': [license_upload_role_logical_id, 'Arn']}, + {'Fn::GetAtt': [disaster_recovery_lambda_role_logical_id, 'Arn']}, + {'Fn::GetAtt': [disaster_recovery_step_function_role_logical_id, 'Arn']}, + ] + if persistent_stack.environment_context['backup_enabled']: + # if backup is enabled, we add an additional principal arn for the backup role to the SSN policy to + # perform backups on data + principal_arn_array.append(Match.any_value()) # SSN backup role reference (may be nested stack output) + + # Ensure our SSN Key is locked down by resource policy + persistent_stack_template.has_resource( + CfnKey.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'KeyPolicy': { + 'Statement': Match.array_with( + [ + { + 'Action': 'kms:*', + 'Effect': 'Allow', + 'Principal': {'AWS': f'arn:aws:iam::{persistent_stack.account}:root'}, + 'Resource': '*', + }, + { + 'Action': ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + 'Condition': { + 'StringNotEquals': { + 'aws:PrincipalArn': principal_arn_array, + 'aws:PrincipalServiceName': [ + 'dynamodb.amazonaws.com', + 'events.amazonaws.com', + ], + } + }, + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + }, + ] + ), + 'Version': '2012-10-17', + } + } + }, + ) + + persistent_stack_template.has_resource( + CfnTable.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + # This naming convention is important for opting into future CloudTrail organization access logging + # don't remove the -DateEventsLog suffix + 'TableName': 'ssn-table-DataEventsLog', + 'ResourcePolicy': { + 'PolicyDocument': { + 'Statement': Match.array_with( + [ + { + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + 'Action': 'dynamodb:CreateBackup', + 'Condition': { + 'StringNotEquals': {'aws:PrincipalServiceName': 'dynamodb.amazonaws.com'} + }, + }, + { + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + 'Action': [ + 'dynamodb:BatchGetItem', + 'dynamodb:BatchWriteItem', + 'dynamodb:PartiQL*', + 'dynamodb:Scan', + ], + 'Condition': { + 'StringNotEquals': { + 'aws:PrincipalServiceName': 'dynamodb.amazonaws.com', + 'aws:PrincipalArn': Match.any_value(), + } + }, + }, + { + 'Action': ['dynamodb:ConditionCheckItem', 'dynamodb:GetItem', 'dynamodb:Query'], + 'Effect': 'Deny', + 'Principal': '*', + 'NotResource': Match.string_like_regexp( + f'arn:aws:dynamodb:{persistent_stack.region}:{persistent_stack.account}:table/ssn-table-DataEventsLog/index/ssnIndex' + ), + }, + ] + ) + } + }, + 'SSESpecification': { + 'KMSMasterKeyId': {'Fn::GetAtt': [ssn_key_logical_id, 'Arn']}, + 'SSEEnabled': True, + 'SSEType': 'KMS', + }, + } + }, + ) + + def _inspect_backup_resources(self, persistent_stack: PersistentStack, persistent_stack_template: Template): + """Validate that backup resources are created for tables and buckets with backup plans.""" + from aws_cdk.aws_backup import CfnBackupPlan, CfnBackupSelection + + # if in env with backup_enabled, Should have 6 backup plans, 6 backup selections: + # - provider table + # - SSN table + # - compact config table + # - data event table + # - staff users table + # - staff cognito user backup + # Every other environment should be 0 + + if persistent_stack.environment_context['backup_enabled']: + persistent_stack_template.resource_count_is(CfnBackupPlan.CFN_RESOURCE_TYPE_NAME, 6) + persistent_stack_template.resource_count_is(CfnBackupSelection.CFN_RESOURCE_TYPE_NAME, 6) + + for plan in [ + persistent_stack.provider_table.backup_plan, + persistent_stack.ssn_table.backup_plan, + persistent_stack.compact_configuration_table.backup_plan, + persistent_stack.data_event_table.backup_plan, + persistent_stack.staff_users.user_table.backup_plan, + persistent_stack.staff_users.backup_system.backup_plan, + ]: + self.assertIsInstance(plan, CCBackupPlan) + else: + persistent_stack_template.resource_count_is(CfnBackupPlan.CFN_RESOURCE_TYPE_NAME, 0) + persistent_stack_template.resource_count_is(CfnBackupSelection.CFN_RESOURCE_TYPE_NAME, 0) + + # Verify that backup plans are None when backups are disabled + self.assertIsNone(persistent_stack.provider_table.backup_plan) + self.assertIsNone(persistent_stack.ssn_table.backup_plan) + self.assertIsNone(persistent_stack.compact_configuration_table.backup_plan) + self.assertIsNone(persistent_stack.data_event_table.backup_plan) + self.assertIsNone(persistent_stack.staff_users.user_table.backup_plan) + self.assertIsNone(persistent_stack.staff_users.backup_system) + + def _inspect_data_events_table(self, persistent_stack: PersistentStack, persistent_stack_template: Template): + # Ensure our DataEventTable and queues are created + event_bus_logical_id = persistent_stack.get_logical_id(persistent_stack._data_event_bus.node.default_child) # noqa: SLF001 private_member_access + queue_logical_id = persistent_stack.get_logical_id( + persistent_stack.data_event_table.event_processor.queue.node.default_child + ) + dlq_logical_id = persistent_stack.get_logical_id( + persistent_stack.data_event_table.event_processor.dlq.node.default_child + ) + + self.get_resource_properties_by_logical_id( + persistent_stack.get_logical_id(persistent_stack.data_event_table.node.default_child), + persistent_stack_template.find_resources(CfnTable.CFN_RESOURCE_TYPE_NAME), + ) + self.get_resource_properties_by_logical_id( + queue_logical_id, + persistent_stack_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + self.get_resource_properties_by_logical_id( + dlq_logical_id, + persistent_stack_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + # Events from bus to queue + rules = persistent_stack_template.find_resources( + type=CfnRule.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'EventBusName': {'Ref': event_bus_logical_id}, + 'Targets': [ + { + 'Arn': { + 'Fn::GetAtt': [ + queue_logical_id, + 'Arn', + ] + } + } + ], + } + }, + ) + self.assertEqual(1, len(rules)) + rule = [rule for rule in rules.values()][0] + self.compare_snapshot(rule, 'DATA_EVENT_RULE', overwrite_snapshot=False) + + # Events from queue to lambda + persistent_stack_template.has_resource( + type=CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'EventSourceArn': { + 'Fn::GetAtt': [ + queue_logical_id, + 'Arn', + ] + }, + 'FunctionName': { + 'Ref': persistent_stack.get_logical_id( + persistent_stack.data_event_table.event_handler.node.default_child + ) + }, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + }, + }, + ) + + def _inspect_api_stack(self, api_stack: ApiStack): + with self.subTest(api_stack.stack_name): + api_template = Template.from_stack(api_stack) + + with self.assertRaises(RuntimeError): + # This is an indicator of unintentional (and invalid) authorizer configuration in the API. + # Not matching is desired in this case and raises a RuntimeError. + api_template.has_resource( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'AuthorizationScopes': Match.any_value(), 'AuthorizationType': 'NONE'}}, + ) + + # This is what the auto-generated preflight CORS OPTIONS methods looks like. If we have one match + # we probably have a ton, so we'll just check for the presence of one method that looks like this. + api_template.has_resource( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'HttpMethod': 'OPTIONS', + 'Integration': { + 'IntegrationResponses': [ + { + 'ResponseParameters': { + 'method.response.header.Access-Control-Allow-Headers': ( + "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token," + "X-Amz-User-Agent,cache-control'" + ), + 'method.response.header.Access-Control-Allow-Origin': ( + f"'{api_stack.allowed_origins[0]}'" + ), + 'method.response.header.Vary': "'Origin'", + 'method.response.header.Access-Control-Allow-Methods': ( + "'OPTIONS,GET,PUT,POST,DELETE,PATCH,HEAD'" + ), + }, + 'StatusCode': '204', + } + ], + 'RequestTemplates': {'application/json': '{ statusCode: 200 }'}, + 'Type': 'MOCK', + }, + 'MethodResponses': [ + { + 'ResponseParameters': { + 'method.response.header.Access-Control-Allow-Headers': True, + 'method.response.header.Access-Control-Allow-Origin': True, + 'method.response.header.Vary': True, + 'method.response.header.Access-Control-Allow-Methods': True, + }, + 'StatusCode': '204', + } + ], + 'RestApiId': {'Ref': api_stack.get_logical_id(api_stack.api.node.default_child)}, + } + }, + ) + + # The GatewayResponses we configure should have a single specific origin, unless we have more than one origin + # in which case we should have the catch-all '*' origin. + if len(api_stack.allowed_origins) > 1: + api_template.has_resource( + CfnGatewayResponse.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ResponseParameters': {'gatewayresponse.header.Access-Control-Allow-Origin': "'*'"}, + 'RestApiId': {'Ref': api_stack.get_logical_id(api_stack.api.node.default_child)}, + }, + }, + ) + else: + api_template.has_resource( + CfnGatewayResponse.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ResponseParameters': { + 'gatewayresponse.header.Access-Control-Allow-Origin': f"'{api_stack.allowed_origins[0]}'" + }, + 'RestApiId': {'Ref': api_stack.get_logical_id(api_stack.api.node.default_child)}, + }, + }, + ) + + # When a custom domain is configured, verify the API Gateway domain uses TLS 1.2 + if api_stack.hosted_zone is not None: + api_template.has_resource_properties( + 'AWS::ApiGateway::DomainName', + { + 'SecurityPolicy': 'TLS_1_2', + }, + ) + + def _check_no_stack_annotations(self, stack: Stack): + with self.subTest(f'Security Rules: {stack.stack_name}'): + errors = Annotations.from_stack(stack).find_error('*', Match.string_like_regexp('.*')) + self.assertEqual(0, len(errors), msg='\n'.join(f'{err.id}: {err.entry.data.strip()}' for err in errors)) + + warnings = Annotations.from_stack(stack).find_warning('*', Match.string_like_regexp('.*')) + self.assertEqual( + 0, len(warnings), msg='\n'.join(f'{warn.id}: {warn.entry.data.strip()}' for warn in warnings) + ) + + def _check_no_backend_stage_annotations(self, stage: BackendStage): + self._check_no_stack_annotations(stage.api_lambda_stack) + self._check_no_stack_annotations(stage.api_stack) + self._check_no_stack_annotations(stage.disaster_recovery_stack) + # TODO - add this check back in once feature flags are enabled # noqa: FIX002 + # self._check_no_stack_annotations(stage.feature_flag_stack) + self._check_no_stack_annotations(stage.ingest_stack) + self._check_no_stack_annotations(stage.managed_login_stack) + self._check_no_stack_annotations(stage.persistent_stack) + self._check_no_stack_annotations(stage.state_api_stack) + self._check_no_stack_annotations(stage.state_auth_stack) + # These are only present if a hosted zone is configured + if stage.persistent_stack.hosted_zone: + self._check_no_stack_annotations(stage.notification_stack) + self._check_no_stack_annotations(stage.reporting_stack) + # No backup stack here, because nexted stack annotations are checked in the parent stack + + def _count_stack_resources(self, stack: Stack) -> int: + """ + Count the number of resources in a CloudFormation stack. + + :param stack: The CDK Stack to analyze + :returns: Number of resources in the stack + """ + template = Template.from_stack(stack) + # Get template as dictionary and count resources + template_dict = template.to_json() + resources = template_dict.get('Resources', {}) + return len(resources) + + def _check_backend_stage_resource_counts(self, stage: BackendStage): + """ + Check resource counts for all stacks in a BackendStage and emit warnings/errors. + + Emits a warning if any stack has more than 400 resources. + Fails the test if any stack has more than 475 resources. + + :param stage: The BackendStage containing stacks to check + """ + stacks_to_check = [ + ('api_lambda_stack', stage.api_lambda_stack), + ('api_stack', stage.api_stack), + ('backup_infrastructure_stack', stage.backup_infrastructure_stack), + ('disaster_recovery_stack', stage.disaster_recovery_stack), + ('ingest_stack', stage.ingest_stack), + ('managed_login_stack', stage.managed_login_stack), + ('persistent_stack', stage.persistent_stack), + ('state_api_stack', stage.state_api_stack), + ('state_auth_stack', stage.state_auth_stack), + # TODO - add this check back in once feature flags are enabled # noqa: FIX002 + # ('feature_flag_stack', stage.feature_flag_stack), + ] + if stage.persistent_stack.hosted_zone: + stacks_to_check.extend( + [ + ('notification_stack', stage.notification_stack), + ('reporting_stack', stage.reporting_stack), + ] + ) + + for _stack_name, stack in stacks_to_check: + if stack is None: + continue + with self.subTest(f'Resource Count: {stack.stack_name}'): + resource_count = self._count_stack_resources(stack) + + if resource_count > 475: + self.fail( + f'{_stack_name} has {resource_count} resources, which exceeds the ' + 'error threshold of 475. Consider splitting this stack or reducing resource count.' + ) + elif resource_count > 400: + sys.stderr.write( + f'WARNING: {_stack_name} has {resource_count} resources, which exceeds the ' + 'warning threshold of 400. Consider monitoring for future growth.\n' + ) + + # Also log the count for visibility in test output + sys.stdout.write(f'INFO: {_stack_name} has {resource_count} resources\n') + + def compare_snapshot(self, actual: Mapping | list, snapshot_name: str, overwrite_snapshot: bool = False): + """ + Compare the actual dictionary to the snapshot with the given name. + If overwrite_snapshot is True, overwrite the snapshot with the actual data. + """ + # Let class attribute force true + overwrite_snapshot = overwrite_snapshot or self._overwrite_snapshots + + snapshot_path = os.path.join('tests', 'resources', 'snapshots', f'{snapshot_name}.json') + + if os.path.exists(snapshot_path): + with open(snapshot_path) as f: + snapshot = json.load(f) + else: + sys.stdout.write(f"Snapshot at path '{snapshot_path}' does not exist.") + snapshot = None + + if snapshot != actual and overwrite_snapshot: + with open(snapshot_path, 'w') as f: + json.dump(actual, f, indent=2) + # So the data files will end with a newline + f.write('\n') + sys.stdout.write(f"Snapshot '{snapshot_name}' has been overwritten.") + else: + self.maxDiff = None # pylint: disable=invalid-name,attribute-defined-outside-init + self.assertEqual( + snapshot, + actual, + f"Snapshot '{snapshot_name}' does not match the actual data. " + 'To overwrite the snapshot, set overwrite_snapshot=True.', + ) diff --git a/backend/social-work-app/tests/app/test_api/__init__.py b/backend/social-work-app/tests/app/test_api/__init__.py new file mode 100644 index 0000000000..6cbf1a3e8b --- /dev/null +++ b/backend/social-work-app/tests/app/test_api/__init__.py @@ -0,0 +1,83 @@ +import json +from unittest import TestCase + +from aws_cdk.assertions import Template +from aws_cdk.aws_lambda import IFunction + +from stacks.api_lambda_stack import ApiLambdaStack +from tests.app.base import TstAppABC + + +class TestApi(TstAppABC, TestCase): + """ + Base API test class with common methods for Compact Connect API resources. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + @staticmethod + def generate_expected_integration_object(handler_logical_id: str) -> dict: + """ + This method should be used for api gateway resources that are integrating with lambdas created directly + within the ApiStack. If the lambda being used by the endpoint is imported from the ApiLambdaStack, please use + generate_expected_integration_object_for_imported_lambda instead. + """ + return { + 'Uri': { + 'Fn::Join': [ + '', + [ + 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/', + {'Fn::GetAtt': [handler_logical_id, 'Arn']}, + '/invocations', + ], + ] + } + } + + @staticmethod + def generate_expected_integration_object_for_imported_lambda( + api_lambda_stack: ApiLambdaStack, api_lambda_stack_template: Template, handler: IFunction + ) -> dict: + """ + This method should be used for api gateway resources that are integrating with lambdas imported + from the ApiLambdaStack. This handles the logic of extracting the output name from the stack which + should be referenced in the ApiStack template. + """ + handler_logical_id = api_lambda_stack.get_logical_id(handler.node.default_child) + stack_outputs = api_lambda_stack_template.find_outputs('*') + # Get the matching lambda arn output name from the api lambda stack + matching_output = [ + output_value['Export']['Name'] + for output_id, output_value in stack_outputs.items() + if 'Fn::GetAtt' in output_value['Value'] + and output_value['Value']['Fn::GetAtt'][0] == handler_logical_id + and output_value['Value']['Fn::GetAtt'][1] == 'Arn' + ] + + if not matching_output: + raise ValueError( + f'Expected lambda function arn not found in api lambda stack outputs for lambda: {handler_logical_id}' + ) + + return { + 'Uri': { + 'Fn::Join': [ + '', + [ + 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/', + {'Fn::ImportValue': matching_output[0]}, + '/invocations', + ], + ] + } + } diff --git a/backend/social-work-app/tests/app/test_api/test_compact_configuration_api.py b/backend/social-work-app/tests/app/test_api/test_compact_configuration_api.py new file mode 100644 index 0000000000..4881784f6a --- /dev/null +++ b/backend/social-work-app/tests/app/test_api/test_compact_configuration_api.py @@ -0,0 +1,441 @@ +from aws_cdk.assertions import Capture, Template +from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.test_api import TestApi + + +class TestCompactConfigurationApi(TestApi): + """ + These tests are focused on checking API endpoint configuration related to fetching compact configuration. + + When adding or modifying API resources related to compact configuration data, a test should be added to ensure + that the resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. The compact configuration api function is referenced with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def test_synth_generates_get_staff_users_compact_jurisdictions_resource(self): + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected '{compact}' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.compact_resource.node.default_child), + }, + 'PathPart': 'jurisdictions', + }, + ) + + # Get the jurisdictions resource + jurisdictions_resource_id = api_stack.get_logical_id( + api_stack.api.v1_api.jurisdictions_resource.node.default_child + ) + + # Ensure the lambda is created with expected code path in the ApiLambdaStack + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + compact_configuration_api_handler = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id( + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler.node.default_child + ), + api_lambda_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + compact_configuration_api_handler['Handler'], + 'handlers.compact_configuration.compact_configuration_api_handler', + ) + + # Ensure the GET method is configured with the lambda integration + method_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'ResourceId': {'Ref': jurisdictions_resource_id}, + # ensure staff users authorizer is being used + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': method_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # now check the response model matches expected contract + get_compact_jurisdictions_response_model = TestApi.get_resource_properties_by_logical_id( + method_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + get_compact_jurisdictions_response_model['Schema'], + 'GET_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_get_public_compact_jurisdictions_resource(self): + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected '{compact}' resource + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.public_compacts_compact_resource.node.default_child + ), + }, + 'PathPart': 'jurisdictions', + }, + ) + + # Ensure the GET method is configured with the lambda integration + method_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': method_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # now check the response model matches expected contract + get_compact_jurisdictions_response_model = TestApi.get_resource_properties_by_logical_id( + method_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + get_compact_jurisdictions_response_model['Schema'], + 'GET_PUBLIC_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_get_live_jurisdictions_resource(self): + """Test that the GET /v1/public/jurisdictions/live + endpoint is properly configured as a public endpoint""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the /v1/public/jurisdictions resource is created + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'public/' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.public_resource.node.default_child), + }, + 'PathPart': 'jurisdictions', + }, + ) + + # Ensure the /v1/public/jurisdictions/live resource is created + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'public/jurisdictions' resource + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.public_jurisdictions_resource.node.default_child + ), + }, + 'PathPart': 'live', + }, + ) + + # Get the live jurisdictions resource + live_jurisdictions_resource_id = api_stack.get_logical_id( + api_stack.api.v1_api.live_jurisdictions_resource.node.default_child + ) + + # Ensure the GET method is configured with the lambda integration (no authorizer since it's public) + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'ResourceId': {'Ref': live_jurisdictions_resource_id}, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'MethodResponses': [ + { + 'StatusCode': '200', + }, + ], + }, + ) + + def test_synth_generates_get_compact_configuration_endpoint(self): + """Test that the GET /v1/compacts/{compact} endpoint is properly configured""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Get the compact resource + compact_resource_id = api_stack.get_logical_id(api_stack.api.v1_api.compact_resource.node.default_child) + + # Ensure the GET method is configured with the lambda integration + method_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'ResourceId': {'Ref': compact_resource_id}, + # ensure staff users authorizer is being used + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': method_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # check the response model matches expected contract + get_compact_configuration_response_model = TestApi.get_resource_properties_by_logical_id( + method_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + get_compact_configuration_response_model['Schema'], + 'GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_put_compact_configuration_endpoint(self): + """Test that the PUT /v1/compacts/{compact} endpoint is properly configured""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Get the compact resource + compact_resource_id = api_stack.get_logical_id(api_stack.api.v1_api.compact_resource.node.default_child) + + # Ensure the PUT method is configured with the lambda integration + request_model_logical_id_capture = Capture() + response_model_logical_id_capture = Capture() + + # ensure the PUT method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'PUT', + 'ResourceId': {'Ref': compact_resource_id}, + # ensure staff users authorizer is being used + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'RequestModels': {'application/json': {'Ref': request_model_logical_id_capture}}, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # check the request model matches expected contract + post_compact_request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + post_compact_request_model['Schema'], + 'PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + # check the response model matches expected contract + message_response_model = TestApi.get_resource_properties_by_logical_id( + response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + message_response_model['Schema'], + 'STANDARD_MESSAGE_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_get_jurisdiction_configuration_endpoint(self): + """Test that the GET /v1/compacts/{compact}/jurisdictions/{jurisdiction} endpoint is properly configured""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Get the jurisdiction resource + jurisdiction_resource_id = api_stack.get_logical_id( + api_stack.api.v1_api.jurisdiction_resource.node.default_child + ) + + # Ensure the GET method is configured with the lambda integration + method_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'ResourceId': {'Ref': jurisdiction_resource_id}, + # ensure staff users authorizer is being used + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': method_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # check the response model matches expected contract + get_jurisdiction_response_model = TestApi.get_resource_properties_by_logical_id( + method_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + get_jurisdiction_response_model['Schema'], + 'GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_put_jurisdiction_configuration_endpoint(self): + """Test that the PUT /v1/compacts/{compact}/jurisdictions/{jurisdiction} endpoint is properly configured""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Get the jurisdiction resource + jurisdiction_resource_id = api_stack.get_logical_id( + api_stack.api.v1_api.jurisdiction_resource.node.default_child + ) + + request_model_logical_id_capture = Capture() + response_model_logical_id_capture = Capture() + + # ensure the PUT method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'PUT', + 'ResourceId': {'Ref': jurisdiction_resource_id}, + # ensure staff users authorizer is being used + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.compact_configuration_lambdas.compact_configuration_api_handler, + ), + 'RequestModels': {'application/json': {'Ref': request_model_logical_id_capture}}, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # check the request model matches expected contract + post_jurisdiction_request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + post_jurisdiction_request_model['Schema'], + 'PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + # check the response model matches expected contract + message_response_model = TestApi.get_resource_properties_by_logical_id( + response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + message_response_model['Schema'], + 'STANDARD_MESSAGE_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) diff --git a/backend/social-work-app/tests/app/test_api/test_investigation_api.py b/backend/social-work-app/tests/app/test_api/test_investigation_api.py new file mode 100644 index 0000000000..367e45217e --- /dev/null +++ b/backend/social-work-app/tests/app/test_api/test_investigation_api.py @@ -0,0 +1,463 @@ +from aws_cdk.assertions import Capture, Template +from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.test_api import TestApi + + +class TestInvestigationApi(TestApi): + """ + These tests are focused on checking that the API endpoints for investigation functionality + are configured correctly. + + When adding or modifying API resources under /investigation/, a test should be added to ensure that the + resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. If the resource has a lambda function associated with it, the function is present with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def _get_privilege_investigation_resource_id(self, api_stack_template, api_stack): + """Helper method to get the privilege investigation resource ID by traversing the resource hierarchy.""" + license_type_param_logical_id = self._get_privilege_license_type_param_resource_id( + api_stack_template, api_stack + ) + + investigation_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_param_logical_id}, + 'PathPart': 'investigation', + } + }, + ) + self.assertEqual(len(investigation_resource_logical_ids), 1) + return next(key for key in investigation_resource_logical_ids.keys()) + + def _get_privilege_investigation_id_resource_id(self, api_stack_template, api_stack): + """Helper method to get the privilege investigation {investigationId} resource ID.""" + investigation_resource_logical_id = self._get_privilege_investigation_resource_id(api_stack_template, api_stack) + + investigation_id_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': investigation_resource_logical_id}, + 'PathPart': '{investigationId}', + } + }, + ) + self.assertEqual(len(investigation_id_resource_logical_ids), 1) + return next(key for key in investigation_id_resource_logical_ids.keys()) + + def _get_license_investigation_resource_id(self, api_stack_template, api_stack): + """Helper method to get the license investigation resource ID by traversing the resource hierarchy.""" + license_type_param_logical_id = self._get_license_license_type_param_resource_id(api_stack_template, api_stack) + + investigation_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_param_logical_id}, + 'PathPart': 'investigation', + } + }, + ) + self.assertEqual(len(investigation_resource_logical_ids), 1) + return next(key for key in investigation_resource_logical_ids.keys()) + + def _get_license_investigation_id_resource_id(self, api_stack_template, api_stack): + """Helper method to get the license investigation {investigationId} resource ID.""" + investigation_resource_logical_id = self._get_license_investigation_resource_id(api_stack_template, api_stack) + + investigation_id_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': investigation_resource_logical_id}, + 'PathPart': '{investigationId}', + } + }, + ) + self.assertEqual(len(investigation_id_resource_logical_ids), 1) + return next(key for key in investigation_id_resource_logical_ids.keys()) + + def _get_privilege_license_type_param_resource_id(self, api_stack_template, api_stack): + """ + Helper method to get the privilege {licenseType} parameter resource ID by traversing the resource hierarchy. + """ + provider_resource = api_stack.api.v1_api.provider_management.provider_resource.node.default_child + privileges_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': api_stack.get_logical_id(provider_resource)}, + 'PathPart': 'privileges', + } + }, + ) + self.assertEqual(len(privileges_logical_id), 1) + privileges_logical_id = next(key for key in privileges_logical_id.keys()) + + jurisdiction_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': privileges_logical_id}, + 'PathPart': 'jurisdiction', + } + }, + ) + self.assertEqual(len(jurisdiction_logical_id), 1) + jurisdiction_logical_id = next(key for key in jurisdiction_logical_id.keys()) + + jurisdiction_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_logical_id}, + 'PathPart': '{jurisdiction}', + } + }, + ) + self.assertEqual(len(jurisdiction_param_logical_id), 1) + jurisdiction_param_logical_id = next(key for key in jurisdiction_param_logical_id.keys()) + + license_type_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_param_logical_id}, + 'PathPart': 'licenseType', + } + }, + ) + self.assertEqual(len(license_type_logical_id), 1) + license_type_logical_id = next(key for key in license_type_logical_id.keys()) + + license_type_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_logical_id}, + 'PathPart': '{licenseType}', + } + }, + ) + self.assertEqual(len(license_type_param_logical_id), 1) + return next(key for key in license_type_param_logical_id.keys()) + + def _get_license_license_type_param_resource_id(self, api_stack_template, api_stack): + """Helper method to get the license {licenseType} parameter resource ID by traversing the resource hierarchy.""" + provider_resource = api_stack.api.v1_api.provider_management.provider_resource.node.default_child + licenses_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': api_stack.get_logical_id(provider_resource)}, + 'PathPart': 'licenses', + } + }, + ) + self.assertEqual(len(licenses_logical_id), 1) + licenses_logical_id = next(key for key in licenses_logical_id.keys()) + + jurisdiction_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': licenses_logical_id}, + 'PathPart': 'jurisdiction', + } + }, + ) + self.assertEqual(len(jurisdiction_logical_id), 1) + jurisdiction_logical_id = next(key for key in jurisdiction_logical_id.keys()) + + jurisdiction_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_logical_id}, + 'PathPart': '{jurisdiction}', + } + }, + ) + self.assertEqual(len(jurisdiction_param_logical_id), 1) + jurisdiction_param_logical_id = next(key for key in jurisdiction_param_logical_id.keys()) + + license_type_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_param_logical_id}, + 'PathPart': 'licenseType', + } + }, + ) + self.assertEqual(len(license_type_logical_id), 1) + license_type_logical_id = next(key for key in license_type_logical_id.keys()) + + license_type_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_logical_id}, + 'PathPart': '{licenseType}', + } + }, + ) + self.assertEqual(len(license_type_param_logical_id), 1) + return next(key for key in license_type_param_logical_id.keys()) + + def test_synth_generates_privilege_investigation_resource(self): + """Test that the privilege investigation resource is created correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': {'Ref': self._get_privilege_license_type_param_resource_id(api_stack_template, api_stack)}, + 'PathPart': 'investigation', + }, + ) + + def test_synth_generates_privilege_investigation_id_resource(self): + """Test that the privilege investigation {investigationId} resource is created correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': {'Ref': self._get_privilege_investigation_resource_id(api_stack_template, api_stack)}, + 'PathPart': '{investigationId}', + }, + ) + + def test_synth_generates_license_investigation_resource(self): + """Test that the license investigation resource is created correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': {'Ref': self._get_license_license_type_param_resource_id(api_stack_template, api_stack)}, + 'PathPart': 'investigation', + }, + ) + + def test_synth_generates_license_investigation_id_resource(self): + """Test that the license investigation {investigationId} resource is created correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': {'Ref': self._get_license_investigation_resource_id(api_stack_template, api_stack)}, + 'PathPart': '{investigationId}', + }, + ) + + def test_synth_generates_privilege_investigation_handler(self): + """Test that the privilege investigation handler lambda is created correctly.""" + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the lambda is created with expected code path + investigation_handler = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id( + api_lambda_stack.provider_management_lambdas.provider_investigation_handler.node.default_child + ), + api_lambda_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual(investigation_handler['Handler'], 'handlers.investigation.investigation_handler') + + def test_synth_generates_post_privilege_investigation_endpoint(self): + """Test that the POST privilege investigation endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the POST method is configured correctly (no request model required) + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + 'AuthorizationType': 'COGNITO_USER_POOLS', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'ResourceId': {'Ref': self._get_privilege_investigation_resource_id(api_stack_template, api_stack)}, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_investigation_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + def test_synth_generates_patch_privilege_investigation_endpoint(self): + """Test that the PATCH privilege investigation endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + request_model_logical_id_capture = Capture() + + # Ensure the PATCH method is configured correctly + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'PATCH', + 'AuthorizationType': 'COGNITO_USER_POOLS', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'ResourceId': {'Ref': self._get_privilege_investigation_id_resource_id(api_stack_template, api_stack)}, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_investigation_handler, + ), + 'RequestModels': {'application/json': {'Ref': request_model_logical_id_capture}}, + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify the request model matches expected schema + patch_privilege_investigation_request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + patch_privilege_investigation_request_model['Schema'], + 'PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_post_license_investigation_endpoint(self): + """Test that the POST license investigation endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the POST method is configured correctly (no request model required) + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + 'AuthorizationType': 'COGNITO_USER_POOLS', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'ResourceId': {'Ref': self._get_license_investigation_resource_id(api_stack_template, api_stack)}, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_investigation_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + def test_synth_generates_patch_license_investigation_endpoint(self): + """Test that the PATCH license investigation endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + request_model_logical_id_capture = Capture() + + # Ensure the PATCH method is configured correctly + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'PATCH', + 'AuthorizationType': 'COGNITO_USER_POOLS', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'ResourceId': {'Ref': self._get_license_investigation_id_resource_id(api_stack_template, api_stack)}, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_investigation_handler, + ), + 'RequestModels': {'application/json': {'Ref': request_model_logical_id_capture}}, + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify the request model matches expected schema + patch_license_investigation_request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + patch_license_investigation_request_model['Schema'], + 'PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) diff --git a/backend/social-work-app/tests/app/test_api/test_provider_management_api.py b/backend/social-work-app/tests/app/test_api/test_provider_management_api.py new file mode 100644 index 0000000000..0caf3c6feb --- /dev/null +++ b/backend/social-work-app/tests/app/test_api/test_provider_management_api.py @@ -0,0 +1,586 @@ +from aws_cdk.assertions import Capture, Template +from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.test_api import TestApi + + +class TestProviderManagementApi(TestApi): + """ + These tests are focused on checking that the API endpoints under /v1/compacts/{compact}/providers/ + are configured correctly. + + When adding or modifying API resources under /providers/, a test should be added to ensure that the + resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. If the resource has a lambda function associated with it, the function is present with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def _get_privilege_encumbrance_resource_id(self, api_stack_template, api_stack): + """Helper method to get the privilege encumbrance resource ID by traversing the resource hierarchy.""" + license_type_param_logical_id = self._get_privilege_license_type_param_resource_id( + api_stack_template, api_stack + ) + + encumbrance_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_param_logical_id}, + 'PathPart': 'encumbrance', + } + }, + ) + self.assertEqual(len(encumbrance_resource_logical_ids), 1) + return next(key for key in encumbrance_resource_logical_ids.keys()) + + def _get_privilege_license_type_param_resource_id(self, api_stack_template, api_stack): + """Helper method to get the privilege {licenseType} + parameter resource ID by traversing the resource hierarchy.""" + provider_resource = api_stack.api.v1_api.provider_management.provider_resource.node.default_child + privileges_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': api_stack.get_logical_id(provider_resource)}, + 'PathPart': 'privileges', + } + }, + ) + self.assertEqual(len(privileges_logical_id), 1) + privileges_logical_id = next(key for key in privileges_logical_id.keys()) + + jurisdiction_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': privileges_logical_id}, + 'PathPart': 'jurisdiction', + } + }, + ) + self.assertEqual(len(jurisdiction_logical_id), 1) + jurisdiction_logical_id = next(key for key in jurisdiction_logical_id.keys()) + + jurisdiction_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_logical_id}, + 'PathPart': '{jurisdiction}', + } + }, + ) + self.assertEqual(len(jurisdiction_param_logical_id), 1) + jurisdiction_param_logical_id = next(key for key in jurisdiction_param_logical_id.keys()) + + license_type_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_param_logical_id}, + 'PathPart': 'licenseType', + } + }, + ) + self.assertEqual(len(license_type_logical_id), 1) + license_type_logical_id = next(key for key in license_type_logical_id.keys()) + + license_type_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_logical_id}, + 'PathPart': '{licenseType}', + } + }, + ) + self.assertEqual(len(license_type_param_logical_id), 1) + return next(key for key in license_type_param_logical_id.keys()) + + def _get_license_encumbrance_resource_id(self, api_stack_template, api_stack): + """Helper method to get the license encumbrance resource ID by traversing the resource hierarchy.""" + provider_resource = api_stack.api.v1_api.provider_management.provider_resource.node.default_child + licenses_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': api_stack.get_logical_id(provider_resource)}, + 'PathPart': 'licenses', + } + }, + ) + self.assertEqual(len(licenses_logical_id), 1) + licenses_logical_id = next(key for key in licenses_logical_id.keys()) + + jurisdiction_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': licenses_logical_id}, + 'PathPart': 'jurisdiction', + } + }, + ) + self.assertEqual(len(jurisdiction_logical_id), 1) + jurisdiction_logical_id = next(key for key in jurisdiction_logical_id.keys()) + + jurisdiction_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_logical_id}, + 'PathPart': '{jurisdiction}', + } + }, + ) + self.assertEqual(len(jurisdiction_param_logical_id), 1) + jurisdiction_param_logical_id = next(key for key in jurisdiction_param_logical_id.keys()) + + license_type_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': jurisdiction_param_logical_id}, + 'PathPart': 'licenseType', + } + }, + ) + self.assertEqual(len(license_type_logical_id), 1) + license_type_logical_id = next(key for key in license_type_logical_id.keys()) + + license_type_param_logical_id = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_logical_id}, + 'PathPart': '{licenseType}', + } + }, + ) + self.assertEqual(len(license_type_param_logical_id), 1) + license_type_param_logical_id = next(key for key in license_type_param_logical_id.keys()) + + encumbrance_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': license_type_param_logical_id}, + 'PathPart': 'encumbrance', + } + }, + ) + self.assertEqual(len(encumbrance_resource_logical_ids), 1) + return next(key for key in encumbrance_resource_logical_ids.keys()) + + def test_synth_generates_providers_resource(self): + """Test that the /providers resource is created correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected '{compact}' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.compact_resource.node.default_child), + }, + 'PathPart': 'providers', + }, + ) + + def test_synth_generates_get_provider_endpoint(self): + """Test that the GET /providers/{providerId} endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.provider_management.resource.node.default_child + ), + }, + 'PathPart': '{providerId}', + }, + ) + + # Ensure the lambda is created with expected code path + api_lambda_stack_template.has_resource_properties( + type=CfnFunction.CFN_RESOURCE_TYPE_NAME, + props={'Handler': 'handlers.providers.get_provider'}, + ) + + # Capture model logical ID for verification + response_model_logical_id_capture = Capture() + + # Ensure the GET method is configured correctly + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.get_provider_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify response model schema + response_model = TestApi.get_resource_properties_by_logical_id( + response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + response_model['Schema'], + 'GET_PROVIDER_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_query_providers_endpoint(self): + """Test that the POST /providers/query endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'provider' resource + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.provider_management.resource.node.default_child + ), + }, + 'PathPart': 'query', + }, + ) + + # Ensure the lambda is created with expected code path + api_lambda_stack_template.has_resource_properties( + type=CfnFunction.CFN_RESOURCE_TYPE_NAME, + props={'Handler': 'handlers.providers.query_providers'}, + ) + + # Capture model logical IDs for verification + request_model_logical_id_capture = Capture() + response_model_logical_id_capture = Capture() + + # Ensure the POST method is configured correctly + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.query_providers_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify request model schema + request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + request_model['Schema'], + 'QUERY_PROVIDERS_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + # Verify response model schema + response_model = TestApi.get_resource_properties_by_logical_id( + response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + response_model['Schema'], + 'QUERY_PROVIDERS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_privilege_encumbrance_endpoint(self): + """Test that the POST /providers/{providerId}/privileges/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/encumbrance endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the lambda is created with expected code path + api_lambda_stack_template.has_resource_properties( + type=CfnFunction.CFN_RESOURCE_TYPE_NAME, + props={'Handler': 'handlers.encumbrance.encumbrance_handler'}, + ) + + # Verify the privilege encumbrance resource path is created correctly + encumbrance_resource_logical_id = self._get_privilege_encumbrance_resource_id(api_stack_template, api_stack) + + # Ensure the POST method is configured correctly + request_model_logical_id_capture = Capture() + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'ResourceId': {'Ref': encumbrance_resource_logical_id}, + 'HttpMethod': 'POST', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_encumbrance_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify request model schema + request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + request_model['Schema'], + 'PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_license_encumbrance_endpoint(self): + """Test that the POST /providers/{providerId}/licenses/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/encumbrance endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Verify the license encumbrance resource path is created correctly + encumbrance_resource_logical_id = self._get_license_encumbrance_resource_id(api_stack_template, api_stack) + + # Ensure the POST method is configured correctly + request_model_logical_id_capture = Capture() + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'ResourceId': {'Ref': encumbrance_resource_logical_id}, + 'HttpMethod': 'POST', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_encumbrance_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify request model schema + request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + request_model['Schema'], + 'LICENSE_ENCUMBRANCE_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_privilege_encumbrance_lifting_endpoint(self): + """Test that the PATCH /providers/{providerId}/privileges/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/encumbrance/{encumbranceId} endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Get the encumbrance resource logical ID + encumbrance_resource_logical_id = self._get_privilege_encumbrance_resource_id(api_stack_template, api_stack) + + # Find the {encumbranceId} sub-resource of the encumbrance resource + encumbrance_id_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': encumbrance_resource_logical_id}, + 'PathPart': '{encumbranceId}', + } + }, + ) + self.assertEqual(len(encumbrance_id_resource_logical_ids), 1) + encumbrance_id_resource_logical_id = next(key for key in encumbrance_id_resource_logical_ids.keys()) + + # Ensure the PATCH method is configured correctly on the {encumbranceId} resource + request_model_logical_id_capture = Capture() + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'ResourceId': {'Ref': encumbrance_id_resource_logical_id}, + 'HttpMethod': 'PATCH', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_encumbrance_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify request model schema + request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + request_model['Schema'], + 'PRIVILEGE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_license_encumbrance_lifting_endpoint(self): + """Test that the PATCH /providers/{providerId}/licenses/jurisdiction/{jurisdiction} + /licenseType/{licenseType}/encumbrance/{encumbranceId} endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Find the license encumbrance resource + encumbrance_resource_logical_id = self._get_license_encumbrance_resource_id(api_stack_template, api_stack) + + # Find the {encumbranceId} sub-resource of the encumbrance resource + encumbrance_id_resource_logical_ids = api_stack_template.find_resources( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'ParentId': {'Ref': encumbrance_resource_logical_id}, + 'PathPart': '{encumbranceId}', + } + }, + ) + self.assertEqual(len(encumbrance_id_resource_logical_ids), 1) + encumbrance_id_resource_logical_id = next(key for key in encumbrance_id_resource_logical_ids.keys()) + + # Ensure the PATCH method is configured correctly on the {encumbranceId} resource + request_model_logical_id_capture = Capture() + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'ResourceId': {'Ref': encumbrance_id_resource_logical_id}, + 'HttpMethod': 'PATCH', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.provider_management_lambdas.provider_encumbrance_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': { + 'application/json': { + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.api_model.message_response_model.node.default_child + ) + } + }, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify request model schema + request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + request_model['Schema'], + 'LICENSE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) diff --git a/backend/social-work-app/tests/app/test_api/test_public_lookup_api.py b/backend/social-work-app/tests/app/test_api/test_public_lookup_api.py new file mode 100644 index 0000000000..d19a176164 --- /dev/null +++ b/backend/social-work-app/tests/app/test_api/test_public_lookup_api.py @@ -0,0 +1,159 @@ +from aws_cdk.assertions import Capture, Template +from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.test_api import TestApi + + +class TestPublicLookupApi(TestApi): + """ + These tests are focused on checking that the API endpoints under /v1/public/compacts/{compact}/providers/ + are configured correctly. + + When adding or modifying API resources under this path, a test should be added to ensure that the + resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. If the resource has a lambda function associated with it, the function is present with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def test_synth_generates_public_get_provider_endpoint(self): + """Test that the GET /v1/public/compacts/{compact}/providers/{providerId} endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.public_lookup_api.resource.node.default_child), + }, + 'PathPart': '{providerId}', + }, + ) + + # Ensure the lambda is created with expected code path in the ApiLambdaStack + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + get_handler = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id( + api_lambda_stack.public_lookup_lambdas.get_provider_handler.node.default_child + ), + api_lambda_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual(get_handler['Handler'], 'handlers.public_lookup.public_get_provider') + + # Capture model logical ID for verification + response_model_logical_id_capture = Capture() + + # Ensure the GET method is configured correctly + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.public_lookup_lambdas.get_provider_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify response model schema + response_model = TestApi.get_resource_properties_by_logical_id( + response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + response_model['Schema'], + 'PUBLIC_GET_PROVIDER_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_public_query_providers_endpoint(self): + """Test that the POST /providers/query endpoint is configured correctly.""" + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + search_persistent_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_persistent_stack_template = Template.from_stack(search_persistent_stack) + + # Ensure the resource is created with expected path + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'provider' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.public_lookup_api.resource.node.default_child), + }, + 'PathPart': 'query', + }, + ) + + # Ensure the lambda is created with expected code path in the ApiLambdaStack + query_handler = TestApi.get_resource_properties_by_logical_id( + search_persistent_stack.get_logical_id( + search_persistent_stack.search_handler.public_handler.node.default_child + ), + search_persistent_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual(query_handler['Handler'], 'handlers.public_search.public_search_api_handler') + + # Capture model logical IDs for verification + request_model_logical_id_capture = Capture() + response_model_logical_id_capture = Capture() + + # Ensure the POST method is configured correctly + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + search_persistent_stack, + search_persistent_stack_template, + search_persistent_stack.search_handler.public_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify request model schema + request_model = TestApi.get_resource_properties_by_logical_id( + request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + request_model['Schema'], + 'PUBLIC_QUERY_PROVIDERS_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + # Verify response model schema + response_model = TestApi.get_resource_properties_by_logical_id( + response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + response_model['Schema'], + 'PUBLIC_QUERY_PROVIDERS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) diff --git a/backend/social-work-app/tests/app/test_api/test_staff_users_api.py b/backend/social-work-app/tests/app/test_api/test_staff_users_api.py new file mode 100644 index 0000000000..f0511a311d --- /dev/null +++ b/backend/social-work-app/tests/app/test_api/test_staff_users_api.py @@ -0,0 +1,315 @@ +from aws_cdk.assertions import Capture, Match, Template +from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.test_api import TestApi + + +class TestStaffUsersApi(TestApi): + """These tests are focused on checking that the API endpoints for the `staff-users` path are + configured correctly. + + When adding or modifying API resources, a test should be added to ensure that the + resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. If the resource has a lambda function associated with it, the function is present with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def test_synth_generates_staff_users_resources(self): + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path for self-service endpoints + # /v1/staff-users + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'v1' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.resource.node.default_child), + }, + 'PathPart': 'staff-users', + }, + ) + + # Ensure the resource is created with expected path for self-service endpoints + # /v1/compacts/{compact}/staff-users + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'v1' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.compact_resource.node.default_child), + }, + 'PathPart': 'staff-users', + }, + ) + + def test_synth_generates_patch_staff_users_endpoint_resource(self): + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + # /v1/compacts/{compact}/staff-users/{userId} + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'staff-users' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.staff_users_admin_resource.node.default_child), + }, + 'PathPart': '{userId}', + }, + ) + + # Ensure the lambda is created with expected code path in the ApiLambdaStack + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + patch_user_handler = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id(api_lambda_stack.staff_users_lambdas.patch_user_handler.node.default_child), + api_lambda_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual(patch_user_handler['Handler'], 'handlers.users.patch_user') + patch_method_request_model_logical_id_capture = Capture() + patch_method_response_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'PATCH', + # the provider users endpoints uses a separate authorizer from the staff endpoints + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.staff_users_lambdas.patch_user_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': patch_method_request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': patch_method_response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + { + 'ResponseModels': {'application/json': {'Ref': Match.any_value()}}, + 'StatusCode': '404', + }, + ], + }, + ) + + # now check the model matches expected contract + patch_request_model = TestApi.get_resource_properties_by_logical_id( + patch_method_request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + patch_request_model['Schema'], + 'PATCH_STAFF_USERS_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + patch_response_model = TestApi.get_resource_properties_by_logical_id( + patch_method_response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + patch_response_model['Schema'], + 'PATCH_STAFF_USERS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_post_staff_user_endpoint_resource(self): + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the lambda is created with expected code path in the ApiLambdaStack + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + post_user_handler = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id(api_lambda_stack.staff_users_lambdas.post_user_handler.node.default_child), + api_lambda_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual(post_user_handler['Handler'], 'handlers.users.post_user') + post_method_request_model_logical_id_capture = Capture() + post_method_response_model_logical_id_capture = Capture() + + # ensure the GET method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + # the provider users endpoints uses a separate authorizer from the staff endpoints + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.staff_users_lambdas.post_user_handler, + ), + 'RequestModels': { + 'application/json': {'Ref': post_method_request_model_logical_id_capture}, + }, + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': post_method_response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # now check the model matches expected contract + post_request_model = TestApi.get_resource_properties_by_logical_id( + post_method_request_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + post_request_model['Schema'], + 'POST_STAFF_USERS_REQUEST_SCHEMA', + overwrite_snapshot=False, + ) + + post_response_model = TestApi.get_resource_properties_by_logical_id( + post_method_response_model_logical_id_capture.as_string(), + api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + post_response_model['Schema'], + 'POST_STAFF_USERS_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_post_staff_user_alarms(self): + """Test that the POST staff users endpoint alarms are configured correctly.""" + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + # Ensure the anomaly detection alarm is created + alarms = api_lambda_stack_template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) + anomaly_alarm = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id( + api_lambda_stack.staff_users_lambdas.staff_user_creation_anomaly_detection_alarm + ), + alarms, + ) + + # The alarm actions ref change depending on sandbox vs pipeline configuration, so we'll just + # make sure there is one action and remove it from the comparison + actions = anomaly_alarm.pop('AlarmActions', []) + self.assertEqual(len(actions), 1) + + self.compare_snapshot( + anomaly_alarm, + 'POST_STAFF_USER_ANOMALY_DETECTION_ALARM_SCHEMA', + overwrite_snapshot=False, + ) + + # Ensure the max hourly alarm is created + max_staff_user_creation_hourly_alarm = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id( + api_lambda_stack.staff_users_lambdas.max_hourly_staff_users_created_alarm.node.default_child + ), + alarms, + ) + + actions = max_staff_user_creation_hourly_alarm.pop('AlarmActions', []) + self.assertEqual(len(actions), 1) + + self.compare_snapshot( + max_staff_user_creation_hourly_alarm, + 'POST_STAFF_USER_MAX_HOURLY_ALARM_SCHEMA', + overwrite_snapshot=False, + ) + + # Ensure the max daily alarm is created + max_staff_user_creation_daily_alarm = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id( + api_lambda_stack.staff_users_lambdas.max_daily_staff_users_created_alarm.node.default_child + ), + alarms, + ) + + actions = max_staff_user_creation_daily_alarm.pop('AlarmActions', []) + self.assertEqual(len(actions), 1) + + self.compare_snapshot( + max_staff_user_creation_daily_alarm, + 'POST_STAFF_USER_MAX_DAILY_ALARM_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_reinvite_user_endpoint_resource(self): + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # Ensure the resource is created with expected path + # /v1/compacts/{compact}/staff-users/{userId}/reinvite + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'userId' resource + 'Ref': api_stack.get_logical_id( + api_stack.api.v1_api.staff_users.user_id_resource.node.default_child + ), + }, + 'PathPart': 'reinvite', + }, + ) + + # Ensure the lambda is created with expected code path in the ApiLambdaStack + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + reinvite_user_handler = TestApi.get_resource_properties_by_logical_id( + api_lambda_stack.get_logical_id( + api_lambda_stack.staff_users_lambdas.reinvite_user_handler.node.default_child + ), + api_lambda_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual(reinvite_user_handler['Handler'], 'handlers.users.reinvite_user') + + # ensure the POST method is configured with the lambda integration and authorizer + api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.staff_users_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object_for_imported_lambda( + api_lambda_stack, + api_lambda_stack_template, + api_lambda_stack.staff_users_lambdas.reinvite_user_handler, + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': Match.any_value()}}, + 'StatusCode': '200', + }, + { + 'ResponseModels': {'application/json': {'Ref': Match.any_value()}}, + 'StatusCode': '404', + }, + ], + }, + ) diff --git a/backend/social-work-app/tests/app/test_api/test_state_api.py b/backend/social-work-app/tests/app/test_api/test_state_api.py new file mode 100644 index 0000000000..09a3fdd4c4 --- /dev/null +++ b/backend/social-work-app/tests/app/test_api/test_state_api.py @@ -0,0 +1,410 @@ +from aws_cdk.assertions import Capture, Match, Template +from aws_cdk.aws_apigateway import CfnAuthorizer, CfnMethod, CfnModel, CfnResource +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.test_api import TestApi + + +class TestStateApi(TestApi): + """ + These tests are focused on checking that the API endpoints under the State API are configured correctly. + + When adding or modifying API resources under the State API, a test should be added to ensure that the + resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. If the resource has a lambda function associated with it, the function is present with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def test_synth_generates_state_api_stack(self): + """Test that the State API stack is created correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the API is created + state_api_stack_template.has_resource_properties( + type='AWS::ApiGateway::RestApi', + props={ + 'Name': 'StateApi', + }, + ) + + def test_state_authorizer_uses_state_user_pool(self): + """Test that the state authorizer uses the state user pool.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the state authorizer uses the state user pool + state_api_stack_template.has_resource_properties( + type=CfnAuthorizer.CFN_RESOURCE_TYPE_NAME, + # An import from the state auth stack + props={'ProviderARNs': [{'Fn::ImportValue': Match.string_like_regexp('Sandbox-StateAuthStack:.*')}]}, + ) + + def test_synth_generates_v1_resource(self): + """Test that the /v1 resource is created correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the v1 resource is created + state_api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Fn::GetAtt': [ + state_api_stack.get_logical_id(state_api_stack.api.node.default_child), + 'RootResourceId', + ] + }, + 'PathPart': 'v1', + }, + ) + + def test_synth_generates_compacts_resource(self): + """Test that the /v1/compacts resource is created correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the compacts resource is created with expected path + state_api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': state_api_stack.get_logical_id(state_api_stack.api.v1_api.resource.node.default_child), + }, + 'PathPart': 'compacts', + }, + ) + + def test_synth_generates_compact_param_resource(self): + """Test that the /v1/compacts/{compact} resource is created correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the {compact} parameter resource is created with expected path + state_api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': state_api_stack.get_logical_id( + state_api_stack.api.v1_api.compacts_resource.node.default_child + ), + }, + 'PathPart': '{compact}', + }, + ) + + def test_synth_generates_jurisdictions_resource(self): + """Test that the /v1/compacts/{compact}/jurisdictions resource is created correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the jurisdictions resource is created with expected path + state_api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': state_api_stack.get_logical_id( + state_api_stack.api.v1_api.compact_resource.node.default_child + ), + }, + 'PathPart': 'jurisdictions', + }, + ) + + def test_synth_generates_jurisdiction_param_resource(self): + """Test that the /v1/compacts/{compact}/jurisdictions/{jurisdiction} resource is created correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the {jurisdiction} parameter resource is created with expected path + state_api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': state_api_stack.get_logical_id( + state_api_stack.api.v1_api.compact_jurisdictions_resource.node.default_child + ), + }, + 'PathPart': '{jurisdiction}', + }, + ) + + def test_synth_generates_licenses_resource(self): + """Test that the /v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses resource is created correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the licenses resource is created with expected path + state_api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': state_api_stack.get_logical_id( + state_api_stack.api.v1_api.compact_jurisdiction_resource.node.default_child + ), + }, + 'PathPart': 'licenses', + }, + ) + + def test_synth_generates_post_licenses_endpoint(self): + """Test that the POST /licenses endpoint is configured correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the lambda is created with expected code path + post_handler = TestApi.get_resource_properties_by_logical_id( + state_api_stack.get_logical_id( + state_api_stack.api.v1_api.post_licenses.post_license_handler.node.default_child + ), + state_api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual(post_handler['Handler'], 'handlers.licenses.post_licenses') + + # Capture model logical IDs for verification + success_response_model_logical_id_capture = Capture() + failure_response_model_logical_id_capture = Capture() + + # Ensure the POST method is configured correctly + state_api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'POST', + 'AuthorizerId': { + 'Ref': state_api_stack.get_logical_id(state_api_stack.api.state_auth_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object( + state_api_stack.get_logical_id( + state_api_stack.api.v1_api.post_licenses.post_license_handler.node.default_child, + ), + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': success_response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + { + 'ResponseModels': {'application/json': {'Ref': failure_response_model_logical_id_capture}}, + 'StatusCode': '400', + }, + ], + }, + ) + + # Verify response model schema + success_response_model = TestApi.get_resource_properties_by_logical_id( + success_response_model_logical_id_capture.as_string(), + state_api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + failure_response_model = TestApi.get_resource_properties_by_logical_id( + failure_response_model_logical_id_capture.as_string(), + state_api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + + self.compare_snapshot( + success_response_model['Schema'], + 'STANDARD_MESSAGE_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + self.compare_snapshot( + failure_response_model['Schema'], + 'STATE_API_POST_LICENSES_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_synth_generates_bulk_upload_url_endpoint(self): + """Test that the GET /licenses/bulk-upload endpoint is configured correctly.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Ensure the bulk-upload resource is created with expected path + state_api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + 'Ref': state_api_stack.get_logical_id( + state_api_stack.api.v1_api.post_licenses.resource.node.default_child + ), + }, + 'PathPart': 'bulk-upload', + }, + ) + + # Find the bulk upload handler by looking for the lambda function with the correct handler + lambda_functions = state_api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME) + bulk_upload_handler = None + for logical_id, function_props in lambda_functions.items(): + if function_props['Properties']['Handler'] == 'handlers.state_api.bulk_upload_url_handler': + bulk_upload_handler = logical_id + break + + self.assertIsNotNone(bulk_upload_handler, 'Bulk upload handler not found') + + # Capture model logical ID for verification + response_model_logical_id_capture = Capture() + + # Ensure the GET method is configured correctly + state_api_stack_template.has_resource_properties( + type=CfnMethod.CFN_RESOURCE_TYPE_NAME, + props={ + 'HttpMethod': 'GET', + 'AuthorizerId': { + 'Ref': state_api_stack.get_logical_id(state_api_stack.api.state_auth_authorizer.node.default_child), + }, + 'Integration': TestApi.generate_expected_integration_object(bulk_upload_handler), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': response_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # Verify response model schema + response_model = TestApi.get_resource_properties_by_logical_id( + response_model_logical_id_capture.as_string(), + state_api_stack_template.find_resources(CfnModel.CFN_RESOURCE_TYPE_NAME), + ) + self.compare_snapshot( + response_model['Schema'], + 'STATE_API_BULK_UPLOAD_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) + + def test_state_api_authorization_scopes(self): + """Test that the State API endpoints have the correct authorization scopes.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Get all methods in the state API + methods = state_api_stack_template.find_resources(CfnMethod.CFN_RESOURCE_TYPE_NAME) + + # Check that all methods have authorization configured (excluding OPTIONS methods for CORS) + for logical_id, method_props in methods.items(): + # Skip OPTIONS methods which are CORS preflight requests + if method_props['Properties']['HttpMethod'] == 'OPTIONS': + continue + + with self.subTest(method=logical_id): + # All methods should have authorization enabled + self.assertEqual( + method_props['Properties']['AuthorizationType'], + 'COGNITO_USER_POOLS', + f'Method {logical_id} should have Cognito authorization', + ) + + # All methods should have an authorizer + self.assertIn( + 'AuthorizerId', method_props['Properties'], f'Method {logical_id} should have an authorizer' + ) + + # All methods should have authorization scopes + self.assertIn( + 'AuthorizationScopes', + method_props['Properties'], + f'Method {logical_id} should have authorization scopes', + ) + + def test_state_api_uses_state_auth_authorizer(self): + """Test that the State API uses the state auth authorizer instead of staff users authorizer.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Get the state auth authorizer ID + state_auth_authorizer_id = state_api_stack.get_logical_id( + state_api_stack.api.state_auth_authorizer.node.default_child + ) + + # Get all methods in the state API + methods = state_api_stack_template.find_resources(CfnMethod.CFN_RESOURCE_TYPE_NAME) + + # Check that all methods use the state auth authorizer (excluding OPTIONS methods for CORS) + for logical_id, method_props in methods.items(): + # Skip OPTIONS methods which are CORS preflight requests + if method_props['Properties']['HttpMethod'] == 'OPTIONS': + continue + + self.assertEqual( + method_props['Properties']['AuthorizerId']['Ref'], + state_auth_authorizer_id, + f'Method {logical_id} should use the state auth authorizer', + ) + + def test_state_api_lambda_environment_variables(self): + """Test that the State API lambda functions have the correct environment variables.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Check post licenses handler + post_licenses_handler = TestApi.get_resource_properties_by_logical_id( + state_api_stack.get_logical_id( + state_api_stack.api.v1_api.post_licenses.post_license_handler.node.default_child + ), + state_api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + env_vars = post_licenses_handler['Environment']['Variables'] + self.assertIn( + 'LICENSE_PREPROCESSING_QUEUE_URL', + env_vars, + 'LICENSE_PREPROCESSING_QUEUE_URL should be present in post licenses handler', + ) + + def test_state_api_lambda_timeouts(self): + """Test that the State API lambda functions have appropriate timeouts.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Check that all lambda functions have appropriate timeouts + lambda_functions = state_api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME) + + for logical_id, function_props in lambda_functions.items(): + with self.subTest(function=logical_id): + # All functions should have a timeout configured + self.assertIn('Timeout', function_props['Properties'], f'Function {logical_id} should have a timeout') + + # Timeout should be reasonable (not too short, not too long) + timeout = function_props['Properties']['Timeout'] + self.assertGreaterEqual(timeout, 3, f'Function {logical_id} timeout should be at least 3 seconds') + self.assertLessEqual( + timeout, 900, f'Function {logical_id} timeout should be at most 900 seconds (15 minutes)' + ) + + def test_state_api_request_parameters(self): + """Test that the State API endpoints have the correct request parameters.""" + state_api_stack = self.app.sandbox_backend_stage.state_api_stack + state_api_stack_template = Template.from_stack(state_api_stack) + + # Get all methods in the state API + methods = state_api_stack_template.find_resources(CfnMethod.CFN_RESOURCE_TYPE_NAME) + + # Check that all methods require the Authorization header (excluding OPTIONS methods for CORS) + for logical_id, method_props in methods.items(): + # Skip OPTIONS methods which are CORS preflight requests + if method_props['Properties']['HttpMethod'] == 'OPTIONS': + continue + + with self.subTest(method=logical_id): + self.assertIn( + 'RequestParameters', + method_props['Properties'], + f'Method {logical_id} should have request parameters', + ) + + request_params = method_props['Properties']['RequestParameters'] + self.assertIn( + 'method.request.header.Authorization', + request_params, + f'Method {logical_id} should require Authorization header', + ) + + self.assertTrue( + request_params['method.request.header.Authorization'], + f'Method {logical_id} Authorization header should be required', + ) diff --git a/backend/social-work-app/tests/app/test_backup_infrastructure_stack.py b/backend/social-work-app/tests/app/test_backup_infrastructure_stack.py new file mode 100644 index 0000000000..be05c892e3 --- /dev/null +++ b/backend/social-work-app/tests/app/test_backup_infrastructure_stack.py @@ -0,0 +1,358 @@ +import json +from unittest import TestCase + +from aws_cdk import ArnFormat +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_backup import CfnBackupVault +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_iam import CfnRole +from aws_cdk.aws_kms import CfnAlias, CfnKey + +from tests.app.base import TstAppABC + + +class TestBackupInfrastructureStack(TstAppABC, TestCase): + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.test-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + return context + + def setUp(self): + """Set up test fixtures.""" + # Use the test backend stage for testing backup infrastructure + self.backup_stack = self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack + self.template = Template.from_stack(self.backup_stack) + + def test_stack_creates_expected_resources(self): + """Test that the stack creates all expected backup infrastructure resources.""" + # Should create 2 KMS keys (general and SSN) + self.template.resource_count_is(CfnKey.CFN_RESOURCE_TYPE_NAME, 2) + + # Should create 2 KMS aliases + self.template.resource_count_is(CfnAlias.CFN_RESOURCE_TYPE_NAME, 2) + + # Should create 2 backup vaults (general and SSN) + self.template.resource_count_is(CfnBackupVault.CFN_RESOURCE_TYPE_NAME, 2) + + # Should create 2 IAM roles (general backup service role and SSN backup service role) + self.template.resource_count_is(CfnRole.CFN_RESOURCE_TYPE_NAME, 2) + + # Should create monitoring resources (alarms and EventBridge rules) + self.template.resource_count_is(CfnAlarm.CFN_RESOURCE_TYPE_NAME, 6) # 6 CloudWatch alarms + self.template.resource_count_is(CfnRule.CFN_RESOURCE_TYPE_NAME, 6) # 6 EventBridge rules + + def test_general_backup_vault_configuration(self): + """Test the general backup vault is configured correctly.""" + environment_name = self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack.environment_name + self.template.has_resource_properties( + CfnBackupVault.CFN_RESOURCE_TYPE_NAME, + { + 'BackupVaultName': f'CompactConnect-{environment_name}-BackupVault', + 'EncryptionKeyArn': Match.any_value(), + 'LockConfiguration': { + 'MinRetentionDays': 90, + }, + }, + ) + + def test_ssn_backup_vault_configuration(self): + """Test the SSN backup vault is configured correctly.""" + environment_name = self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack.environment_name + self.template.has_resource_properties( + CfnBackupVault.CFN_RESOURCE_TYPE_NAME, + { + 'BackupVaultName': f'CompactConnect-{environment_name}-SSNBackupVault', + 'EncryptionKeyArn': Match.any_value(), + 'LockConfiguration': { + 'MinRetentionDays': 90, + }, + }, + ) + + def test_kms_keys_have_correct_aliases(self): + """Test that KMS keys have the correct aliases.""" + environment_name = self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack.environment_name + + # General backup key alias + self.template.has_resource_properties( + CfnAlias.CFN_RESOURCE_TYPE_NAME, + {'AliasName': f'alias/compactconnect-{environment_name}-backup-key', 'TargetKeyId': Match.any_value()}, + ) + + # SSN backup key alias + self.template.has_resource_properties( + CfnAlias.CFN_RESOURCE_TYPE_NAME, + {'AliasName': f'alias/compactconnect-{environment_name}-ssn-backup-key', 'TargetKeyId': Match.any_value()}, + ) + + def test_backup_service_roles_configuration(self): + """Test that backup service roles are configured correctly.""" + environment_name = self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack.environment_name + + # General backup service role + self.template.has_resource_properties( + CfnRole.CFN_RESOURCE_TYPE_NAME, + { + 'RoleName': f'CompactConnect-{environment_name}-BackupServiceRole', + 'AssumeRolePolicyDocument': { + 'Statement': [ + { + 'Effect': 'Allow', + 'Principal': {'Service': 'backup.amazonaws.com'}, + 'Action': 'sts:AssumeRole', + } + ] + }, + 'ManagedPolicyArns': Match.array_with([Match.object_like({'Fn::Join': Match.any_value()})]), + }, + ) + + # SSN backup service role with enhanced security controls + self.template.has_resource_properties( + CfnRole.CFN_RESOURCE_TYPE_NAME, + { + 'RoleName': f'CompactConnect-{environment_name}-SSNBackupRole', + 'AssumeRolePolicyDocument': { + 'Statement': [ + { + 'Effect': 'Allow', + 'Principal': {'Service': 'backup.amazonaws.com'}, + 'Action': 'sts:AssumeRole', + } + ] + }, + 'ManagedPolicyArns': Match.array_with( + [ + Match.object_like({'Fn::Join': Match.any_value()}), + Match.object_like({'Fn::Join': Match.any_value()}), + ] + ), + }, + ) + + def test_ssn_backup_role_has_cross_account_restrictions(self): + """Test that the SSN backup role restricts cross-account copy operations.""" + environment_name = self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack.environment_name + + # Verify the SSN backup role exists and has the inline policy with cross-account restrictions + self.template.has_resource_properties( + CfnRole.CFN_RESOURCE_TYPE_NAME, + { + 'RoleName': f'CompactConnect-{environment_name}-SSNBackupRole', + 'Policies': Match.array_with( + [ + { + 'PolicyName': 'SSNBackupSecurityPolicy', + 'PolicyDocument': { + 'Version': '2012-10-17', + 'Statement': Match.array_with( + [ + { + 'Sid': 'RestrictCrossAccountOperations', + 'Effect': 'Deny', + 'Action': ['backup:CopyIntoBackupVault', 'backup:StartCopyJob'], + 'Resource': '*', + 'Condition': { + 'ForAnyValue:ArnNotEquals': {'backup:CopyTargets': Match.any_value()} + }, + } + ] + ), + }, + } + ] + ), + }, + ) + + def test_cross_account_vault_references(self): + """Test that cross-account vault references are correctly created.""" + # Test that the vault objects are created and have the expected ARNs + backup_config = self.backup_stack.backup_config + expected_general_arn = self.backup_stack.format_arn( + arn_format=ArnFormat.COLON_RESOURCE_NAME, + service='backup', + region=backup_config['backup_region'], + account=backup_config['backup_account_id'], + resource='backup-vault', + resource_name=backup_config['general_vault_name'], + ) + expected_ssn_arn = self.backup_stack.format_arn( + arn_format=ArnFormat.COLON_RESOURCE_NAME, + service='backup', + region=backup_config['backup_region'], + account=backup_config['backup_account_id'], + resource='backup-vault', + resource_name=backup_config['ssn_vault_name'], + ) + + # Test that the vault objects exist and have the correct ARNs + self.assertIsNotNone(self.backup_stack.cross_account_backup_vault) + self.assertIsNotNone(self.backup_stack.cross_account_ssn_backup_vault) + self.assertEqual(expected_general_arn, self.backup_stack.cross_account_backup_vault.backup_vault_arn) + self.assertEqual(expected_ssn_arn, self.backup_stack.cross_account_ssn_backup_vault.backup_vault_arn) + + def test_removal_policy_set_for_test_environment(self): + """Test that all resources have RemovalPolicy.DESTROY in test environment for development cleanup.""" + # Since we're testing with test context (non-prod), resources should have DESTROY policy + environment_name = self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack.environment_name + self.assertNotEqual(environment_name, 'prod', 'Test should be using non-prod environment') + + # KMS keys should have DeletionPolicy: Delete (DESTROY) + kms_keys = self.template.find_resources(CfnKey.CFN_RESOURCE_TYPE_NAME) + for key_id, key_props in kms_keys.items(): + self.assertEqual( + key_props.get('DeletionPolicy'), + 'Delete', + f'KMS key {key_id} should have Delete deletion policy in {environment_name} environment', + ) + + # Backup vaults should have DeletionPolicy: Delete (DESTROY) + backup_vaults = self.template.find_resources(CfnBackupVault.CFN_RESOURCE_TYPE_NAME) + for vault_id, vault_props in backup_vaults.items(): + self.assertEqual( + vault_props.get('DeletionPolicy'), + 'Delete', + f'Backup vault {vault_id} should have Delete deletion policy in {environment_name} environment', + ) + + def test_backend_stage_integration(self): + """Test that the backup infrastructure stack integrates correctly with the backend stage.""" + # The backup infrastructure stack should be present in the test backend stage + self.assertIsNotNone(self.app.test_backend_pipeline_stack.test_stage.backup_infrastructure_stack) + + # Validate that the stack is properly configured as a nested stack + # NestedStacks have token-based names so we check that it's not None instead of exact match + self.assertIsNotNone(self.backup_stack.stack_name) + + # Validate that all expected backup infrastructure resources are created + self._check_no_backend_stage_annotations(self.app.test_backend_pipeline_stack.test_stage) + + def test_backup_monitoring_configuration(self): + """Test that backup monitoring alarms and rules are correctly configured.""" + + # Test general backup vault failure alarm (uses CloudFormation reference for vault name) + self.template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'MetricName': 'NumberOfBackupJobsFailed', + 'Namespace': 'AWS/Backup', + 'Dimensions': [{'Name': 'BackupVaultName', 'Value': Match.any_value()}], + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + }, + ) + + # Test SSN backup vault failure alarm (critical) (uses CloudFormation reference for vault name) + self.template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'MetricName': 'NumberOfBackupJobsFailed', + 'Namespace': 'AWS/Backup', + 'Dimensions': [{'Name': 'BackupVaultName', 'Value': Match.any_value()}], + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + 'AlarmDescription': Match.string_like_regexp('.*CRITICAL.*'), + }, + ) + + # Test copy job failure alarm + self.template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'MetricName': 'NumberOfCopyJobsFailed', + 'Namespace': 'AWS/Backup', + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + }, + ) + + # Test backup job failure EventBridge rule + self.template.has_resource_properties( + CfnRule.CFN_RESOURCE_TYPE_NAME, + { + 'EventPattern': { + 'source': ['aws.backup'], + 'detail-type': ['Backup Job State Change'], + 'detail': {'state': ['FAILED', 'ABORTED']}, + }, + 'Targets': Match.any_value(), + }, + ) + + # Test copy job failure EventBridge rule + self.template.has_resource_properties( + CfnRule.CFN_RESOURCE_TYPE_NAME, + { + 'EventPattern': { + 'source': ['aws.backup'], + 'detail-type': ['Copy Job State Change'], + 'detail': {'state': ['FAILED']}, + }, + 'Targets': Match.any_value(), + }, + ) + + +class TestBackupInfrastructureStackProduction(TstAppABC, TestCase): + """Test backup infrastructure stack behavior in production environment.""" + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.prod-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + return context + + def setUp(self): + """Set up test fixtures.""" + # Use the production backend stage for testing backup infrastructure + self.backup_stack = self.app.prod_backend_pipeline_stack.prod_stage.backup_infrastructure_stack + self.template = Template.from_stack(self.backup_stack) + + def test_removal_policy_set_for_production_environment(self): + """Test that all resources have RemovalPolicy.RETAIN in production environment for data protection.""" + from aws_cdk import RemovalPolicy + + # Verify that the removal policy is set to RETAIN for production + self.assertEqual( + self.backup_stack.removal_policy, + RemovalPolicy.RETAIN, + 'Production environment should have RETAIN removal policy', + ) + + # Verify environment name is 'prod' + self.assertEqual(self.backup_stack.environment_name, 'prod', 'Should be testing production environment') + + # KMS keys should have DeletionPolicy: Retain + kms_keys = self.template.find_resources(CfnKey.CFN_RESOURCE_TYPE_NAME) + self.assertGreater(len(kms_keys), 0, 'Should have KMS keys in the template') + for key_id, key_props in kms_keys.items(): + self.assertEqual( + key_props.get('DeletionPolicy'), + 'Retain', + f'KMS key {key_id} should have Retain deletion policy in prod environment', + ) + + # Backup vaults should have DeletionPolicy: Retain + backup_vaults = self.template.find_resources(CfnBackupVault.CFN_RESOURCE_TYPE_NAME) + self.assertGreater(len(backup_vaults), 0, 'Should have backup vaults in the template') + for vault_id, vault_props in backup_vaults.items(): + self.assertEqual( + vault_props.get('DeletionPolicy'), + 'Retain', + f'Backup vault {vault_id} should have Retain deletion policy in prod environment', + ) diff --git a/backend/social-work-app/tests/app/test_cognito_backup.py b/backend/social-work-app/tests/app/test_cognito_backup.py new file mode 100644 index 0000000000..67a1e5d23f --- /dev/null +++ b/backend/social-work-app/tests/app/test_cognito_backup.py @@ -0,0 +1,88 @@ +""" +Integration tests for Cognito backup functionality in the CDK app. + +This module tests the CDK constructs and integration for the Cognito backup system +including the backup bucket, Lambda function, EventBridge scheduling, and backup plans. +""" + +import json +from unittest import TestCase + +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_lambda import CfnFunction + +from common_constructs.cognito_user_backup import CognitoUserBackup +from tests.app.base import TstAppABC + + +class TestCognitoBackup(TstAppABC, TestCase): + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.test-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + return context + + def test_cognito_backup_created(self): + """Test that the Cognito backup bucket is created with proper configuration.""" + persistent_stack = self.app.test_backend_pipeline_stack.test_stage.persistent_stack + + self.assertIsInstance(persistent_stack.staff_users.backup_system, CognitoUserBackup) + + def test_cognito_backup_lambda_created(self): + """Test that the Cognito backup Lambda function is created with proper configuration.""" + persistent_stack = self.app.test_backend_pipeline_stack.test_stage.persistent_stack + stack_template = Template.from_stack(persistent_stack) + + # Verify that we have a Cognito backup Lambda function + lambda_function = stack_template.find_resources( + CfnFunction.CFN_RESOURCE_TYPE_NAME, + props=Match.object_like( + { + 'Properties': { + 'Handler': 'handlers.cognito_backup.backup_handler', + 'Description': 'Export user pool data for backup purposes', + } + } + ), + ) + self.assertEqual(len(lambda_function), 1, 'Should have one Cognito backup Lambda function') + lambda_function_logical_id = list(lambda_function.keys())[0] + + # Verify that the lambda has an event bridge rule + stack_template.has_resource_properties( + CfnRule.CFN_RESOURCE_TYPE_NAME, + props={ + 'ScheduleExpression': Match.string_like_regexp('cron.*'), + 'State': 'ENABLED', + 'Targets': [ + Match.object_like({'Arn': Match.object_like({'Fn::GetAtt': [lambda_function_logical_id, 'Arn']})}) + ], + }, + ) + + # Find CloudWatch alarms + alarm_topic_logical_id = persistent_stack.get_logical_id(persistent_stack.alarm_topic.node.default_child) + stack_template.has_resource_properties( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + props=Match.object_like( + { + 'AlarmDescription': ( + 'User pool backup export Lambda has failed. User data backup may be incomplete.' + ), + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + 'Threshold': 1, + 'EvaluationPeriods': 1, + 'AlarmActions': Match.array_with([Match.object_like({'Ref': alarm_topic_logical_id})]), + 'Namespace': 'AWS/Lambda', + 'MetricName': 'Errors', + } + ), + ) diff --git a/backend/social-work-app/tests/app/test_compact_api.py b/backend/social-work-app/tests/app/test_compact_api.py new file mode 100644 index 0000000000..4e40e11197 --- /dev/null +++ b/backend/social-work-app/tests/app/test_compact_api.py @@ -0,0 +1,45 @@ +from aws_cdk.assertions import Template +from aws_cdk.aws_apigateway import CfnResource + +from tests.app.test_api import TestApi + + +class TestCompactsApi(TestApi): + """ + These tests are focused on checking that the API endpoints for the `/compacts/ root path are configured correctly. + + When adding or modifying API resources under /compacts/, a test should be added to ensure that the + resource is created as expected. The pattern for these tests includes the following checks: + 1. The path and parent id of the API Gateway resource matches expected values. + 2. If the resource has a lambda function associated with it, the function is present with the expected + module and function. + 3. Check the methods associated with the resource, ensuring they are all present and have the correct handlers. + 4. Ensure the request and response models for the endpoint are present and match the expected schemas. + """ + + def test_synth_generates_compacts_resources(self): + api_stack = self.app.sandbox_backend_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + # /v1/compacts + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'v1' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.resource.node.default_child), + }, + 'PathPart': 'compacts', + }, + ) + # /v1/compacts/{compact} + api_stack_template.has_resource_properties( + type=CfnResource.CFN_RESOURCE_TYPE_NAME, + props={ + 'ParentId': { + # Verify the parent id matches the expected 'v1' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.compacts_resource.node.default_child), + }, + 'PathPart': '{compact}', + }, + ) diff --git a/backend/social-work-app/tests/app/test_notification_stack.py b/backend/social-work-app/tests/app/test_notification_stack.py new file mode 100644 index 0000000000..305262baa6 --- /dev/null +++ b/backend/social-work-app/tests/app/test_notification_stack.py @@ -0,0 +1,906 @@ +import json +from unittest import TestCase + +from aws_cdk.assertions import Template +from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_lambda import CfnEventSourceMapping, CfnFunction +from aws_cdk.aws_sqs import CfnQueue + +from tests.app.base import TstAppABC + + +class TestNotificationStack(TstAppABC, TestCase): + """ + Test cases for the NotificationStack to ensure proper resource configuration + for handling notification events that require SES integration. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + def test_license_encumbrance_notification_listener_resources_created(self): + """ + Test that the license encumbrance notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'license.encumbrance' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + license_encumbrance_notification_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceNotificationListener' + ].queue_processor.process_function.node.default_child + ) + license_encumbrance_notification_handler = TestNotificationStack.get_resource_properties_by_logical_id( + license_encumbrance_notification_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.encumbrance_events.license_encumbrance_notification_listener', + license_encumbrance_notification_handler['Handler'], + ) + + # Verify SQS queue is created for the license encumbrance notification listener + notification_listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceNotificationListener' + ].queue_processor.queue.node.default_child + ) + license_encumbrance_notification_listener_queue = TestNotificationStack.get_resource_properties_by_logical_id( + notification_listener_queue_logical_id, + resources=notification_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + + dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + # remove dynamic field + del license_encumbrance_notification_listener_queue['KmsMasterKeyId'] + + self.assertEqual( + { + 'MessageRetentionPeriod': 43200, + 'RedrivePolicy': {'deadLetterTargetArn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}, 'maxReceiveCount': 3}, + 'VisibilityTimeout': 300, + }, + license_encumbrance_notification_listener_queue, + ) + + # Verify EventBridge rule is created with correct detail type + license_encumbrance_notification_listener_event_bridge_rule = ( + TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['license.encumbrance']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [notification_listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + license_encumbrance_notification_listener_event_bridge_rule, + ) + + # Verify event source mapping between SQS queue and Lambda function + event_source_mapping = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceNotificationListener' + ].queue_processor.event_source_mapping.node.default_child + ), + resources=notification_template.find_resources(CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual( + { + 'BatchSize': 10, + 'EventSourceArn': {'Fn::GetAtt': [notification_listener_queue_logical_id, 'Arn']}, + 'FunctionName': {'Ref': license_encumbrance_notification_handler_logical_id}, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + 'MaximumBatchingWindowInSeconds': 15, + }, + event_source_mapping, + ) + + def test_license_encumbrance_lifting_notification_listener_resources_created(self): + """ + Test that the license encumbrance lifting notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'license.encumbranceLifted' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + license_encumbrance_lifting_notification_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceLiftingNotificationListener' + ].queue_processor.process_function.node.default_child + ) + license_encumbrance_lifting_notification_handler = TestNotificationStack.get_resource_properties_by_logical_id( + license_encumbrance_lifting_notification_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.encumbrance_events.license_encumbrance_lifting_notification_listener', + license_encumbrance_lifting_notification_handler['Handler'], + ) + + # Verify SQS queue is created for the license encumbrance lifting notification listener + lifting_notification_listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceLiftingNotificationListener' + ].queue_processor.queue.node.default_child + ) + license_encumbrance_lifting_notification_listener_queue = ( + TestNotificationStack.get_resource_properties_by_logical_id( + lifting_notification_listener_queue_logical_id, + resources=notification_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + ) + + dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceLiftingNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + # remove dynamic field + del license_encumbrance_lifting_notification_listener_queue['KmsMasterKeyId'] + + self.assertEqual( + { + 'MessageRetentionPeriod': 43200, + 'RedrivePolicy': {'deadLetterTargetArn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}, 'maxReceiveCount': 3}, + 'VisibilityTimeout': 300, + }, + license_encumbrance_lifting_notification_listener_queue, + ) + + # Verify EventBridge rule is created with correct detail type + license_encumbrance_lifting_notification_listener_event_bridge_rule = ( + TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceLiftingNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['license.encumbranceLifted']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [lifting_notification_listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + license_encumbrance_lifting_notification_listener_event_bridge_rule, + ) + + # Verify event source mapping between SQS queue and Lambda function + event_source_mapping = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseEncumbranceLiftingNotificationListener' + ].queue_processor.event_source_mapping.node.default_child + ), + resources=notification_template.find_resources(CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual( + { + 'BatchSize': 10, + 'EventSourceArn': {'Fn::GetAtt': [lifting_notification_listener_queue_logical_id, 'Arn']}, + 'FunctionName': {'Ref': license_encumbrance_lifting_notification_handler_logical_id}, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + 'MaximumBatchingWindowInSeconds': 15, + }, + event_source_mapping, + ) + + def test_privilege_encumbrance_notification_listener_resources_created(self): + """ + Test that the privilege encumbrance notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'privilege.encumbrance' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + privilege_encumbrance_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceNotificationListener' + ].queue_processor.process_function.node.default_child + ) + privilege_encumbrance_handler = TestNotificationStack.get_resource_properties_by_logical_id( + privilege_encumbrance_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.encumbrance_events.privilege_encumbrance_notification_listener', + privilege_encumbrance_handler['Handler'], + ) + + # Verify SQS queue is created for the privilege encumbrance notification listener + listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceNotificationListener' + ].queue_processor.queue.node.default_child + ) + privilege_encumbrance_listener_queue = TestNotificationStack.get_resource_properties_by_logical_id( + listener_queue_logical_id, resources=notification_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME) + ) + + dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + # remove dynamic field + del privilege_encumbrance_listener_queue['KmsMasterKeyId'] + + self.assertEqual( + { + 'MessageRetentionPeriod': 43200, + 'RedrivePolicy': {'deadLetterTargetArn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}, 'maxReceiveCount': 3}, + 'VisibilityTimeout': 300, + }, + privilege_encumbrance_listener_queue, + ) + + # Verify EventBridge rule is created with correct detail type + privilege_encumbrance_listener_event_bridge_rule = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['privilege.encumbrance']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + privilege_encumbrance_listener_event_bridge_rule, + ) + + # Verify event source mapping between SQS queue and Lambda function + event_source_mapping = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceNotificationListener' + ].queue_processor.event_source_mapping.node.default_child + ), + resources=notification_template.find_resources(CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual( + { + 'BatchSize': 10, + 'EventSourceArn': {'Fn::GetAtt': [listener_queue_logical_id, 'Arn']}, + 'FunctionName': {'Ref': privilege_encumbrance_handler_logical_id}, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + 'MaximumBatchingWindowInSeconds': 15, + }, + event_source_mapping, + ) + + def test_privilege_encumbrance_lifting_notification_listener_resources_created(self): + """ + Test that the privilege encumbrance lifting notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'privilege.encumbranceLifted' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + privilege_encumbrance_lifting_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceLiftingNotificationListener' + ].queue_processor.process_function.node.default_child + ) + privilege_encumbrance_lifting_handler = TestNotificationStack.get_resource_properties_by_logical_id( + privilege_encumbrance_lifting_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.encumbrance_events.privilege_encumbrance_lifting_notification_listener', + privilege_encumbrance_lifting_handler['Handler'], + ) + + # Verify SQS queue is created for the privilege encumbrance lifting notification listener + lifting_listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceLiftingNotificationListener' + ].queue_processor.queue.node.default_child + ) + privilege_encumbrance_lifting_listener_queue = TestNotificationStack.get_resource_properties_by_logical_id( + lifting_listener_queue_logical_id, + resources=notification_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + + dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceLiftingNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + # remove dynamic field + del privilege_encumbrance_lifting_listener_queue['KmsMasterKeyId'] + + self.assertEqual( + { + 'MessageRetentionPeriod': 43200, + 'RedrivePolicy': {'deadLetterTargetArn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}, 'maxReceiveCount': 3}, + 'VisibilityTimeout': 300, + }, + privilege_encumbrance_lifting_listener_queue, + ) + + # Verify EventBridge rule is created with correct detail type + privilege_encumbrance_lifting_listener_event_bridge_rule = ( + TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceLiftingNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['privilege.encumbranceLifted']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [lifting_listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + privilege_encumbrance_lifting_listener_event_bridge_rule, + ) + + # Verify event source mapping between SQS queue and Lambda function + event_source_mapping = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeEncumbranceLiftingNotificationListener' + ].queue_processor.event_source_mapping.node.default_child + ), + resources=notification_template.find_resources(CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual( + { + 'BatchSize': 10, + 'EventSourceArn': {'Fn::GetAtt': [lifting_listener_queue_logical_id, 'Arn']}, + 'FunctionName': {'Ref': privilege_encumbrance_lifting_handler_logical_id}, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + 'MaximumBatchingWindowInSeconds': 15, + }, + event_source_mapping, + ) + + def test_license_investigation_notification_resources_created(self): + """ + Test that the license investigation notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'license.investigation' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + license_investigation_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationNotificationListener' + ].queue_processor.process_function.node.default_child + ) + license_investigation_handler = TestNotificationStack.get_resource_properties_by_logical_id( + license_investigation_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.investigation_events.license_investigation_notification_listener', + license_investigation_handler['Handler'], + ) + + # Verify SQS queue is created for the license investigation notification listener + investigation_listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationNotificationListener' + ].queue_processor.queue.node.default_child + ) + license_investigation_listener_queue = TestNotificationStack.get_resource_properties_by_logical_id( + investigation_listener_queue_logical_id, + resources=notification_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + + dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + # remove dynamic field + del license_investigation_listener_queue['KmsMasterKeyId'] + + self.assertEqual( + { + 'MessageRetentionPeriod': 43200, + 'RedrivePolicy': {'deadLetterTargetArn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}, 'maxReceiveCount': 3}, + 'VisibilityTimeout': 300, + }, + license_investigation_listener_queue, + ) + + # Verify EventBridge rule is created with correct detail type + license_investigation_rule = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['license.investigation']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [investigation_listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + license_investigation_rule, + ) + + # Verify event source mapping is created + event_source_mapping = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationNotificationListener' + ].queue_processor.event_source_mapping.node.default_child + ), + resources=notification_template.find_resources(CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual( + { + 'BatchSize': 10, + 'EventSourceArn': {'Fn::GetAtt': [investigation_listener_queue_logical_id, 'Arn']}, + 'FunctionName': {'Ref': license_investigation_handler_logical_id}, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + 'MaximumBatchingWindowInSeconds': 15, + }, + event_source_mapping, + ) + + def test_license_investigation_closed_notification_resources_created(self): + """ + Test that the license investigation closed notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'license.investigationClosed' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + license_investigation_closed_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationClosedNotificationListener' + ].queue_processor.process_function.node.default_child + ) + license_investigation_closed_handler = TestNotificationStack.get_resource_properties_by_logical_id( + license_investigation_closed_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.investigation_events.license_investigation_closed_notification_listener', + license_investigation_closed_handler['Handler'], + ) + + # Verify EventBridge rule is created with correct detail type + license_investigation_closed_rule = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationClosedNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + + # Get the queue and DLQ logical IDs for the targets + investigation_closed_listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationClosedNotificationListener' + ].queue_processor.queue.node.default_child + ) + investigation_closed_dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'LicenseInvestigationClosedNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['license.investigationClosed']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [investigation_closed_listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [investigation_closed_dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + license_investigation_closed_rule, + ) + + def test_privilege_investigation_notification_resources_created(self): + """ + Test that the privilege investigation notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'privilege.investigation' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + privilege_investigation_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationNotificationListener' + ].queue_processor.process_function.node.default_child + ) + privilege_investigation_handler = TestNotificationStack.get_resource_properties_by_logical_id( + privilege_investigation_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.investigation_events.privilege_investigation_notification_listener', + privilege_investigation_handler['Handler'], + ) + + # Verify EventBridge rule is created with correct detail type + privilege_investigation_rule = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + + # Get the queue and DLQ logical IDs for the targets + privilege_investigation_listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationNotificationListener' + ].queue_processor.queue.node.default_child + ) + privilege_investigation_dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['privilege.investigation']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [privilege_investigation_listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [privilege_investigation_dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + privilege_investigation_rule, + ) + + def test_privilege_investigation_closed_notification_resources_created(self): + """ + Test that the privilege investigation closed notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'privilege.investigationClosed' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + privilege_investigation_closed_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationClosedNotificationListener' + ].queue_processor.process_function.node.default_child + ) + privilege_investigation_closed_handler = TestNotificationStack.get_resource_properties_by_logical_id( + privilege_investigation_closed_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.investigation_events.privilege_investigation_closed_notification_listener', + privilege_investigation_closed_handler['Handler'], + ) + + # Verify EventBridge rule is created with correct detail type + privilege_investigation_closed_rule = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationClosedNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + + # Get the queue and DLQ logical IDs for the targets + privilege_investigation_closed_listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationClosedNotificationListener' + ].queue_processor.queue.node.default_child + ) + privilege_investigation_closed_dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'PrivilegeInvestigationClosedNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['privilege.investigationClosed']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [privilege_investigation_closed_listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': { + 'Arn': {'Fn::GetAtt': [privilege_investigation_closed_dlq_logical_id, 'Arn']} + }, + 'Id': 'Target0', + } + ], + }, + privilege_investigation_closed_rule, + ) + + def test_provider_home_jurisdiction_change_notification_listener_resources_created(self): + """ + Test that the provider home jurisdiction change notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'provider.homeStateChange' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + provider_home_jurisdiction_change_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.process_function.node.default_child + ) + provider_home_jurisdiction_change_handler = TestNotificationStack.get_resource_properties_by_logical_id( + provider_home_jurisdiction_change_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.home_state_change_events.home_state_change_notification_listener', + provider_home_jurisdiction_change_handler['Handler'], + ) + + # Verify SQS queue is created + listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.queue.node.default_child + ) + listener_queue = TestNotificationStack.get_resource_properties_by_logical_id( + listener_queue_logical_id, + resources=notification_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + + dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + # remove dynamic field + del listener_queue['KmsMasterKeyId'] + + self.assertEqual( + { + 'MessageRetentionPeriod': 43200, + 'RedrivePolicy': {'deadLetterTargetArn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}, 'maxReceiveCount': 3}, + 'VisibilityTimeout': 300, + }, + listener_queue, + ) + + # Verify EventBridge rule is created with correct detail type + event_bridge_rule = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['provider.homeStateChange']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + event_bridge_rule, + ) + + # Verify event source mapping is created + event_source_mapping = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.event_source_mapping.node.default_child + ), + resources=notification_template.find_resources(CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual( + { + 'BatchSize': 10, + 'EventSourceArn': {'Fn::GetAtt': [listener_queue_logical_id, 'Arn']}, + 'FunctionName': {'Ref': provider_home_jurisdiction_change_handler_logical_id}, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + 'MaximumBatchingWindowInSeconds': 15, + }, + event_source_mapping, + ) diff --git a/backend/social-work-app/tests/app/test_pipeline.py b/backend/social-work-app/tests/app/test_pipeline.py new file mode 100644 index 0000000000..d98d2ed965 --- /dev/null +++ b/backend/social-work-app/tests/app/test_pipeline.py @@ -0,0 +1,303 @@ +import json +import os +from unittest import TestCase +from unittest.mock import patch + +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_cognito import ( + CfnUserPool, + CfnUserPoolClient, + CfnUserPoolResourceServer, + CfnUserPoolRiskConfigurationAttachment, +) +from aws_cdk.aws_lambda import CfnLayerVersion +from aws_cdk.aws_ssm import CfnParameter + +from app import CompactConnectApp +from tests.app.base import TstAppABC + + +class TestBackendPipeline(TstAppABC, TestCase): + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + # For pipeline deployments, we do not have a cdk.context.json file to extend context: + # ssm_context is actually pulled from SSM Parameter Store + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + return context + + def test_synth_pipeline(self): + """ + Test infrastructure as deployed via the pipeline + """ + # Identify any findings from our AwsSolutions rule sets + self._check_no_stack_annotations(self.app.deployment_resources_stack) + self._check_no_stack_annotations(self.app.test_backend_pipeline_stack) + self._check_no_stack_annotations(self.app.prod_backend_pipeline_stack) + for stage in ( + self.app.test_backend_pipeline_stack.test_stage, + self.app.beta_backend_pipeline_stack.beta_backend_stage, + self.app.prod_backend_pipeline_stack.prod_stage, + ): + self._check_no_backend_stage_annotations(stage) + # Check resource counts and emit warnings/errors if thresholds are exceeded + self._check_backend_stage_resource_counts(stage) + + for api_stack in ( + self.app.test_backend_pipeline_stack.test_stage.api_stack, + self.app.beta_backend_pipeline_stack.beta_backend_stage.api_stack, + self.app.prod_backend_pipeline_stack.prod_stage.api_stack, + ): + with self.subTest(api_stack.stack_name): + self._inspect_api_stack(api_stack) + + self._inspect_persistent_stack( + self.app.test_backend_pipeline_stack.test_stage.persistent_stack, + ui_domain_name='app.test.compactconnect.org', + allow_local_ui=True, + ) + self._inspect_persistent_stack( + self.app.beta_backend_pipeline_stack.beta_backend_stage.persistent_stack, + ui_domain_name='app.beta.compactconnect.org', + allow_local_ui=False, + ) + self._inspect_persistent_stack( + self.app.prod_backend_pipeline_stack.prod_stage.persistent_stack, + ui_domain_name='app.compactconnect.org', + ) + + self._inspect_state_auth_stack( + self.app.test_backend_pipeline_stack.test_stage.state_auth_stack, + ) + + self._inspect_state_auth_stack( + self.app.beta_backend_pipeline_stack.beta_backend_stage.state_auth_stack, + ) + + self._inspect_state_auth_stack( + self.app.prod_backend_pipeline_stack.prod_stage.state_auth_stack, + ) + + def _when_testing_compact_resource_servers(self, persistent_stack): + persistent_stack_template = Template.from_stack(persistent_stack) + + # Get the resource servers created in the persistent stack + resource_servers = persistent_stack.staff_users.compact_resource_servers + # We must confirm that these scopes are being explicitly created for each compact marked as active in the + # environment, which are absolutely critical for the system to function as expected. + self.assertEqual( + sorted(persistent_stack.get_list_of_compact_abbreviations()), + sorted(list(resource_servers.keys())), + ) + + for compact, resource_server in resource_servers.items(): + resource_server_properties = self.get_resource_properties_by_logical_id( + persistent_stack.get_logical_id(resource_server.node.default_child), + persistent_stack_template.find_resources(CfnUserPoolResourceServer.CFN_RESOURCE_TYPE_NAME), + ) + # Ensure the compact resource servers are created with the expected scopes + self.assertEqual( + ['admin', 'write', 'readGeneral'], + [scope['ScopeName'] for scope in resource_server_properties['Scopes']], + msg=f'Expected scopes for compact {compact} not found', + ) + + def test_synth_generates_compact_resource_servers_with_expected_scopes_for_staff_users_beta_stage(self): + persistent_stack = self.app.beta_backend_pipeline_stack.beta_backend_stage.persistent_stack + self._when_testing_compact_resource_servers(persistent_stack) + + def test_synth_generates_compact_resource_servers_with_expected_scopes_for_staff_users_prod_stage(self): + persistent_stack = self.app.prod_backend_pipeline_stack.prod_stage.persistent_stack + self._when_testing_compact_resource_servers(persistent_stack) + + def _when_testing_jurisdiction_resource_servers(self, persistent_stack, snapshot_name, overwrite_snapshot): + persistent_stack_template = Template.from_stack(persistent_stack) + + # Get the jurisdiction resource servers created in the persistent stack + resource_servers = persistent_stack.staff_users.jurisdiction_resource_servers + # We must confirm that these scopes are being explicitly created for each active jurisdiction + # which are absolutely critical for the system to function as expected. + # If a new jurisdiction is made active within the system, this test will need to be updated + jurisdiction_resource_server_config = [] + for _jurisdiction, resource_server in resource_servers.items(): + resource_server_properties = self.get_resource_properties_by_logical_id( + persistent_stack.get_logical_id(resource_server.node.default_child), + persistent_stack_template.find_resources(CfnUserPoolResourceServer.CFN_RESOURCE_TYPE_NAME), + ) + # remove dynamic user pool id + del resource_server_properties['UserPoolId'] + jurisdiction_resource_server_config.append(resource_server_properties) + + # sort the resource server list by jurisdiction for consistency + jurisdiction_resource_server_config.sort(key=lambda jurisdiction: jurisdiction['Identifier']) + # sort the scopes within the resource server by name for consistency + for resource_server in jurisdiction_resource_server_config: + resource_server['Scopes'].sort(key=lambda scope: scope['ScopeName']) + # this will only include resource server scopes for compacts/jurisdictions that are marked as active + # for the environment + self.compare_snapshot( + jurisdiction_resource_server_config, + snapshot_name, + overwrite_snapshot=overwrite_snapshot, + ) + + def test_synth_generates_jurisdiction_resource_servers_with_expected_scopes_for_staff_users(self): + """ + Test that the jurisdiction resource servers are created with the expected scopes + for the staff users. This setup is now environment agnostic, so whatever is shown + in this snapshot will be applied to all environments. + """ + persistent_stack = self.app.prod_backend_pipeline_stack.prod_stage.persistent_stack + self._when_testing_jurisdiction_resource_servers( + persistent_stack=persistent_stack, + snapshot_name='JURISDICTION_RESOURCE_SERVER_CONFIGURATION', + overwrite_snapshot=False, + ) + + def test_cognito_using_recommended_security_in_prod(self): + persistent_stack = self.app.prod_backend_pipeline_stack.prod_stage.persistent_stack + persistent_stack_template = Template.from_stack(persistent_stack) + + # Make sure user pool matches the security settings above + user_pools = persistent_stack_template.find_resources( + CfnUserPool.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'UserPoolAddOns': {'AdvancedSecurityMode': 'ENFORCED'}, 'MfaConfiguration': 'ON'}}, + ) + number_of_user_pools = len(user_pools) + + # Check risk configurations + risk_configurations = persistent_stack_template.find_resources( + CfnUserPoolRiskConfigurationAttachment.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'AccountTakeoverRiskConfiguration': { + 'Actions': { + 'HighAction': {'EventAction': 'MFA_REQUIRED', 'Notify': True}, + 'LowAction': {'EventAction': 'MFA_REQUIRED', 'Notify': True}, + 'MediumAction': {'EventAction': 'MFA_REQUIRED', 'Notify': True}, + } + }, + 'CompromisedCredentialsRiskConfiguration': {'Actions': {'EventAction': 'BLOCK'}}, + } + }, + ) + # Every user pool should have this risk configuration + self.assertEqual(number_of_user_pools, len(risk_configurations)) + + # Verify that we're not allowing the implicit grant flow in any of our clients + implicit_grant_clients = persistent_stack_template.find_resources( + CfnUserPoolClient.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'AllowedOAuthFlows': Match.array_with(['implicit'])}}, + ) + self.assertEqual(0, len(implicit_grant_clients)) + + def test_cognito_risk_configuration_includes_notify_configuration_when_domain_configured(self): + """ + Test that when a domain name is configured and security profile is RECOMMENDED, + the user pool risk configurations include the notify_configuration with a non-null from_ email address. + """ + # Test prod stage which has domain configured and RECOMMENDED security + persistent_stack = self.app.prod_backend_pipeline_stack.prod_stage.persistent_stack + persistent_stack_template = Template.from_stack(persistent_stack) + + # Get all risk configurations first + all_risk_configurations = persistent_stack_template.find_resources( + CfnUserPoolRiskConfigurationAttachment.CFN_RESOURCE_TYPE_NAME, + ) + + # Find risk configurations that include notify_configuration + risk_configurations_with_notify = persistent_stack_template.find_resources( + CfnUserPoolRiskConfigurationAttachment.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'AccountTakeoverRiskConfiguration': { + 'Actions': { + 'HighAction': {'EventAction': 'MFA_REQUIRED', 'Notify': True}, + 'LowAction': {'EventAction': 'MFA_REQUIRED', 'Notify': True}, + 'MediumAction': {'EventAction': 'MFA_REQUIRED', 'Notify': True}, + }, + 'NotifyConfiguration': Match.object_like( + { + 'SourceArn': Match.any_value(), + 'BlockEmail': Match.any_value(), + 'NoActionEmail': Match.any_value(), + 'From': Match.any_value(), + } + ), + }, + 'CompromisedCredentialsRiskConfiguration': {'Actions': {'EventAction': 'BLOCK'}}, + } + }, + ) + + # Every risk configuration should include notify_configuration when domain is configured + self.assertEqual(len(all_risk_configurations), len(risk_configurations_with_notify)) + + # Verify that each risk configuration has a non-null from_ email address + for logical_id, resource in risk_configurations_with_notify.items(): + properties = resource['Properties'] + notify_config = properties['AccountTakeoverRiskConfiguration']['NotifyConfiguration'] + self.assertIsNotNone(notify_config['From'], f'Risk configuration {logical_id} missing from_ email address') + self.assertIn( + '@', + notify_config['From'], + f'Risk configuration {logical_id} has invalid from_ email address: {notify_config["From"]}', + ) + self.assertIsNotNone(notify_config['SourceArn'], f'Risk configuration {logical_id} missing source_arn') + self.assertIsNotNone( + notify_config['BlockEmail'], f'Risk configuration {logical_id} missing block_email configuration' + ) + + def test_synth_generates_python_lambda_layer_with_ssm_parameter(self): + persistent_stack = self.app.test_backend_pipeline_stack.test_stage.persistent_stack + persistent_stack_template = Template.from_stack(persistent_stack) + + # Ensure we have a layer and parameter referencing that layer for each expected runtime + for runtime in ['python3.14']: + layers = persistent_stack_template.find_resources( + type=CfnLayerVersion.CFN_RESOURCE_TYPE_NAME, + props={ + 'Properties': { + 'Description': 'A layer for common code shared between python lambdas', + 'CompatibleRuntimes': [runtime], + } + }, + ) + # We expect exactly one for each runtime + self.assertEqual(1, len(layers)) + persistent_stack_template.has_resource_properties( + type=CfnParameter.CFN_RESOURCE_TYPE_NAME, + props={'Value': {'Ref': list(layers.keys())[0]}}, + ) + + +class TestBackendPipelineVulnerable(TestCase): + @patch.dict(os.environ, {'CDK_DEFAULT_ACCOUNT': '000000000000', 'CDK_DEFAULT_REGION': 'us-east-1'}) + def test_app_refuses_to_synth_with_prod_vulnerable(self): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.prod-example.json') as f: + ssm_context = json.load(f)['ssm_context'] + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + # Try to set VULNERABLE testing security profile in prod + ssm_context['environments']['prod']['security_profile'] = 'VULNERABLE' + context['ssm_context'] = ssm_context + # The PipelineStack will read `ssm_context` from Systems Manager (SSM) ParameterStore. + # To simulate the context being retrieved from SSM, we will package the context in the way + # it is persisted to local context after being retrieved from SSM: + pipeline_context = context['ssm_context']['environments']['pipeline'] + context[ + f'ssm:account={pipeline_context["account_id"]}' + ':parameterName=prod-socialwork-context' + f':region={pipeline_context["region"]}' + ] = json.dumps(ssm_context) + + with self.assertRaises(ValueError): + CompactConnectApp(context=context) diff --git a/backend/social-work-app/tests/app/test_sandbox.py b/backend/social-work-app/tests/app/test_sandbox.py new file mode 100644 index 0000000000..93b09f399b --- /dev/null +++ b/backend/social-work-app/tests/app/test_sandbox.py @@ -0,0 +1,125 @@ +import json +from unittest import TestCase + +from app import CompactConnectApp +from tests.app.base import TstAppABC + + +class TstSandbox(TstAppABC, TestCase): + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + return context + + +class TestSandbox(TstSandbox): + def test_synth_security(self): + """ + Test infrastructure as deployed in a developer's sandbox + """ + # Identify any findings from our AwsSolutions rule sets + self._check_no_backend_stage_annotations(self.app.sandbox_backend_stage) + + # Check resource counts and emit warnings/errors if thresholds are exceeded + self._check_backend_stage_resource_counts(self.app.sandbox_backend_stage) + + def test_api_stack(self): + self._inspect_api_stack(self.app.sandbox_backend_stage.api_stack) + + self._inspect_persistent_stack( + self.app.sandbox_backend_stage.persistent_stack, + ui_domain_name='app.justin.compactconnect.org', + allow_local_ui=True, + ) + + self._inspect_state_auth_stack( + self.app.sandbox_backend_stage.state_auth_stack, + ) + + +class TestSandboxNoDomain(TstSandbox): + """ + Test infrastructure as deployed in a developer's sandbox: + In the case where they opt _not_ to set up a hosted zone and domain name for their sandbox, + we will skip setting up domain names and DNS records for the API and UI. + """ + + @classmethod + def get_context(cls): + context = super().get_context() + + # Drop domain name and ui_domain_name_override to ensure we still handle the optional DNS setup + del context['ssm_context']['environments'][context['environment_name']]['domain_name'] + del context['ssm_context']['environments'][context['environment_name']]['ui_domain_name_override'] + return context + + def test_synth_sandbox_no_domain(self): + self._check_no_backend_stage_annotations(self.app.sandbox_backend_stage) + + # Check resource counts and emit warnings/errors if thresholds are exceeded + self._check_backend_stage_resource_counts(self.app.sandbox_backend_stage) + + self._inspect_api_stack(self.app.sandbox_backend_stage.api_stack) + + self._inspect_persistent_stack(self.app.sandbox_backend_stage.persistent_stack, allow_local_ui=True) + self._inspect_state_auth_stack(self.app.sandbox_backend_stage.state_auth_stack) + + +class TestSandboxLocalUiPortOverride(TstSandbox): + """ + Test infrastructure as deployed in a developer's sandbox + """ + + @classmethod + def get_context(cls): + context = super().get_context() + + # Drop domain name and ui_domain_name_override to ensure we still handle the optional DNS setup + del context['ssm_context']['environments'][context['environment_name']]['domain_name'] + del context['ssm_context']['environments'][context['environment_name']]['ui_domain_name_override'] + context['ssm_context']['environments'][context['environment_name']]['local_ui_port'] = '5432' + + return context + + def test_synth_local_ui_port_override(self): + self._check_no_backend_stage_annotations(self.app.sandbox_backend_stage) + + # Check resource counts and emit warnings/errors if thresholds are exceeded + self._check_backend_stage_resource_counts(self.app.sandbox_backend_stage) + + self._inspect_api_stack(self.app.sandbox_backend_stage.api_stack) + + self._inspect_persistent_stack( + self.app.sandbox_backend_stage.persistent_stack, allow_local_ui=True, local_ui_port='5432' + ) + self._inspect_state_auth_stack(self.app.sandbox_backend_stage.state_auth_stack) + + +class TestSandboxNoUi(TestCase): + """ + If a developer tries to deploy this app without either a domain name or allowing a local UI, the app + should fail to synthesize. + """ + + def test_synth_no_ui_raises_value_error(self): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + del context['ssm_context']['environments'][context['environment_name']]['domain_name'] + del context['ssm_context']['environments'][context['environment_name']]['ui_domain_name_override'] + del context['ssm_context']['environments'][context['environment_name']]['allow_local_ui'] + + with self.assertRaises(ValueError): + CompactConnectApp(context=context) diff --git a/backend/social-work-app/tests/app/test_search_persistent_stack.py b/backend/social-work-app/tests/app/test_search_persistent_stack.py new file mode 100644 index 0000000000..1b57da7c23 --- /dev/null +++ b/backend/social-work-app/tests/app/test_search_persistent_stack.py @@ -0,0 +1,445 @@ +import json +from unittest import TestCase + +from aws_cdk.assertions import Match, Template + +from tests.app.base import TstAppABC + + +class TestSearchPersistentStack(TstAppABC, TestCase): + """ + Test cases for the SearchPersistentStack to ensure proper OpenSearch Domain configuration + for advanced provider search functionality. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + def test_opensearch_domain_created(self): + """ + Test that the OpenSearch Domain is created with the correct basic configuration. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify exactly one OpenSearch Domain is created + search_template.resource_count_is('AWS::OpenSearchService::Domain', 1) + + def test_opensearch_version(self): + """ + Test that OpenSearch uses the correct version. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify OpenSearch version + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'EngineVersion': 'OpenSearch_3.3', + }, + ) + + def test_vpc_configuration(self): + """ + Test that the OpenSearch Domain is deployed within the VPC for network isolation. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify VPC configuration is present + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'VPCOptions': { + 'SubnetIds': Match.any_value(), + 'SecurityGroupIds': Match.any_value(), + }, + }, + ) + + def test_node_to_node_encryption(self): + """ + Test that node-to-node encryption is enabled. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify node-to-node encryption is enabled + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'NodeToNodeEncryptionOptions': { + 'Enabled': True, + }, + }, + ) + + def test_https_enforcement(self): + """ + Test that HTTPS is enforced for all traffic to the domain. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify HTTPS is required + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'DomainEndpointOptions': { + 'EnforceHTTPS': True, + 'TLSSecurityPolicy': 'Policy-Min-TLS-1-2-2019-07', + }, + }, + ) + + def test_ebs_encryption(self): + """ + Test that EBS volumes are encrypted. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + encryption_key_logical_id = search_stack.get_logical_id( + search_stack.opensearch_encryption_key.node.default_child + ) + + # Verify EBS volumes are encrypted + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'EBSOptions': { + 'EBSEnabled': True, + 'VolumeSize': 10, + }, + 'EncryptionAtRestOptions': { + 'Enabled': True, + 'KmsKeyId': { + 'Ref': encryption_key_logical_id, + }, + }, + }, + ) + + def test_sandbox_instance_type(self): + """ + Test that sandbox environment uses t3.small.search instance type for cost optimization. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify sandbox uses t3.small.search with single node + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'ClusterConfig': { + 'InstanceType': 't3.small.search', + 'InstanceCount': 1, + 'DedicatedMasterEnabled': False, + 'MultiAZWithStandbyEnabled': False, + }, + }, + ) + + def test_logging_configuration(self): + """ + Test that appropriate logging is enabled for monitoring and troubleshooting. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify logging configuration + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'LogPublishingOptions': { + 'ES_APPLICATION_LOGS': Match.object_like({'Enabled': True}), + }, + }, + ) + + def test_capacity_alarms_configured(self): + """ + Test that capacity monitoring alarms are configured for proactive scaling. + + Verifies six critical alarms: + 1. Free Storage Space < 50% threshold + 2. JVM Memory Pressure > 85% threshold + 3. CPU Utilization > 70% threshold + 4. Cluster Status RED for critical issues + 5. Cluster Status YELLOW for degraded state + 6. Automated Snapshot Failure for backup issues + + These alarms give DevOps team time to plan scaling activities before hitting limits. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify Free Storage Space Alarm + # Note: FreeStorageSpace is reported in megabytes (MB), not bytes + search_template.has_resource_properties( + 'AWS::CloudWatch::Alarm', + { + 'MetricName': 'FreeStorageSpace', + 'Namespace': 'AWS/ES', + 'Threshold': 5120, # 5GB in MB (50% of 10GB = 5GB = 5120MB for sandbox) + 'ComparisonOperator': 'LessThanThreshold', + 'EvaluationPeriods': 1, + }, + ) + + # Verify JVM Memory Pressure Alarm + search_template.has_resource_properties( + 'AWS::CloudWatch::Alarm', + { + 'MetricName': 'JVMMemoryPressure', + 'Namespace': 'AWS/ES', + 'Threshold': 85, + 'ComparisonOperator': 'GreaterThanThreshold', + 'EvaluationPeriods': 3, + }, + ) + + # Verify CPU Utilization Alarm + search_template.has_resource_properties( + 'AWS::CloudWatch::Alarm', + { + 'MetricName': 'CPUUtilization', + 'Namespace': 'AWS/ES', + 'Threshold': 70, + 'ComparisonOperator': 'GreaterThanThreshold', + 'EvaluationPeriods': 3, # 15 minutes sustained + }, + ) + + # Verify Cluster Status RED Alarm + search_template.has_resource_properties( + 'AWS::CloudWatch::Alarm', + { + 'MetricName': 'ClusterStatus.red', + 'Namespace': 'AWS/ES', + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + 'EvaluationPeriods': 1, + }, + ) + + # Verify Cluster Status YELLOW Alarm + search_template.has_resource_properties( + 'AWS::CloudWatch::Alarm', + { + 'MetricName': 'ClusterStatus.yellow', + 'Namespace': 'AWS/ES', + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + 'EvaluationPeriods': 1, + }, + ) + + # Verify Automated Snapshot Failure Alarm + search_template.has_resource_properties( + 'AWS::CloudWatch::Alarm', + { + 'MetricName': 'AutomatedSnapshotFailure', + 'Namespace': 'AWS/ES', + 'Threshold': 1, + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + 'EvaluationPeriods': 1, + }, + ) + + def test_sandbox_uses_expected_private_subnet(self): + """ + Test that the OpenSearch Domain in sandbox uses expected private Subnet. + + For non-prod single-node deployments, OpenSearch must use exactly one subnet. + We explicitly select privateSubnet1 (CIDR 10.0.0.0/20) to ensure deterministic + placement across deployments, since the related lambda functions will also be + deployed within that same subnet, and we want to ensure that can communicate with + one another. + + This test verifies that OpenSearch references the specific subnet we expect, + not just any arbitrary subnet from the VPC. + """ + search_stack = self.app.sandbox_backend_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Get the OpenSearch Domain's subnet configuration + opensearch_resources = search_template.find_resources('AWS::OpenSearchService::Domain') + opensearch_properties = list(opensearch_resources.values())[0]['Properties'] + vpc_options = opensearch_properties['VPCOptions'] + subnet_ids = vpc_options['SubnetIds'] + + # For sandbox (non-prod), should use exactly one subnet + self.assertEqual(len(subnet_ids), 1, 'Sandbox OpenSearch should use exactly one subnet') + + # Get the subnet reference from OpenSearch + opensearch_subnet_ref = subnet_ids[0] + # Extract the export name that OpenSearch is importing + import_value = opensearch_subnet_ref['Fn::ImportValue'] + + # Verify OpenSearch is importing the correct subnet (privateSubnet1) + # The import_value should reference the export name of privateSubnet1 + # The export name contains the construct name, which includes 'privateSubnet1' + self.assertIn( + 'privateSubnet1', + str(import_value), + f'OpenSearch should import privateSubnet1, but is importing: {import_value}. ' + 'This is critical for deterministic subnet placement in non-prod environments.', + ) + + +class TestProdSearchPersistentStack(TstAppABC, TestCase): + """ + Test cases for the prod SearchPersistentStack to ensure proper production OpenSearch Domain configuration + for advanced provider search functionality. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.prod-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + def test_prod_instance_type(self): + """ + Test that production environment uses m7g.medium.search instance type for data nodes + and r8g.medium.search for master nodes with high availability configuration. + """ + search_stack = self.app.prod_backend_pipeline_stack.prod_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify production uses m7g.medium.search with 3 data nodes + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'ClusterConfig': { + 'InstanceType': 'm7g.medium.search', + 'InstanceCount': 3, + 'DedicatedMasterEnabled': True, + 'DedicatedMasterType': 'r8g.medium.search', + 'DedicatedMasterCount': 3, + 'MultiAZWithStandbyEnabled': True, + }, + }, + ) + + def test_prod_ebs_volume_size(self): + """ + Test that production environment uses 25GB EBS volume size. + """ + search_stack = self.app.prod_backend_pipeline_stack.prod_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify production uses 25GB EBS volume + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'EBSOptions': { + 'EBSEnabled': True, + 'VolumeSize': 25, + }, + }, + ) + + def test_prod_zone_awareness(self): + """ + Test that production environment has zone awareness enabled with 3 availability zones. + """ + search_stack = self.app.prod_backend_pipeline_stack.prod_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify zone awareness is enabled with 3 AZs + search_template.has_resource_properties( + 'AWS::OpenSearchService::Domain', + { + 'ClusterConfig': { + 'ZoneAwarenessEnabled': True, + }, + }, + ) + + def test_prod_uses_all_private_subnets(self): + """ + Test that production OpenSearch Domain uses all private isolated subnets (3 AZs) + for high availability and zone awareness. + + Production requires 3 subnets across 3 availability zones to support + multi-AZ with standby configuration. + """ + search_stack = self.app.prod_backend_pipeline_stack.prod_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Get the OpenSearch Domain's subnet configuration + opensearch_resources = search_template.find_resources('AWS::OpenSearchService::Domain') + opensearch_properties = list(opensearch_resources.values())[0]['Properties'] + vpc_options = opensearch_properties['VPCOptions'] + subnet_ids = vpc_options['SubnetIds'] + + # For production, should use 3 subnets (one per AZ) + self.assertEqual( + len(subnet_ids), + 3, + 'Production OpenSearch should use exactly 3 subnets (one per availability zone)', + ) + + def test_prod_index_shard_configuration(self): + """ + Test that production index manager custom resource uses production shard configuration: + - 1 primary shard + - 2 replica shards (for 3 data nodes across 3 AZs) + + This ensures data availability if one node fails, with total shards (1 + 2 = 3) + being a multiple of 3 to distribute evenly across the 3 data nodes. + """ + search_stack = self.app.prod_backend_pipeline_stack.prod_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify index manager custom resource has production shard/replica configuration + search_template.has_resource_properties( + 'Custom::IndexManager', + { + 'numberOfShards': 1, + 'numberOfReplicas': 2, + }, + ) + + # Note that the prod alarm tests specifically check for the + # differences we configure for our production environment as opposed + # to the non-prod environments. If all the sandbox alarms are properly + # configured, they are configured for prod as well, so we don't retest that here. + def test_prod_storage_threshold_alarm(self): + """ + Test that production storage alarm threshold is set to 50% of 25GB volume (12800 MB). + + Production uses 25GB EBS volumes, so 50% threshold = 12.5GB = 12800 MB. + This gives ample time to plan capacity increases before hitting critical levels. + """ + search_stack = self.app.prod_backend_pipeline_stack.prod_stage.search_persistent_stack + search_template = Template.from_stack(search_stack) + + # Verify Free Storage Space Alarm threshold for production (50% of 25GB = 12800 MB) + # Note: FreeStorageSpace metric is reported in megabytes (MB) + search_template.has_resource_properties( + 'AWS::CloudWatch::Alarm', + { + 'MetricName': 'FreeStorageSpace', + 'Namespace': 'AWS/ES', + 'Threshold': 12800, # 50% of 25GB = 12.5GB = 12800 MB + 'ComparisonOperator': 'LessThanThreshold', + 'EvaluationPeriods': 1, + }, + ) diff --git a/backend/social-work-app/tests/app/test_vpc.py b/backend/social-work-app/tests/app/test_vpc.py new file mode 100644 index 0000000000..4c59c0e901 --- /dev/null +++ b/backend/social-work-app/tests/app/test_vpc.py @@ -0,0 +1,222 @@ +import json +from unittest import TestCase + +from aws_cdk.assertions import Match, Template + +from tests.app.base import TstAppABC + + +class TestVpcStack(TstAppABC, TestCase): + """ + Test cases for the VpcStack to ensure proper VPC configuration + for OpenSearch Domain and Lambda functions. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + def test_vpc_configuration(self): + """ + Test that the VPC is created with the correct configuration for OpenSearch and Lambda functions. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Verify exactly one VPC is created + vpc_template.resource_count_is('AWS::EC2::VPC', 1) + + # Verify VPC has the correct configuration + vpc_template.has_resource_properties( + 'AWS::EC2::VPC', + { + 'CidrBlock': '10.0.0.0/16', + 'EnableDnsHostnames': True, + 'EnableDnsSupport': True, + }, + ) + + def test_no_internet_gateway(self): + """ + Test that no Internet Gateway is created, as we're using VPC endpoints for AWS service access. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Verify no Internet Gateway is created + vpc_template.resource_count_is('AWS::EC2::InternetGateway', 0) + + def test_no_nat_gateway(self): + """ + Test that no NAT Gateway is created, as we're using VPC endpoints for AWS service access. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Verify no NAT Gateway is created + vpc_template.resource_count_is('AWS::EC2::NatGateway', 0) + + def test_vpc_flow_logs(self): + """ + Test that VPC Flow Logs are configured to monitor network traffic. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Verify Flow Log is created + vpc_template.resource_count_is('AWS::EC2::FlowLog', 1) + + # Verify Flow Log is configured correctly + vpc_template.has_resource_properties( + 'AWS::EC2::FlowLog', + { + 'ResourceType': 'VPC', + 'TrafficType': 'ALL', + }, + ) + + # Verify CloudWatch Log Group for Flow Logs exists + vpc_template.resource_count_is('AWS::Logs::LogGroup', 1) + + def test_cloudwatch_logs_vpc_endpoint(self): + """ + Test that CloudWatch Logs VPC endpoint is created to allow Lambda functions to send logs. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Verify VPC endpoint for CloudWatch Logs is created + vpc_template.has_resource_properties( + 'AWS::EC2::VPCEndpoint', + { + 'ServiceName': Match.string_like_regexp('.*logs.*'), + 'VpcEndpointType': 'Interface', + }, + ) + + def test_dynamodb_vpc_endpoint(self): + """ + Test that DynamoDB VPC endpoint is created for Lambda functions to access DynamoDB. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Verify VPC gateway endpoint for DynamoDB is created + vpc_template.has_resource_properties( + 'AWS::EC2::VPCEndpoint', + { + 'VpcEndpointType': 'Gateway', + }, + ) + + def test_security_groups_created(self): + """ + Test that security groups are created for OpenSearch and Lambda functions. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Verify security groups are created (2 for our services + default VPC security group) + security_groups = vpc_template.find_resources('AWS::EC2::SecurityGroup') + + # Verify OpenSearch security group exists with correct description + opensearch_sg_logical_id = vpc_stack.get_logical_id(vpc_stack.opensearch_security_group.node.default_child) + opensearch_sg = TestVpcStack.get_resource_properties_by_logical_id(opensearch_sg_logical_id, security_groups) + self.assertEqual( + { + 'GroupDescription': 'Security group for OpenSearch Domain', + 'SecurityGroupEgress': [ + {'CidrIp': '0.0.0.0/0', 'Description': 'Allow all outbound traffic by default', 'IpProtocol': '-1'} + ], + 'VpcId': {'Ref': 'CompactConnectVpcF5956695'}, + }, + opensearch_sg, + ) + + # Verify Lambda security group exists with correct description + lambda_sg_logical_id = vpc_stack.get_logical_id(vpc_stack.lambda_security_group.node.default_child) + lambda_sg = TestVpcStack.get_resource_properties_by_logical_id(lambda_sg_logical_id, security_groups) + self.assertEqual( + { + 'GroupDescription': 'Security group for Lambda functions within VPC', + 'SecurityGroupEgress': [ + {'CidrIp': '0.0.0.0/0', 'Description': 'Allow all outbound traffic by default', 'IpProtocol': '-1'} + ], + 'VpcId': {'Ref': 'CompactConnectVpcF5956695'}, + }, + lambda_sg, + ) + + def test_opensearch_ingress_rule(self): + """ + Test that the OpenSearch security group allows ingress from Lambda security group on port 443. + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Get the logical IDs for both security groups + lambda_sg_logical_id = vpc_stack.get_logical_id(vpc_stack.lambda_security_group.node.default_child) + + # Verify ingress rule exists allowing Lambda to access OpenSearch on port 443 + vpc_template.has_resource_properties( + 'AWS::EC2::SecurityGroupIngress', + { + 'IpProtocol': 'tcp', + 'FromPort': 443, + 'ToPort': 443, + 'SourceSecurityGroupId': {'Fn::GetAtt': [lambda_sg_logical_id, 'GroupId']}, + }, + ) + + def test_explicit_subnet_cidr_blocks(self): + """ + Test that subnet CIDR blocks are explicitly set to allow future VPC expansion. + + This verifies that each subnet has its CIDR block locked in via CloudFormation + property overrides. This prevents CIDR conflicts when adding more AZs in the future. + + CIDR allocation from 10.0.0.0/16 VPC: + - Subnet 1 (AZ 1): 10.0.0.0/20 (10.0.0.0 - 10.0.15.255, 4096 IPs) + - Subnet 2 (AZ 2): 10.0.16.0/20 (10.0.16.0 - 10.0.31.255, 4096 IPs) + - Subnet 3 (AZ 3): 10.0.32.0/20 (10.0.32.0 - 10.0.47.255, 4096 IPs) + - Reserved for future: 10.0.48.0/20 and beyond + + Reference: https://github.com/aws/aws-cdk/issues/24708#issuecomment-1665795316 + """ + vpc_stack = self.app.sandbox_backend_stage.vpc_stack + vpc_template = Template.from_stack(vpc_stack) + + # Get all subnet resources + subnet_resources = vpc_template.find_resources('AWS::EC2::Subnet') + + # Filter to only private subnets (those without MapPublicIpOnLaunch) + private_subnets = [] + for logical_id, subnet in subnet_resources.items(): + properties = subnet.get('Properties', {}) + # Private subnets don't have MapPublicIpOnLaunch or it's set to false + if not properties.get('MapPublicIpOnLaunch', False): + private_subnets.append((logical_id, properties)) + + # Verify we have exactly 3 private subnets + self.assertEqual(3, len(private_subnets), f'Expected exactly 3 private subnets, found {len(private_subnets)}') + + # Expected CIDR blocks for the 3 private subnets + expected_cidr_blocks = ['10.0.0.0/20', '10.0.16.0/20', '10.0.32.0/20'] + + # Extract and sort the CIDR blocks from the subnets + actual_cidr_blocks = sorted([subnet[1]['CidrBlock'] for subnet in private_subnets]) + + # Verify the CIDR blocks match our expected explicit allocation + self.assertEqual( + expected_cidr_blocks, + actual_cidr_blocks, + 'Subnet CIDR blocks do not match expected explicit allocation. ' + 'This is critical for preventing conflicts when expanding the VPC.', + ) diff --git a/backend/social-work-app/tests/common_constructs/__init__.py b/backend/social-work-app/tests/common_constructs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/tests/common_constructs/test_cognito_user_backup.py b/backend/social-work-app/tests/common_constructs/test_cognito_user_backup.py new file mode 100644 index 0000000000..4142ffdf2d --- /dev/null +++ b/backend/social-work-app/tests/common_constructs/test_cognito_user_backup.py @@ -0,0 +1,333 @@ +""" +Test suite for the CognitoUserBackup common construct. + +This module tests the CognitoUserBackup construct to ensure it creates all necessary +resources with proper configuration, including the S3 bucket, Lambda function, +EventBridge rule, CloudWatch alarm, and backup plan. +""" + +from unittest import TestCase + +from aws_cdk import App, RemovalPolicy, Stack +from aws_cdk.assertions import Match, Template +from aws_cdk.aws_backup import CfnBackupPlan, CfnBackupSelection +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_iam import CfnPolicy +from aws_cdk.aws_kms import Key +from aws_cdk.aws_lambda import CfnFunction, Runtime +from aws_cdk.aws_s3 import CfnBucket +from aws_cdk.aws_sns import Topic +from common_constructs.access_logs_bucket import AccessLogsBucket +from common_constructs.python_common_layer_versions import PythonCommonLayerVersions +from common_constructs.stack import AppStack, StandardTags +from common_stacks.backup_infrastructure_stack import BackupInfrastructureStack + +from common_constructs.cognito_user_backup import CognitoUserBackup + + +class TestCognitoUserBackup(TestCase): + @classmethod + def setUpClass(cls): + """Set up test infrastructure.""" + cls.app = App() + # The persistent stack and layer are required for CognitoUserBackup, as an internal lambda depends on it. + # Use a non-pipeline environment name so domain_name is not required (avoids HostedZone.from_lookup in tests). + common_stack = AppStack( + cls.app, + 'CommonStack', + environment_context={}, + environment_name='sandbox', + standard_tags=StandardTags(project='compact-connect', service='compact-connect', environment='test'), + ) + # Create common lambda layers + PythonCommonLayerVersions( + common_stack, + 'CommonLayers', + compatible_runtimes=[Runtime.PYTHON_3_14], + ) + + cls.stack = Stack(cls.app, 'TestStack') + + # Create required dependencies + cls.encryption_key = Key(cls.stack, 'TestKey') + cls.alarm_topic = Topic(cls.stack, 'AlarmTopic', master_key=cls.encryption_key) + cls.access_logs_bucket = AccessLogsBucket(cls.stack, 'AccessLogsBucket', removal_policy=RemovalPolicy.DESTROY) + + # Mock backup infrastructure components + cls.mock_backup_config = { + 'backup_account_id': '123456789012', + 'backup_region': 'us-east-1', + 'general_vault_name': 'test-general-vault', + 'ssn_vault_name': 'test-ssn-vault', + } + + # Create backup infrastructure stack for dependencies + cls.backup_infrastructure_stack = BackupInfrastructureStack( + cls.stack, + 'BackupInfrastructure', + environment_name='test', + backup_config=cls.mock_backup_config, + alarm_topic=cls.alarm_topic, + removal_policy=RemovalPolicy.DESTROY, + ) + + # Create test environment context with backup policies + cls.environment_context = { + 'backup_enabled': True, + 'backup_policies': { + 'general_data': { + 'schedule': { + 'week_day': '5', + 'year': '*', + 'month': '*', + 'hour': '5', + 'minute': '0', + }, + 'delete_after_days': 180, + 'cold_storage_after_days': 30, + } + }, + } + + # Create the construct under test + cls.cognito_backup = CognitoUserBackup( + cls.stack, + 'TestCognitoBackup', + user_pool_id='us-east-1_TestPool123', + access_logs_bucket=cls.access_logs_bucket, + encryption_key=cls.encryption_key, + removal_policy=RemovalPolicy.DESTROY, + backup_infrastructure_stack=cls.backup_infrastructure_stack, + alarm_topic=cls.alarm_topic, + environment_context=cls.environment_context, + ) + + cls.template = Template.from_stack(cls.stack) + + def test_creates_s3_backup_bucket(self): + """Test that the S3 backup bucket is created with proper configuration.""" + # Should create an S3 bucket with KMS encryption and versioning + self.template.has_resource_properties( + CfnBucket.CFN_RESOURCE_TYPE_NAME, + { + 'BucketEncryption': { + 'ServerSideEncryptionConfiguration': [ + { + 'ServerSideEncryptionByDefault': { + 'SSEAlgorithm': 'aws:kms', + 'KMSMasterKeyID': { + 'Fn::GetAtt': [ + self.stack.get_logical_id(self.encryption_key.node.default_child), + 'Arn', + ] + }, + } + } + ] + }, + 'LoggingConfiguration': { + 'DestinationBucketName': { + 'Ref': self.stack.get_logical_id(self.access_logs_bucket.node.default_child) + } + }, + 'PublicAccessBlockConfiguration': { + 'BlockPublicAcls': True, + 'BlockPublicPolicy': True, + 'IgnorePublicAcls': True, + 'RestrictPublicBuckets': True, + }, + 'VersioningConfiguration': { + 'Status': 'Enabled', + }, + }, + ) + + def test_creates_lambda_function(self): + """Test that the Lambda function is created with proper configuration.""" + # Find the Lambda function + lambda_functions = self.template.find_resources( + CfnFunction.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'Handler': 'handlers.cognito_backup.backup_handler', + 'Description': 'Export user pool data for backup purposes', + } + }, + ) + self.assertEqual(len(lambda_functions), 1, 'Should have exactly one Cognito backup Lambda function') + + lambda_logical_id = list(lambda_functions.keys())[0] + lambda_props = lambda_functions[lambda_logical_id]['Properties'] + + # Verify function configuration + self.assertEqual(lambda_props['Runtime'], 'python3.14') + self.assertEqual(lambda_props['Timeout'], 900) # 15 minutes + self.assertEqual(lambda_props['MemorySize'], 512) + + def test_creates_iam_permissions_for_lambda(self): + """Test that the Lambda function has proper IAM permissions.""" + # Should have policies for Cognito access + self.template.has_resource( + CfnPolicy.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'PolicyDocument': { + 'Statement': Match.array_with( + [ + Match.object_like( + { + 'Effect': 'Allow', + 'Action': ['cognito-idp:ListUsers', 'cognito-idp:DescribeUserPool'], + 'Resource': { + 'Fn::Join': [ + '', + [ + 'arn:', + {'Ref': 'AWS::Partition'}, + ':cognito-idp:', + {'Ref': 'AWS::Region'}, + ':', + {'Ref': 'AWS::AccountId'}, + ':userpool/us-east-1_TestPool123', + ], + ] + }, + } + ) + ] + ) + } + } + }, + ) + + # Should have policies for S3 access + self.template.has_resource( + CfnPolicy.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'PolicyDocument': { + 'Statement': Match.array_with( + [ + Match.object_like( + { + 'Effect': 'Allow', + 'Action': Match.array_with([Match.string_like_regexp(r's3:.*')]), + 'Resource': Match.any_value(), + } + ), + ] + ) + } + } + }, + ) + + def test_creates_eventbridge_rule(self): + """Test that the EventBridge rule is created for daily scheduling.""" + backup_bucket_logical_id = self.stack.get_logical_id(self.cognito_backup.backup_bucket.node.default_child) + + # Find EventBridge rules + self.template.has_resource( + CfnRule.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'Description': 'Daily schedule for user pool backup export', + 'ScheduleExpression': 'cron(0 5 ? * * *)', # 5 AM UTC daily + 'State': 'ENABLED', + 'Targets': [ + Match.object_like( + { + 'Arn': Match.any_value(), + 'Input': { + 'Fn::Join': [ + '', + [ + '{"user_pool_id":"us-east-1_TestPool123","backup_bucket_name":"', + {'Ref': backup_bucket_logical_id}, + '"}', + ], + ] + }, + } + ) + ], + } + }, + ) + + def test_creates_cloudwatch_alarm(self): + """Test that the CloudWatch alarm is created with proper configuration.""" + alarm_topic_logical_id = self.stack.get_logical_id(self.alarm_topic.node.default_child) + + # Find CloudWatch alarms + self.template.has_resource( + CfnAlarm.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'AlarmDescription': ( + 'User pool backup export Lambda has failed. User data backup may be incomplete.' + ), + 'ComparisonOperator': 'GreaterThanOrEqualToThreshold', + 'Threshold': 1, + 'EvaluationPeriods': 1, + 'TreatMissingData': 'notBreaching', + 'AlarmActions': [{'Ref': alarm_topic_logical_id}], + 'Namespace': 'AWS/Lambda', + 'MetricName': 'Errors', + } + }, + ) + + def test_creates_backup_plan(self): + """Test that the backup plan is created for the bucket.""" + backup_bucket_logical_id = self.stack.get_logical_id(self.cognito_backup.backup_bucket.node.default_child) + + # Should create a backup plan + self.template.has_resource( + CfnBackupPlan.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'BackupPlan': { + 'BackupPlanName': { + 'Fn::Join': [ + '', + [ + {'Ref': backup_bucket_logical_id}, + '-cognito-backup-BackupPlan', + ], + ] + } + } + } + }, + ) + + # Should create a backup selection + self.template.has_resource( + CfnBackupSelection.CFN_RESOURCE_TYPE_NAME, + { + 'Properties': { + 'BackupSelection': { + 'SelectionName': Match.any_value(), + 'IamRoleArn': { + 'Fn::GetAtt': [ + self.stack.get_logical_id(self.backup_infrastructure_stack.node.default_child), + Match.string_like_regexp( + r'Outputs\.TestStackBackupInfrastructureBackupServiceRole.*Arn' + ), + ], + }, + 'Resources': [ + { + 'Fn::GetAtt': [ + backup_bucket_logical_id, + 'Arn', + ], + }, + ], + } + } + }, + ) diff --git a/backend/social-work-app/tests/resources/snapshots/DATA_EVENT_RULE.json b/backend/social-work-app/tests/resources/snapshots/DATA_EVENT_RULE.json new file mode 100644 index 0000000000..e1bf16f53c --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/DATA_EVENT_RULE.json @@ -0,0 +1,35 @@ +{ + "Type": "AWS::Events::Rule", + "Properties": { + "EventBusName": { + "Ref": "DataEventBus55A300BC" + }, + "EventPattern": { + "detail-type": [ + { + "prefix": "" + } + ] + }, + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::GetAtt": [ + "DataEventTableDataSourceQueueA8D4356F", + "Arn" + ] + }, + "DeadLetterConfig": { + "Arn": { + "Fn::GetAtt": [ + "DataEventTableDataSourceDLQ391B9932", + "Arn" + ] + } + }, + "Id": "Target0" + } + ] + } +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..0914918329 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_COMPACT_CONFIGURATION_RESPONSE_SCHEMA.json @@ -0,0 +1,75 @@ +{ + "properties": { + "compactAbbr": { + "description": "The abbreviation of the compact", + "type": "string" + }, + "compactName": { + "description": "The full name of the compact", + "type": "string" + }, + "compactOperationsTeamEmails": { + "description": "List of email addresses for operations team notifications", + "items": { + "format": "email", + "type": "string" + }, + "type": "array" + }, + "compactAdverseActionsNotificationEmails": { + "description": "List of email addresses for adverse actions notifications", + "items": { + "format": "email", + "type": "string" + }, + "type": "array" + }, + "licenseeRegistrationEnabled": { + "description": "Denotes whether licensee registration is enabled", + "type": "boolean" + }, + "configuredStates": { + "description": "List of states that have submitted configurations and their live status", + "items": { + "properties": { + "postalAbbreviation": { + "description": "The postal abbreviation of the jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "isLive": { + "description": "Whether the state is live and available for registrations.", + "type": "boolean" + } + }, + "required": [ + "postalAbbreviation", + "isLive" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "compactAbbr", + "compactName", + "compactOperationsTeamEmails", + "compactAdverseActionsNotificationEmails", + "licenseeRegistrationEnabled", + "configuredStates" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..dcbd79088d --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA.json @@ -0,0 +1,25 @@ +{ + "items": { + "properties": { + "compact": { + "type": "string" + }, + "jurisdictionName": { + "description": "The name of the jurisdiction", + "type": "string" + }, + "postalAbbreviation": { + "description": "The postal abbreviation of the jurisdiction", + "type": "string" + } + }, + "required": [ + "compact", + "jurisdictionName", + "postalAbbreviation" + ], + "type": "object" + }, + "type": "array", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..b07f5a3638 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_JURISDICTION_CONFIGURATION_RESPONSE_SCHEMA.json @@ -0,0 +1,49 @@ +{ + "properties": { + "compact": { + "description": "The compact this jurisdiction configuration belongs to", + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdictionName": { + "description": "The name of the jurisdiction", + "type": "string" + }, + "postalAbbreviation": { + "description": "The postal abbreviation of the jurisdiction", + "type": "string" + }, + "jurisdictionOperationsTeamEmails": { + "description": "List of email addresses for operations team notifications", + "items": { + "format": "email", + "type": "string" + }, + "type": "array" + }, + "jurisdictionAdverseActionsNotificationEmails": { + "description": "List of email addresses for adverse actions notifications", + "items": { + "format": "email", + "type": "string" + }, + "type": "array" + }, + "licenseeRegistrationEnabled": { + "description": "Denotes whether licensee registration is enabled", + "type": "boolean" + } + }, + "required": [ + "compact", + "jurisdictionName", + "postalAbbreviation", + "jurisdictionOperationsTeamEmails", + "jurisdictionAdverseActionsNotificationEmails", + "licenseeRegistrationEnabled" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..b87a4b97b3 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_RESPONSE_SCHEMA.json @@ -0,0 +1,1418 @@ +{ + "properties": { + "adverseActions": { + "items": { + "properties": { + "type": { + "enum": [ + "adverseAction" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "actionAgainst": { + "type": "string" + }, + "effectiveStartDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "creationDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "encumbranceType": { + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + }, + "liftingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "jurisdiction", + "licenseTypeAbbreviation", + "licenseType", + "actionAgainst", + "effectiveStartDate", + "creationDate", + "adverseActionId", + "dateOfUpdate", + "encumbranceType" + ], + "type": "object" + }, + "type": "array" + }, + "licenses": { + "items": { + "properties": { + "type": { + "enum": [ + "license-home" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "licensed clinical social worker", + "licensed master social worker,", + "licensed bachelor social worker" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "history": { + "items": { + "properties": { + "type": { + "enum": [ + "licenseUpdate" + ], + "type": "string" + }, + "updateType": { + "enum": [ + "deactivation", + "expiration", + "issuance", + "other", + "renewal", + "encumbrance", + "lifting_encumbrance", + "licenseDeactivation" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "licensed clinical social worker", + "licensed master social worker,", + "licensed bachelor social worker" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "previous": { + "properties": { + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "emailAddress": { + "format": "email", + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "givenName", + "middleName", + "familyName", + "dateOfUpdate", + "dateOfIssuance", + "dateOfRenewal", + "dateOfExpiration", + "homeAddressStreet1", + "homeAddressCity", + "homeAddressState", + "homeAddressPostalCode", + "jurisdictionUploadedLicenseStatus", + "jurisdictionUploadedCompactEligibility" + ], + "type": "object" + }, + "updatedValues": { + "properties": { + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "emailAddress": { + "format": "email", + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "removedValues": { + "description": "List of field names that were present in the previous record but removed in the update", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "updateType", + "compact", + "jurisdiction", + "dateOfUpdate", + "previous" + ], + "type": "object" + }, + "type": "array" + }, + "adverseActions": { + "items": { + "properties": { + "type": { + "enum": [ + "adverseAction" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "actionAgainst": { + "type": "string" + }, + "effectiveStartDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "creationDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "encumbranceType": { + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + }, + "liftingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "jurisdiction", + "licenseTypeAbbreviation", + "licenseType", + "actionAgainst", + "effectiveStartDate", + "creationDate", + "adverseActionId", + "dateOfUpdate", + "encumbranceType" + ], + "type": "object" + }, + "type": "array" + }, + "investigations": { + "items": { + "properties": { + "type": { + "enum": [ + "investigation" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "creationDate": { + "format": "date-time", + "type": "string" + }, + "submittingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "investigationId", + "jurisdiction", + "licenseType", + "dateOfUpdate", + "creationDate", + "submittingUser" + ], + "type": "object" + }, + "type": "array" + }, + "investigationStatus": { + "description": "Status indicating if the license is under investigation", + "enum": [ + "underInvestigation" + ], + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "emailAddress": { + "format": "email", + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "compact", + "jurisdiction", + "dateOfUpdate", + "givenName", + "middleName", + "familyName", + "homeAddressStreet1", + "homeAddressCity", + "homeAddressState", + "homeAddressPostalCode", + "licenseType", + "dateOfIssuance", + "dateOfRenewal", + "dateOfExpiration", + "birthMonthDay", + "licenseStatus", + "compactEligibility", + "jurisdictionUploadedLicenseStatus", + "jurisdictionUploadedCompactEligibility", + "history" + ], + "type": "object" + }, + "type": "array" + }, + "privileges": { + "items": { + "properties": { + "history": { + "items": { + "properties": { + "type": { + "enum": [ + "privilegeUpdate" + ], + "type": "string" + }, + "updateType": { + "enum": [ + "deactivation", + "expiration", + "issuance", + "other", + "renewal", + "encumbrance", + "lifting_encumbrance", + "licenseDeactivation" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "licensed clinical social worker", + "licensed master social worker,", + "licensed bachelor social worker" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "previous": { + "properties": { + "type": { + "enum": [ + "privilege" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "compactTransactionId": { + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "administratorSetStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + } + }, + "required": [ + "dateOfExpiration", + "compactTransactionId", + "licenseJurisdiction", + "administratorSetStatus" + ], + "type": "object" + }, + "updatedValues": { + "properties": { + "type": { + "enum": [ + "privilege" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "compactTransactionId": { + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "administratorSetStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + } + }, + "type": "object" + }, + "removedValues": { + "description": "List of field names that were present in the previous record but removed in the update", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "updateType", + "compact", + "jurisdiction", + "dateOfUpdate", + "previous" + ], + "type": "object" + }, + "type": "array" + }, + "licenseType": { + "enum": [ + "licensed clinical social worker", + "licensed master social worker,", + "licensed bachelor social worker" + ], + "type": "string" + }, + "adverseActions": { + "items": { + "properties": { + "type": { + "enum": [ + "adverseAction" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "actionAgainst": { + "type": "string" + }, + "effectiveStartDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "creationDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "encumbranceType": { + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + }, + "liftingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "jurisdiction", + "licenseTypeAbbreviation", + "licenseType", + "actionAgainst", + "effectiveStartDate", + "creationDate", + "adverseActionId", + "dateOfUpdate", + "encumbranceType" + ], + "type": "object" + }, + "type": "array" + }, + "investigations": { + "items": { + "properties": { + "type": { + "enum": [ + "investigation" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "creationDate": { + "format": "date-time", + "type": "string" + }, + "submittingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "investigationId", + "jurisdiction", + "licenseType", + "dateOfUpdate", + "creationDate", + "submittingUser" + ], + "type": "object" + }, + "type": "array" + }, + "investigationStatus": { + "description": "Status indicating if the privilege is under investigation", + "enum": [ + "underInvestigation" + ], + "type": "string" + }, + "type": { + "enum": [ + "privilege" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "compactTransactionId": { + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "administratorSetStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "compact", + "jurisdiction", + "dateOfExpiration", + "compactTransactionId", + "licenseType", + "licenseJurisdiction", + "administratorSetStatus", + "status", + "history" + ], + "type": "object" + }, + "type": "array" + }, + "type": { + "enum": [ + "provider" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "birthMonthDay": { + "format": "date", + "pattern": "^[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "givenName", + "familyName", + "compact", + "licenseJurisdiction", + "dateOfUpdate", + "dateOfExpiration", + "birthMonthDay", + "licenses", + "privileges", + "adverseActions" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_4XX_ALARM_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_4XX_ALARM_SCHEMA.json new file mode 100644 index 0000000000..af8b0ef881 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_4XX_ALARM_SCHEMA.json @@ -0,0 +1,31 @@ +{ + "AlarmDescription": "Sandbox/APIStack/LicenseApi SECURITY ALERT: Potential abuse detected - Excessive 4xx errors triggered on GET provider SSN endpoint. Immediate investigation required.", + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Dimensions": [ + { + "Name": "ApiName", + "Value": "LicenseApi" + }, + { + "Name": "Method", + "Value": "GET" + }, + { + "Name": "Resource", + "Value": "/v1/compacts/{compact}/providers/{providerId}/ssn" + }, + { + "Name": "Stage", + "Value": { + "Ref": "LicenseApiDeploymentStagejustinblue167DE831" + } + } + ], + "EvaluationPeriods": 1, + "MetricName": "4XXError", + "Namespace": "AWS/ApiGateway", + "Period": 300, + "Statistic": "Sum", + "Threshold": 100, + "TreatMissingData": "notBreaching" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_ANOMALY_DETECTION_ALARM_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_ANOMALY_DETECTION_ALARM_SCHEMA.json new file mode 100644 index 0000000000..0fa791e186 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_ANOMALY_DETECTION_ALARM_SCHEMA.json @@ -0,0 +1,31 @@ +{ + "ActionsEnabled": true, + "AlarmDescription": "Sandbox/ApiLambdaStack/GetProviderSSNHandler read-ssn anomaly detection. The GET provider SSN endpoint has beencalled an irregular number of times. Investigation required to ensure ssn endpoint is not being abused.", + "ComparisonOperator": "GreaterThanUpperThreshold", + "EvaluationPeriods": 1, + "Metrics": [ + { + "Expression": "ANOMALY_DETECTION_BAND(m1, 2)", + "Id": "ad1" + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Dimensions": [ + { + "Name": "service", + "Value": "common" + } + ], + "MetricName": "read-ssn", + "Namespace": "compact-connect" + }, + "Period": 3600, + "Stat": "SampleCount" + } + } + ], + "ThresholdMetricId": "ad1", + "TreatMissingData": "notBreaching" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_ENDPOINT_DISABLED_ALARM_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_ENDPOINT_DISABLED_ALARM_SCHEMA.json new file mode 100644 index 0000000000..7f0ee4302a --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_ENDPOINT_DISABLED_ALARM_SCHEMA.json @@ -0,0 +1,17 @@ +{ + "AlarmDescription": "Sandbox/ApiLambdaStack/GetProviderSSNHandler SECURITY ALERT: SSN ENDPOINT DISABLED. The GET provider SSN endpoint has been disabled due to excessive requests. Immediate investigation required. Endpoint will need to be manually reactivated before any further requests can be processed.", + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Dimensions": [ + { + "Name": "service", + "Value": "common" + } + ], + "EvaluationPeriods": 1, + "MetricName": "ssn-endpoint-disabled", + "Namespace": "compact-connect", + "Period": 300, + "Statistic": "SampleCount", + "Threshold": 1, + "TreatMissingData": "notBreaching" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_READS_RATE_LIMITED_ALARM_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_READS_RATE_LIMITED_ALARM_SCHEMA.json new file mode 100644 index 0000000000..1e27801c3c --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_READS_RATE_LIMITED_ALARM_SCHEMA.json @@ -0,0 +1,17 @@ +{ + "AlarmDescription": "Sandbox/ApiLambdaStack/GetProviderSSNHandler ssn reads rate-limited alarm. The GET provider SSN endpoint has been invoked more than an expected threshold within a 24 hour period. Investigation is required to ensure access is not the result of abuse.", + "ComparisonOperator": "GreaterThanOrEqualToThreshold", + "Dimensions": [ + { + "Name": "service", + "Value": "common" + } + ], + "EvaluationPeriods": 1, + "MetricName": "rate-limited-ssn-access", + "Namespace": "compact-connect", + "Period": 300, + "Statistic": "SampleCount", + "Threshold": 1, + "TreatMissingData": "notBreaching" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..4ea8cc9475 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_PROVIDER_SSN_RESPONSE_SCHEMA.json @@ -0,0 +1,14 @@ +{ + "properties": { + "ssn": { + "description": "The provider's social security number", + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string" + } + }, + "required": [ + "ssn" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/GET_PUBLIC_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/GET_PUBLIC_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..dcbd79088d --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/GET_PUBLIC_COMPACT_JURISDICTIONS_RESPONSE_SCHEMA.json @@ -0,0 +1,25 @@ +{ + "items": { + "properties": { + "compact": { + "type": "string" + }, + "jurisdictionName": { + "description": "The name of the jurisdiction", + "type": "string" + }, + "postalAbbreviation": { + "description": "The postal abbreviation of the jurisdiction", + "type": "string" + } + }, + "required": [ + "compact", + "jurisdictionName", + "postalAbbreviation" + ], + "type": "object" + }, + "type": "array", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/JURISDICTION_RESOURCE_SERVER_CONFIGURATION.json b/backend/social-work-app/tests/resources/snapshots/JURISDICTION_RESOURCE_SERVER_CONFIGURATION.json new file mode 100644 index 0000000000..300c7e7ad4 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/JURISDICTION_RESOURCE_SERVER_CONFIGURATION.json @@ -0,0 +1,182 @@ +[ + { + "Identifier": "al", + "Name": "al", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "az", + "Name": "az", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "co", + "Name": "co", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "ks", + "Name": "ks", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "ky", + "Name": "ky", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "md", + "Name": "md", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "oh", + "Name": "oh", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "tn", + "Name": "tn", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "va", + "Name": "va", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + }, + { + "Identifier": "wa", + "Name": "wa", + "Scopes": [ + { + "ScopeDescription": "Admin access for the socw compact within the jurisdiction", + "ScopeName": "socw.admin" + }, + { + "ScopeDescription": "Read access for SSNs in the socw compact within the jurisdiction", + "ScopeName": "socw.readPrivate" + }, + { + "ScopeDescription": "Write access for the socw compact within the jurisdiction", + "ScopeName": "socw.write" + } + ] + } +] diff --git a/backend/social-work-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..18cbcbff07 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA.json @@ -0,0 +1,16 @@ +{ + "additionalProperties": false, + "properties": { + "effectiveLiftDate": { + "description": "The effective date when the encumbrance will be lifted", + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + } + }, + "required": [ + "effectiveLiftDate" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..7aea9a0f24 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/LICENSE_ENCUMBRANCE_REQUEST_SCHEMA.json @@ -0,0 +1,40 @@ +{ + "additionalProperties": false, + "description": "Encumbrance data to create", + "properties": { + "encumbranceEffectiveDate": { + "description": "The effective date of the encumbrance", + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "encumbranceType": { + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ], + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "encumbranceEffectiveDate", + "encumbranceType", + "clinicalPrivilegeActionCategories" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..0078eabf52 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PATCH_LICENSE_INVESTIGATION_REQUEST_SCHEMA.json @@ -0,0 +1,54 @@ +{ + "properties": { + "action": { + "enum": [ + "close" + ], + "type": "string" + }, + "encumbrance": { + "additionalProperties": false, + "description": "Encumbrance data to create", + "properties": { + "encumbranceEffectiveDate": { + "description": "The effective date of the encumbrance", + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "encumbranceType": { + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ], + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "encumbranceEffectiveDate", + "encumbranceType", + "clinicalPrivilegeActionCategories" + ], + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..0078eabf52 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PATCH_PRIVILEGE_INVESTIGATION_REQUEST_SCHEMA.json @@ -0,0 +1,54 @@ +{ + "properties": { + "action": { + "enum": [ + "close" + ], + "type": "string" + }, + "encumbrance": { + "additionalProperties": false, + "description": "Encumbrance data to create", + "properties": { + "encumbranceEffectiveDate": { + "description": "The effective date of the encumbrance", + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "encumbranceType": { + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ], + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "encumbranceEffectiveDate", + "encumbranceType", + "clinicalPrivilegeActionCategories" + ], + "type": "object" + } + }, + "required": [ + "action" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..d3bf1d6d25 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json @@ -0,0 +1,50 @@ +{ + "additionalProperties": false, + "properties": { + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..fed034d2ba --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json @@ -0,0 +1,92 @@ +{ + "additionalProperties": false, + "properties": { + "userId": { + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "attributes": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "email", + "givenName", + "familyName" + ], + "type": "object" + }, + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "userId", + "attributes", + "permissions", + "status" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/POST_LICENSES_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/POST_LICENSES_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..46544416d6 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/POST_LICENSES_RESPONSE_SCHEMA.json @@ -0,0 +1,25 @@ +{ + "properties": { + "message": { + "description": "Message indicating success or failure", + "type": "string" + }, + "errors": { + "additionalProperties": { + "additionalProperties": { + "description": "List of error messages for a field", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "Errors for a specific record", + "type": "object" + }, + "description": "Validation errors by record index", + "type": "object" + } + }, + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..7809782f71 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json @@ -0,0 +1,80 @@ +{ + "additionalProperties": false, + "properties": { + "attributes": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "email", + "givenName", + "familyName" + ], + "type": "object" + }, + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "attributes", + "permissions" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..fed034d2ba --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json @@ -0,0 +1,92 @@ +{ + "additionalProperties": false, + "properties": { + "userId": { + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "attributes": { + "additionalProperties": false, + "properties": { + "email": { + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "email", + "givenName", + "familyName" + ], + "type": "object" + }, + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + } + }, + "type": "object" + }, + "jurisdictions": { + "additionalProperties": { + "properties": { + "actions": { + "additionalProperties": false, + "properties": { + "write": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "readPrivate": { + "type": "boolean" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "object" + } + }, + "required": [ + "userId", + "attributes", + "permissions", + "status" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_ANOMALY_DETECTION_ALARM_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_ANOMALY_DETECTION_ALARM_SCHEMA.json new file mode 100644 index 0000000000..585f27a689 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_ANOMALY_DETECTION_ALARM_SCHEMA.json @@ -0,0 +1,31 @@ +{ + "ActionsEnabled": true, + "AlarmDescription": "Sandbox/ApiLambdaStack staff-user-created anomaly detection. Anomalies in the number of staff users created per day are detected. Investigation is required to ensure requests are authorized.", + "ComparisonOperator": "GreaterThanUpperThreshold", + "EvaluationPeriods": 1, + "Metrics": [ + { + "Expression": "ANOMALY_DETECTION_BAND(m1, 2)", + "Id": "ad1" + }, + { + "Id": "m1", + "MetricStat": { + "Metric": { + "Dimensions": [ + { + "Name": "service", + "Value": "common" + } + ], + "MetricName": "staff-user-created", + "Namespace": "compact-connect" + }, + "Period": 3600, + "Stat": "SampleCount" + } + } + ], + "ThresholdMetricId": "ad1", + "TreatMissingData": "notBreaching" +} diff --git a/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_MAX_DAILY_ALARM_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_MAX_DAILY_ALARM_SCHEMA.json new file mode 100644 index 0000000000..728f5577b1 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_MAX_DAILY_ALARM_SCHEMA.json @@ -0,0 +1,17 @@ +{ + "AlarmDescription": "Sandbox/ApiLambdaStack max daily staff users created alarm. The POST staff user endpoint has been invoked more than an expected threshold within a day. Investigation is required to ensure requests are authorized.", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "service", + "Value": "common" + } + ], + "EvaluationPeriods": 1, + "MetricName": "staff-user-created", + "Namespace": "compact-connect", + "Period": 86400, + "Statistic": "SampleCount", + "Threshold": 20, + "TreatMissingData": "notBreaching" +} diff --git a/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_MAX_HOURLY_ALARM_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_MAX_HOURLY_ALARM_SCHEMA.json new file mode 100644 index 0000000000..ae470c377c --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/POST_STAFF_USER_MAX_HOURLY_ALARM_SCHEMA.json @@ -0,0 +1,17 @@ +{ + "AlarmDescription": "Sandbox/ApiLambdaStack max hourly staff users created alarm. The POST staff user endpoint has been invoked more than an expected threshold within an hour period. Investigation is required to ensure requests are authorized.", + "ComparisonOperator": "GreaterThanThreshold", + "Dimensions": [ + { + "Name": "service", + "Value": "common" + } + ], + "EvaluationPeriods": 1, + "MetricName": "staff-user-created", + "Namespace": "compact-connect", + "Period": 3600, + "Statistic": "SampleCount", + "Threshold": 5, + "TreatMissingData": "notBreaching" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..18cbcbff07 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_LIFTING_REQUEST_SCHEMA.json @@ -0,0 +1,16 @@ +{ + "additionalProperties": false, + "properties": { + "effectiveLiftDate": { + "description": "The effective date when the encumbrance will be lifted", + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + } + }, + "required": [ + "effectiveLiftDate" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..7aea9a0f24 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PRIVILEGE_ENCUMBRANCE_REQUEST_SCHEMA.json @@ -0,0 +1,40 @@ +{ + "additionalProperties": false, + "description": "Encumbrance data to create", + "properties": { + "encumbranceEffectiveDate": { + "description": "The effective date of the encumbrance", + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "encumbranceType": { + "description": "The type of encumbrance", + "enum": [ + "suspension", + "revocation", + "surrender of license" + ], + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "encumbranceEffectiveDate", + "encumbranceType", + "clinicalPrivilegeActionCategories" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..96b9cc65d3 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json @@ -0,0 +1,2191 @@ +{ + "properties": { + "licenses": { + "items": { + "properties": { + "type": { + "enum": [ + "license-home" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "cosmetologist", + "esthetician" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "history": { + "items": { + "properties": { + "type": { + "enum": [ + "licenseUpdate" + ], + "type": "string" + }, + "updateType": { + "enum": [ + "deactivation", + "expiration", + "issuance", + "other", + "renewal", + "encumbrance", + "homeJurisdictionChange", + "registration", + "lifting_encumbrance", + "licenseDeactivation", + "emailChange" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "cosmetologist", + "esthetician" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "previous": { + "properties": { + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "emailAddress": { + "format": "email", + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "givenName", + "middleName", + "familyName", + "dateOfUpdate", + "dateOfIssuance", + "dateOfRenewal", + "dateOfExpiration", + "homeAddressStreet1", + "homeAddressCity", + "homeAddressState", + "homeAddressPostalCode", + "jurisdictionUploadedLicenseStatus", + "jurisdictionUploadedCompactEligibility", + "licenseNumber" + ], + "type": "object" + }, + "updatedValues": { + "properties": { + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "emailAddress": { + "format": "email", + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "removedValues": { + "description": "List of field names that were present in the previous record but removed in the update", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "updateType", + "compact", + "jurisdiction", + "dateOfUpdate", + "previous" + ], + "type": "object" + }, + "type": "array" + }, + "adverseActions": { + "items": { + "properties": { + "type": { + "enum": [ + "adverseAction" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "actionAgainst": { + "type": "string" + }, + "effectiveStartDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "creationDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "encumbranceType": { + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + }, + "liftingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "jurisdiction", + "licenseTypeAbbreviation", + "licenseType", + "actionAgainst", + "effectiveStartDate", + "creationDate", + "adverseActionId", + "dateOfUpdate", + "encumbranceType" + ], + "type": "object" + }, + "type": "array" + }, + "investigations": { + "items": { + "properties": { + "type": { + "enum": [ + "investigation" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "creationDate": { + "format": "date-time", + "type": "string" + }, + "submittingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "investigationId", + "jurisdiction", + "licenseType", + "dateOfUpdate", + "creationDate", + "submittingUser" + ], + "type": "object" + }, + "type": "array" + }, + "investigationStatus": { + "description": "Status indicating if the license is under investigation", + "enum": [ + "underInvestigation" + ], + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseStatusName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "emailAddress": { + "format": "email", + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "phoneNumber": { + "pattern": "^\\+[0-9]{8,15}$", + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "compact", + "jurisdiction", + "dateOfUpdate", + "givenName", + "middleName", + "familyName", + "homeAddressStreet1", + "homeAddressCity", + "homeAddressState", + "homeAddressPostalCode", + "licenseType", + "dateOfIssuance", + "dateOfRenewal", + "dateOfExpiration", + "birthMonthDay", + "licenseStatus", + "compactEligibility", + "jurisdictionUploadedLicenseStatus", + "jurisdictionUploadedCompactEligibility", + "history", + "licenseNumber" + ], + "type": "object" + }, + "type": "array" + }, + "privileges": { + "items": { + "properties": { + "history": { + "items": { + "properties": { + "type": { + "enum": [ + "privilegeUpdate" + ], + "type": "string" + }, + "updateType": { + "enum": [ + "deactivation", + "expiration", + "issuance", + "other", + "renewal", + "encumbrance", + "homeJurisdictionChange", + "registration", + "lifting_encumbrance", + "licenseDeactivation", + "emailChange" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "cosmetologist", + "esthetician" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "previous": { + "properties": { + "type": { + "enum": [ + "privilege" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "compactTransactionId": { + "type": "string" + }, + "privilegeId": { + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "administratorSetStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "attestations": { + "items": { + "properties": { + "attestationId": { + "maxLength": 100, + "type": "string" + }, + "version": { + "maxLength": 100, + "type": "string" + } + }, + "required": [ + "attestationId", + "version" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "dateOfIssuance", + "dateOfRenewal", + "dateOfExpiration", + "dateOfUpdate", + "compactTransactionId", + "privilegeId", + "licenseJurisdiction", + "administratorSetStatus", + "attestations" + ], + "type": "object" + }, + "updatedValues": { + "properties": { + "type": { + "enum": [ + "privilege" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "compactTransactionId": { + "type": "string" + }, + "privilegeId": { + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "administratorSetStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "attestations": { + "items": { + "properties": { + "attestationId": { + "maxLength": 100, + "type": "string" + }, + "version": { + "maxLength": 100, + "type": "string" + } + }, + "required": [ + "attestationId", + "version" + ], + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "removedValues": { + "description": "List of field names that were present in the previous record but removed in the update", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "type", + "updateType", + "compact", + "jurisdiction", + "dateOfUpdate", + "previous" + ], + "type": "object" + }, + "type": "array" + }, + "licenseType": { + "enum": [ + "cosmetologist", + "esthetician" + ], + "type": "string" + }, + "adverseActions": { + "items": { + "properties": { + "type": { + "enum": [ + "adverseAction" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "licenseTypeAbbreviation": { + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "actionAgainst": { + "type": "string" + }, + "effectiveStartDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "creationDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "adverseActionId": { + "type": "string" + }, + "effectiveLiftDate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "encumbranceType": { + "type": "string" + }, + "clinicalPrivilegeActionCategories": { + "description": "The categories of clinical privilege action", + "items": { + "enum": [ + "fraud", + "consumer harm", + "other" + ], + "type": "string" + }, + "type": "array" + }, + "liftingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "jurisdiction", + "licenseTypeAbbreviation", + "licenseType", + "actionAgainst", + "effectiveStartDate", + "creationDate", + "adverseActionId", + "dateOfUpdate", + "encumbranceType" + ], + "type": "object" + }, + "type": "array" + }, + "investigations": { + "items": { + "properties": { + "type": { + "enum": [ + "investigation" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "investigationId": { + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "licenseType": { + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "creationDate": { + "format": "date-time", + "type": "string" + }, + "submittingUser": { + "type": "string" + } + }, + "required": [ + "type", + "compact", + "providerId", + "investigationId", + "jurisdiction", + "licenseType", + "dateOfUpdate", + "creationDate", + "submittingUser" + ], + "type": "object" + }, + "type": "array" + }, + "investigationStatus": { + "description": "Status indicating if the privilege is under investigation", + "enum": [ + "underInvestigation" + ], + "type": "string" + }, + "type": { + "enum": [ + "privilege" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "compactTransactionId": { + "type": "string" + }, + "privilegeId": { + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "administratorSetStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "attestations": { + "items": { + "properties": { + "attestationId": { + "maxLength": 100, + "type": "string" + }, + "version": { + "maxLength": 100, + "type": "string" + } + }, + "required": [ + "attestationId", + "version" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "type", + "providerId", + "compact", + "jurisdiction", + "dateOfIssuance", + "dateOfRenewal", + "dateOfExpiration", + "dateOfUpdate", + "compactTransactionId", + "privilegeId", + "licenseType", + "licenseJurisdiction", + "administratorSetStatus", + "status", + "attestations", + "history" + ], + "type": "object" + }, + "type": "array" + }, + "militaryAffiliations": { + "items": { + "properties": { + "type": { + "enum": [ + "militaryAffiliation" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "fileNames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "affiliationType": { + "enum": [ + "militaryMember", + "militaryMemberSpouse" + ], + "type": "string" + }, + "dateOfUpload": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "downloadLinks": { + "items": { + "properties": { + "url": { + "type": "string" + }, + "fileName": { + "type": "string" + } + }, + "required": [ + "url", + "fileName" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "type", + "dateOfUpdate", + "providerId", + "compact", + "fileNames", + "affiliationType", + "dateOfUpload", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "type": { + "enum": [ + "provider" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "birthMonthDay": { + "format": "date", + "pattern": "^[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy" + ], + "type": "string" + }, + "compactConnectRegisteredEmailAddress": { + "format": "email", + "maxLength": 100, + "minLength": 5, + "type": "string" + }, + "currentHomeJurisdiction": { + "description": "The current jurisdiction postal abbreviation if known.", + "enum": [ + "al", + "ak", + "az", + "ar", + "ca", + "co", + "ct", + "de", + "dc", + "fl", + "ga", + "hi", + "id", + "il", + "in", + "ia", + "ks", + "ky", + "la", + "me", + "md", + "ma", + "mi", + "mn", + "ms", + "mo", + "mt", + "ne", + "nv", + "nh", + "nj", + "nm", + "ny", + "nc", + "nd", + "oh", + "ok", + "or", + "pa", + "pr", + "ri", + "sc", + "sd", + "tn", + "tx", + "ut", + "vt", + "va", + "vi", + "wa", + "wv", + "wi", + "wy", + "other", + "unknown" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "givenName", + "familyName", + "compact", + "licenseJurisdiction", + "dateOfUpdate", + "dateOfExpiration", + "birthMonthDay", + "licenses", + "privileges", + "militaryAffiliations" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PUBLIC_GET_PROVIDER_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PUBLIC_GET_PROVIDER_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..a79886b2c2 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PUBLIC_GET_PROVIDER_RESPONSE_SCHEMA.json @@ -0,0 +1,240 @@ +{ + "properties": { + "licenses": { + "description": "Sanitized home-state license rows (LicensePublicResponseSchema)", + "items": { + "properties": { + "type": { + "enum": [ + "license" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "licensed clinical social worker", + "licensed master social worker,", + "licensed bachelor social worker" + ], + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "type", + "compact", + "jurisdiction", + "licenseType", + "licenseStatus", + "compactEligibility", + "dateOfExpiration", + "licenseNumber" + ], + "type": "object" + }, + "type": "array" + }, + "privileges": { + "items": { + "properties": { + "type": { + "enum": [ + "privilege" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "jurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "licenseType": { + "enum": [ + "licensed clinical social worker", + "licensed master social worker,", + "licensed bachelor social worker" + ], + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "administratorSetStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "compact", + "jurisdiction", + "licenseJurisdiction", + "licenseType", + "dateOfExpiration", + "administratorSetStatus", + "status" + ], + "type": "object" + }, + "type": "array" + }, + "type": { + "enum": [ + "provider" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "dateOfUpdate", + "compact", + "licenseJurisdiction", + "givenName", + "familyName" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PUBLIC_QUERY_PROVIDERS_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PUBLIC_QUERY_PROVIDERS_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..e6102e020a --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PUBLIC_QUERY_PROVIDERS_REQUEST_SCHEMA.json @@ -0,0 +1,95 @@ +{ + "additionalProperties": false, + "properties": { + "query": { + "additionalProperties": false, + "description": "The query parameters", + "properties": { + "providerId": { + "description": "Internal UUID for the provider", + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "description": "Filter for providers with license in a jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "givenName": { + "description": "Filter for providers with a given name (familyName is required if givenName is provided)", + "maxLength": 100, + "type": "string" + }, + "familyName": { + "description": "Filter for providers with a family name", + "maxLength": 100, + "type": "string" + }, + "licenseNumber": { + "description": "Filter for licenses with a specific license number", + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "pagination": { + "additionalProperties": false, + "properties": { + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + }, + "type": "object" + }, + "sorting": { + "description": "How to sort results", + "properties": { + "key": { + "description": "The key to sort results by", + "enum": [ + "dateOfUpdate", + "familyName" + ], + "type": "string" + }, + "direction": { + "description": "Direction to sort results by", + "enum": [ + "ascending", + "descending" + ], + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PUBLIC_QUERY_PROVIDERS_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PUBLIC_QUERY_PROVIDERS_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..b5a2d2b580 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PUBLIC_QUERY_PROVIDERS_RESPONSE_SCHEMA.json @@ -0,0 +1,174 @@ +{ + "properties": { + "providers": { + "items": { + "properties": { + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "licenseType": { + "description": "License type or profession designation for this license row", + "type": "string" + }, + "licenseNumber": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseEligibility": { + "description": "Whether the license is eligible for compact participation in public search results", + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + } + }, + "required": [ + "providerId", + "givenName", + "familyName", + "licenseJurisdiction", + "compact", + "licenseType", + "licenseNumber", + "licenseEligibility" + ], + "type": "object" + }, + "maxLength": 100, + "type": "array" + }, + "pagination": { + "properties": { + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "prevLastKey": { + "maxLength": 1024, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + }, + "type": "object" + }, + "query": { + "properties": { + "providerId": { + "description": "Internal UUID for the provider", + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "description": "Filter for providers with privilege/license in a jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "givenName": { + "description": "Filter for providers with a given name", + "maxLength": 100, + "type": "string" + }, + "familyName": { + "description": "Filter for providers with a family name", + "maxLength": 100, + "type": "string" + }, + "licenseNumber": { + "description": "Filter for licenses with a specific license number", + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "sorting": { + "description": "How to sort results", + "properties": { + "key": { + "description": "The key to sort results by", + "enum": [ + "dateOfUpdate", + "familyName" + ], + "type": "string" + }, + "direction": { + "description": "Direction to sort results by", + "enum": [ + "ascending", + "descending" + ], + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + } + }, + "required": [ + "providers", + "pagination" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..bcfe3e22db --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PUT_COMPACT_CONFIGURATION_REQUEST_SCHEMA.json @@ -0,0 +1,73 @@ +{ + "additionalProperties": false, + "properties": { + "compactOperationsTeamEmails": { + "description": "List of email addresses for operations team notifications", + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "compactAdverseActionsNotificationEmails": { + "description": "List of email addresses for adverse actions notifications", + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "licenseeRegistrationEnabled": { + "description": "Denotes whether licensee registration is enabled", + "type": "boolean" + }, + "configuredStates": { + "description": "List of states that have submitted configurations and their live status", + "items": { + "additionalProperties": false, + "properties": { + "postalAbbreviation": { + "description": "The postal abbreviation of the jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "isLive": { + "description": "Whether the state is live and available for registrations.", + "type": "boolean" + } + }, + "required": [ + "postalAbbreviation", + "isLive" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "compactOperationsTeamEmails", + "compactAdverseActionsNotificationEmails", + "licenseeRegistrationEnabled", + "configuredStates" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..7996e3d856 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/PUT_JURISDICTION_CONFIGURATION_REQUEST_SCHEMA.json @@ -0,0 +1,38 @@ +{ + "additionalProperties": false, + "properties": { + "jurisdictionOperationsTeamEmails": { + "description": "List of email addresses for operations team notifications", + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "jurisdictionAdverseActionsNotificationEmails": { + "description": "List of email addresses for adverse actions notifications", + "items": { + "format": "email", + "type": "string" + }, + "maxItems": 10, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "licenseeRegistrationEnabled": { + "description": "Denotes whether licensee registration is enabled", + "type": "boolean" + } + }, + "required": [ + "jurisdictionOperationsTeamEmails", + "jurisdictionAdverseActionsNotificationEmails", + "licenseeRegistrationEnabled" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/QUERY_PROVIDERS_REQUEST_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/QUERY_PROVIDERS_REQUEST_SCHEMA.json new file mode 100644 index 0000000000..e6102e020a --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/QUERY_PROVIDERS_REQUEST_SCHEMA.json @@ -0,0 +1,95 @@ +{ + "additionalProperties": false, + "properties": { + "query": { + "additionalProperties": false, + "description": "The query parameters", + "properties": { + "providerId": { + "description": "Internal UUID for the provider", + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "jurisdiction": { + "description": "Filter for providers with license in a jurisdiction", + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "givenName": { + "description": "Filter for providers with a given name (familyName is required if givenName is provided)", + "maxLength": 100, + "type": "string" + }, + "familyName": { + "description": "Filter for providers with a family name", + "maxLength": 100, + "type": "string" + }, + "licenseNumber": { + "description": "Filter for licenses with a specific license number", + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "type": "object" + }, + "pagination": { + "additionalProperties": false, + "properties": { + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": "string" + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + }, + "type": "object" + }, + "sorting": { + "description": "How to sort results", + "properties": { + "key": { + "description": "The key to sort results by", + "enum": [ + "dateOfUpdate", + "familyName" + ], + "type": "string" + }, + "direction": { + "description": "Direction to sort results by", + "enum": [ + "ascending", + "descending" + ], + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + } + }, + "required": [ + "query" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/QUERY_PROVIDERS_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/QUERY_PROVIDERS_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..fc0515ea21 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/QUERY_PROVIDERS_RESPONSE_SCHEMA.json @@ -0,0 +1,187 @@ +{ + "properties": { + "providers": { + "items": { + "properties": { + "type": { + "enum": [ + "provider" + ], + "type": "string" + }, + "providerId": { + "pattern": "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab]{1}[0-9a-f]{3}-[0-9a-f]{12}", + "type": "string" + }, + "ssnLastFour": { + "pattern": "^[0-9]{4}$", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "suffix": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "compactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "jurisdictionUploadedLicenseStatus": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "jurisdictionUploadedCompactEligibility": { + "enum": [ + "eligible", + "ineligible" + ], + "type": "string" + }, + "compact": { + "enum": [ + "socw" + ], + "type": "string" + }, + "birthMonthDay": { + "format": "date", + "pattern": "^[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfBirth": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$", + "type": "string" + }, + "licenseJurisdiction": { + "enum": [ + "al", + "az", + "co", + "ks", + "ky", + "md", + "oh", + "tn", + "va", + "wa" + ], + "type": "string" + }, + "dateOfUpdate": { + "format": "date-time", + "type": "string" + } + }, + "required": [ + "type", + "providerId", + "givenName", + "familyName", + "licenseStatus", + "compactEligibility", + "jurisdictionUploadedLicenseStatus", + "jurisdictionUploadedCompactEligibility", + "compact", + "licenseJurisdiction", + "dateOfUpdate", + "dateOfExpiration", + "birthMonthDay" + ], + "type": "object" + }, + "maxLength": 100, + "type": "array" + }, + "pagination": { + "properties": { + "lastKey": { + "maxLength": 1024, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "prevLastKey": { + "maxLength": 1024, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "pageSize": { + "maximum": 100, + "minimum": 5, + "type": "integer" + } + }, + "type": "object" + }, + "sorting": { + "description": "How to sort results", + "properties": { + "key": { + "description": "The key to sort results by", + "enum": [ + "dateOfUpdate", + "familyName" + ], + "type": "string" + }, + "direction": { + "description": "Direction to sort results by", + "enum": [ + "ascending", + "descending" + ], + "type": "string" + } + }, + "required": [ + "key" + ], + "type": "object" + } + }, + "required": [ + "providers", + "pagination" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/STANDARD_MESSAGE_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/STANDARD_MESSAGE_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..a2d68ad756 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/STANDARD_MESSAGE_RESPONSE_SCHEMA.json @@ -0,0 +1,13 @@ +{ + "properties": { + "message": { + "description": "A message about the request", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/STATE_API_BULK_UPLOAD_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/STATE_API_BULK_UPLOAD_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..9a950789dc --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/STATE_API_BULK_UPLOAD_RESPONSE_SCHEMA.json @@ -0,0 +1,27 @@ +{ + "properties": { + "upload": { + "properties": { + "url": { + "type": "string" + }, + "fields": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + "required": [ + "url", + "fields" + ], + "type": "object" + } + }, + "required": [ + "upload" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/snapshots/STATE_API_POST_LICENSES_RESPONSE_SCHEMA.json b/backend/social-work-app/tests/resources/snapshots/STATE_API_POST_LICENSES_RESPONSE_SCHEMA.json new file mode 100644 index 0000000000..46544416d6 --- /dev/null +++ b/backend/social-work-app/tests/resources/snapshots/STATE_API_POST_LICENSES_RESPONSE_SCHEMA.json @@ -0,0 +1,25 @@ +{ + "properties": { + "message": { + "description": "Message indicating success or failure", + "type": "string" + }, + "errors": { + "additionalProperties": { + "additionalProperties": { + "description": "List of error messages for a field", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": "Errors for a specific record", + "type": "object" + }, + "description": "Validation errors by record index", + "type": "object" + } + }, + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/social-work-app/tests/resources/test_files/military_affiliation.pdf b/backend/social-work-app/tests/resources/test_files/military_affiliation.pdf new file mode 100644 index 0000000000..579058d9a3 Binary files /dev/null and b/backend/social-work-app/tests/resources/test_files/military_affiliation.pdf differ diff --git a/backend/social-work-app/tests/smoke/README.md b/backend/social-work-app/tests/smoke/README.md new file mode 100644 index 0000000000..272279e004 --- /dev/null +++ b/backend/social-work-app/tests/smoke/README.md @@ -0,0 +1,141 @@ +# Smoke Tests + +This directory contains smoke tests for the Compact ConnectSocial WorkAPI. Smoke tests are end-to-end integration tests that run against a test environment to verify that critical functionality works as expected. + +## Overview + +Smoke tests validate that key features of the Compact Connect API are working correctly in a test environment. They make real API calls and interact with actual AWS services (DynamoDB, Cognito, etc.) to ensure the system behaves correctly end-to-end. + +## Prerequisites + +Before running smoke tests, you must complete the following setup: + +### 1. Sandbox/Test Environment + +You must have access to a deployed sandbox environment of the Compact ConnectSocial WorkAPI. The sandbox should be deployed with the following configuration: + +- **Security Profile**: Your `cdk.context.json` file must have `"security_profile": "VULNERABLE"` set. This allows the smoke tests to create users programmatically using the boto3 Cognito client. +- +### 2. AWS Credentials + +Ensure your AWS credentials are configured with appropriate permissions to: +- Access DynamoDB tables in the sandbox environment +- Access Cognito user pools in the sandbox environment +- Access other AWS services used by the smoke tests + +1. Configure your AWS profile to use SSO: + ```bash + aws configure sso + ``` + Follow the prompts to set up your SSO profile using the values from your IAM identity center login + (see [AWS CLI SSO Configuration](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html#sso-configure-profile-token-auto-sso)) + +2. Log in to AWS SSO: + ```bash + aws sso login --profile + ``` + +3. Set your AWS profile environment variable (if not using the default profile): + ```bash + export AWS_PROFILE= + ``` + +### 3. Python Dependencies + +Install the required Python packages. The smoke tests use the same dependencies as the main codebase. Ensure you have: +- Python 3.x +- All dependencies from the project's requirements files + +### 4. Upload Test License Record + +You must have a test license record uploaded in your sandbox environment to generate a provider record. This license/provider will be used by the smoke tests to perform various tests. Once you have uploaded the license record into your environment, you will need to look up the provider id generated for that record in the Provider DynamoDB table and set it in your environment variables (see Environment Variables Setup below). + + +## Environment Variables Setup + +1. **Copy the example environment file:** + ```bash + cp smoke_tests_env_example.json smoke_tests_env.json + ``` + +2. **Edit `smoke_tests_env.json`** with your sandbox environment values: + + **Required Variables:** + - `CC_TEST_API_BASE_URL`: Base URL for the Compact Connect API (e.g., `https://api.sandbox.compactconnect.org`) + - `CC_TEST_STATE_API_BASE_URL`: Base URL for the state API + - `CC_TEST_STATE_AUTH_URL`: OAuth2 token endpoint for state authentication + - `CC_TEST_COGNITO_STATE_AUTH_USER_POOL_ID`: Cognito user pool ID for state auth + - `CC_TEST_PROVIDER_DYNAMO_TABLE_NAME`: DynamoDB table name for provider data + - `CC_TEST_COMPACT_CONFIGURATION_DYNAMO_TABLE_NAME`: DynamoDB table name for compact configuration + - `CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME`: DynamoDB table name for data events + - `CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME`: DynamoDB table name for staff users + - `CC_TEST_COGNITO_STAFF_USER_POOL_ID`: Cognito user pool ID for staff users + - `CC_TEST_COGNITO_STAFF_USER_POOL_CLIENT_ID`: Cognito client ID for staff users + - `CC_TEST_PROVIDER_ID`: Provider id of your test provider user + - `ENVIRONMENT_NAME`: Name of your sandbox environment + - `AWS_DEFAULT_REGION`: AWS region where your sandbox is deployed (e.g., `us-east-1`) + + **Optional Variables (for specific tests):** + - `CC_TEST_ROLLBACK_STEP_FUNCTION_ARN`: Step function ARN for rollback tests + - `CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME`: DynamoDB table name for rate limiting + - `CC_TEST_SSN_DYNAMO_TABLE_NAME`: DynamoDB table name for SSN data + +3. **Important:** Never commit `smoke_tests_env.json` to version control. It contains sensitive credentials and should be in `.gitignore`. + +## Running Smoke Tests + +### Running Individual Test Files + +Each test file can be run independently from the social-work-app folder: + +```bash +# Navigate to the compact-connect directory +cd backend/social-work-app + +# Run a specific test file +python3 tests/smoke/encumbrance_smoke_tests.py +``` + +## Special Test Requirements + +### Tests Creating Test Data + +Many tests create temporary test data (staff users, configurations, etc.) and clean it up automatically. However, if a test fails partway through, you may need to manually clean up test data. + +## Troubleshooting + +### Common Issues + +1. **"ResourceNotFoundException" when accessing DynamoDB tables** + - Verify that your `smoke_tests_env.json` has the correct table names for your sandbox environment + - Ensure your AWS credentials have permissions to access the tables + - Check that the tables exist in the specified region + +2. **"Failed to authenticate" or Cognito errors** + - Check that `security_profile: "VULNERABLE"` is set in your `cdk.context.json` + + +### Triage Test Failures + +If a test fails, you can consider the following steps to triage the cause of the failures: + +1. Review CloudWatch logs for Lambda functions that were invoked +2. Check DynamoDB tables directly using the AWS Console or CLI +3. Check Cognito user pools to see if test users were created + +## Contributing + +When adding new smoke tests: + +1. Follow the existing pattern in other test files +2. Use `SmokeTestFailureException` for test failures +3. Include cleanup logic for any test data created +4. Add appropriate docstrings explaining what the test does +5. Update this README with information about your new test if there are any special requirements + +## Additional Resources + +- See individual test files for specific requirements and usage examples +- Check `smoke_common.py` for shared utilities and helper functions +- Review `config.py` to understand how environment variables are loaded + diff --git a/backend/social-work-app/tests/smoke/__init__.py b/backend/social-work-app/tests/smoke/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/social-work-app/tests/smoke/backup_operations_smoke_tests.py b/backend/social-work-app/tests/smoke/backup_operations_smoke_tests.py new file mode 100755 index 0000000000..d2da4d3f8d --- /dev/null +++ b/backend/social-work-app/tests/smoke/backup_operations_smoke_tests.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +""" +Backup Operations Smoke Test + +This script tests the backup and cross-account copy functionality for CompactConnect tables. +It can test either 'provider' or 'ssn' table backup operations. + +Usage: + python test_backup_operations.py provider + python test_backup_operations.py ssn + +The script will: +1. Find the requested table and backup vaults +2. Initiate a backup job +3. Wait for backup completion +4. Initiate a cross-account copy job +5. Wait for copy completion +6. Report the results +""" + +import argparse +import json +import sys +import time +from datetime import UTC, datetime + +import boto3 +from botocore.exceptions import ClientError +from smoke_common import SmokeTestFailureException, load_smoke_test_env, logger + + +class BackupSmokeTest: + def __init__(self, table_type: str): + """Initialize the backup smoke test for the specified table type.""" + self.table_type = table_type.lower() + if self.table_type not in ['provider', 'ssn']: + raise ValueError("table_type must be 'provider' or 'ssn'") + + # Load environment variables + load_smoke_test_env() + + # Initialize AWS clients + self.backup_client = boto3.client('backup') + self.dynamodb_client = boto3.client('dynamodb') + + # Get table and vault configuration + self._load_table_config() + self._load_vault_config() + + logger.info(f'Initialized backup smoke test for {self.table_type} table') + + def _load_table_config(self): + """Load table configuration based on table type.""" + if self.table_type == 'provider': + # Provider table configuration + self.table_name = self._get_env_var('CC_TEST_PROVIDER_DYNAMO_TABLE_NAME') + self.backup_role_name = self._get_backup_role_name('BackupServiceRole') + self.local_vault_suffix = 'BackupVault' + self.cross_account_vault_suffix = 'BackupVault' + else: # ssn + # SSN table configuration + self.table_name = self._get_env_var('CC_TEST_SSN_DYNAMO_TABLE_NAME') + self.backup_role_name = self._get_backup_role_name('SSNBackupRole') + self.local_vault_suffix = 'SSNBackupVault' + self.cross_account_vault_suffix = 'SSNBackupVault' + + # Build table ARN + account_id = boto3.client('sts').get_caller_identity()['Account'] + region = boto3.Session().region_name + self.table_arn = f'arn:aws:dynamodb:{region}:{account_id}:table/{self.table_name}' + + logger.info(f'Table configuration - Name: {self.table_name}, ARN: {self.table_arn}') + + def _load_vault_config(self): + """Load backup vault configuration.""" + # Get environment name from context or environment + with open('cdk.json') as f: + context = json.load(f)['context'] + + # Load environment-specific context if available + try: + with open('cdk.context.json') as f: + env_context = json.load(f) + context.update(env_context) + except FileNotFoundError: + logger.warning('No environment-specific context file found, using base context') + + # Try to determine environment name from various sources + environment_name = context.get('environment_name') or self._get_env_var('ENVIRONMENT_NAME', required=False) + + # Local vault names (in the same account) + self.local_vault_name = f'CompactConnect-{environment_name}-{self.local_vault_suffix}' + + # Cross-account vault configuration + backup_config = context.get('ssm_context', {}).get('backup_config', {}) + backup_account_id = backup_config.get('backup_account_id') + backup_region = backup_config.get('backup_region', 'us-west-2') + + if self.table_type == 'provider': + cross_account_vault_name = backup_config.get('general_vault_name', 'CompactConnectBackupVault') + else: # ssn + cross_account_vault_name = backup_config.get('ssn_vault_name', 'CompactConnectBackupVault-SSN') + + if not backup_account_id: + raise SmokeTestFailureException('backup_account_id not found in CDK context configuration') + + self.cross_account_vault_arn = ( + f'arn:aws:backup:{backup_region}:{backup_account_id}:backup-vault:{cross_account_vault_name}' + ) + + logger.info( + f'Vault configuration - Local: {self.local_vault_name}, Cross-account: {self.cross_account_vault_arn}' + ) + + def _get_backup_role_name(self, role_suffix: str) -> str: + """Get the backup role name based on environment.""" + # Get environment name from context (should be loaded by _load_vault_config) + with open('cdk.json') as f: + context = json.load(f)['context'] + + # Load environment-specific context if available + try: + with open('cdk.context.json') as f: + env_context = json.load(f) + context.update(env_context) + except FileNotFoundError: + pass + + environment_name = context.get('environment_name', 'test') + return f'CompactConnect-{environment_name}-{role_suffix}' + + def _get_env_var(self, var_name: str, required: bool = True) -> str: + """Get environment variable with error handling.""" + import os + + value = os.environ.get(var_name) + if required and not value: + raise SmokeTestFailureException(f'Required environment variable {var_name} not found') + return value + + def run_backup_test(self) -> dict: + """Run the complete backup test and return results.""" + results = { + 'table_type': self.table_type, + 'table_name': self.table_name, + 'start_time': datetime.now(UTC).isoformat(), + 'backup_job': {}, + 'copy_job': {}, + 'success': False, + 'error': None, + } + + try: + logger.info(f'Starting backup smoke test for {self.table_type} table') + + # Step 1: Initiate backup + backup_job_id, recovery_point_arn = self._initiate_backup() + results['backup_job']['job_id'] = backup_job_id + results['backup_job']['recovery_point_arn'] = recovery_point_arn + + # Step 2: Wait for backup completion + backup_success = self._wait_for_backup_completion(backup_job_id) + results['backup_job']['success'] = backup_success + + if not backup_success: + raise SmokeTestFailureException('Backup job failed') + + # Step 3: Initiate cross-account copy + copy_job_id = self._initiate_copy(recovery_point_arn) + results['copy_job']['job_id'] = copy_job_id + + # Step 4: Wait for copy completion + copy_success = self._wait_for_copy_completion(copy_job_id) + results['copy_job']['success'] = copy_success + + if not copy_success: + raise SmokeTestFailureException('Copy job failed') + + results['success'] = True + results['end_time'] = datetime.now(UTC).isoformat() + + logger.info(f'Backup smoke test completed successfully for {self.table_type} table') + return results + + except (SmokeTestFailureException, ClientError, ValueError) as e: + results['error'] = str(e) + results['end_time'] = datetime.now(UTC).isoformat() + logger.error(f'Backup smoke test failed for {self.table_type} table: {str(e)}') + return results + + def _initiate_backup(self) -> tuple[str, str]: + """Initiate a backup job and return the job ID and recovery point ARN.""" + logger.info(f'Initiating backup for {self.table_type} table: {self.table_name}') + + # Get the backup role ARN + account_id = boto3.client('sts').get_caller_identity()['Account'] + backup_role_arn = f'arn:aws:iam::{account_id}:role/{self.backup_role_name}' + + try: + response = self.backup_client.start_backup_job( + BackupVaultName=self.local_vault_name, + ResourceArn=self.table_arn, + IamRoleArn=backup_role_arn, + StartWindowMinutes=480, + CompleteWindowMinutes=10080, + Lifecycle={'MoveToColdStorageAfterDays': 30, 'DeleteAfterDays': 365}, + RecoveryPointTags={ + 'Purpose': f'Smoke test {self.table_type} backup', + 'InitiatedBy': 'BackupSmokeTest', + 'TableType': self.table_type, + }, + ) + + backup_job_id = response['BackupJobId'] + recovery_point_arn = response['RecoveryPointArn'] + + logger.info(f'Backup job initiated - Job ID: {backup_job_id}, Recovery Point: {recovery_point_arn}') + return backup_job_id, recovery_point_arn + + except ClientError as e: + raise SmokeTestFailureException(f'Failed to initiate backup: {str(e)}') from e + + def _wait_for_backup_completion(self, backup_job_id: str, max_wait_minutes: int = 10) -> bool: + """Wait for backup job to complete and return success status.""" + logger.info(f'Waiting for backup job {backup_job_id} to complete (max {max_wait_minutes} minutes)') + + start_time = time.time() + max_wait_seconds = max_wait_minutes * 60 + + while time.time() - start_time < max_wait_seconds: + try: + response = self.backup_client.describe_backup_job(BackupJobId=backup_job_id) + job_state = response['State'] + + logger.info(f'Backup job {backup_job_id} state: {job_state}') + + if job_state == 'COMPLETED': + backup_size = response.get('BackupSizeInBytes', 0) + logger.info(f'Backup job completed successfully. Size: {backup_size} bytes') + return True + if job_state in ['FAILED', 'ABORTED', 'EXPIRED']: + status_message = response.get('StatusMessage', 'No status message') + logger.error(f'Backup job failed with state {job_state}: {status_message}') + return False + if job_state in ['CREATED', 'PENDING', 'RUNNING']: + # Job is still in progress + time.sleep(30) # Wait 30 seconds before checking again + continue + logger.warning(f'Unknown backup job state: {job_state}') + time.sleep(30) + + except ClientError as e: + logger.error(f'Error checking backup job status: {str(e)}') + return False + + logger.error(f'Backup job {backup_job_id} did not complete within {max_wait_minutes} minutes') + return False + + def _initiate_copy(self, recovery_point_arn: str) -> str: + """Initiate a cross-account copy job and return the copy job ID.""" + logger.info(f'Initiating cross-account copy for recovery point: {recovery_point_arn}') + + # Get the backup role ARN + account_id = boto3.client('sts').get_caller_identity()['Account'] + backup_role_arn = f'arn:aws:iam::{account_id}:role/{self.backup_role_name}' + + try: + response = self.backup_client.start_copy_job( + RecoveryPointArn=recovery_point_arn, + SourceBackupVaultName=self.local_vault_name, + DestinationBackupVaultArn=self.cross_account_vault_arn, + IamRoleArn=backup_role_arn, + Lifecycle={'MoveToColdStorageAfterDays': 30, 'DeleteAfterDays': 365}, + ) + + copy_job_id = response['CopyJobId'] + logger.info(f'Copy job initiated - Job ID: {copy_job_id}') + return copy_job_id + + except ClientError as e: + raise SmokeTestFailureException(f'Failed to initiate copy job: {str(e)}') from e + + def _wait_for_copy_completion(self, copy_job_id: str, max_wait_minutes: int = 15) -> bool: + """Wait for copy job to complete and return success status.""" + logger.info(f'Waiting for copy job {copy_job_id} to complete (max {max_wait_minutes} minutes)') + + start_time = time.time() + max_wait_seconds = max_wait_minutes * 60 + + while time.time() - start_time < max_wait_seconds: + try: + response = self.backup_client.describe_copy_job(CopyJobId=copy_job_id) + job_state = response['CopyJob']['State'] + + logger.info(f'Copy job {copy_job_id} state: {job_state}') + + if job_state == 'COMPLETED': + backup_size = response['CopyJob'].get('BackupSizeInBytes', 0) + destination_arn = response['CopyJob'].get('DestinationRecoveryPointArn', 'Unknown') + logger.info( + f'Copy job completed successfully. Size: {backup_size} bytes, Destination: {destination_arn}' + ) + return True + if job_state in ['FAILED', 'PARTIAL']: + status_message = response['CopyJob'].get('StatusMessage', 'No status message') + logger.error(f'Copy job failed with state {job_state}: {status_message}') + return False + if job_state in ['CREATED', 'RUNNING']: + # Job is still in progress + time.sleep(45) # Wait 45 seconds before checking again (copy jobs take longer) + continue + logger.warning(f'Unknown copy job state: {job_state}') + time.sleep(45) + + except ClientError as e: + logger.error(f'Error checking copy job status: {str(e)}') + return False + + logger.error(f'Copy job {copy_job_id} did not complete within {max_wait_minutes} minutes') + return False + + +def main(): + """Main function to run the backup smoke test.""" + parser = argparse.ArgumentParser( + description='Run backup operations smoke test for CompactConnect tables', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument('table_type', choices=['provider', 'ssn'], help='Type of table to test backup operations for') + parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose logging') + + args = parser.parse_args() + + if args.verbose: + import logging + + logging.getLogger().setLevel(logging.DEBUG) + + try: + # Initialize and run the backup test + backup_test = BackupSmokeTest(args.table_type) + results = backup_test.run_backup_test() + + # Log results using logger instead of print + logger.info('\n' + '=' * 60) + logger.info(f'BACKUP SMOKE TEST RESULTS - {args.table_type.upper()} TABLE') + logger.info('=' * 60) + logger.info(f'Table Name: {results["table_name"]}') + logger.info(f'Start Time: {results["start_time"]}') + logger.info(f'End Time: {results.get("end_time", "N/A")}') + logger.info(f'Overall Success: {results["success"]}') + + if results.get('error'): + logger.error(f'Error: {results["error"]}') + + logger.info('\nBackup Job:') + backup_job = results.get('backup_job', {}) + logger.info(f' Job ID: {backup_job.get("job_id", "N/A")}') + logger.info(f' Recovery Point ARN: {backup_job.get("recovery_point_arn", "N/A")}') + logger.info(f' Success: {backup_job.get("success", "N/A")}') + + logger.info('\nCopy Job:') + copy_job = results.get('copy_job', {}) + logger.info(f' Job ID: {copy_job.get("job_id", "N/A")}') + logger.info(f' Success: {copy_job.get("success", "N/A")}') + + logger.info('=' * 60) + + # Exit with appropriate code + sys.exit(0 if results['success'] else 1) + + except (SmokeTestFailureException, ValueError, FileNotFoundError) as e: + logger.error(f'Backup smoke test failed with exception: {str(e)}') + logger.error(f'\nERROR: {str(e)}') + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/tests/smoke/compact_configuration_smoke_tests.py b/backend/social-work-app/tests/smoke/compact_configuration_smoke_tests.py new file mode 100644 index 0000000000..0d62948d03 --- /dev/null +++ b/backend/social-work-app/tests/smoke/compact_configuration_smoke_tests.py @@ -0,0 +1,442 @@ +# ruff: noqa: T201 we use print statements for smoke testing +#!/usr/bin/env python3 +import json + +import requests +from botocore.exceptions import ClientError +from config import config +from smoke_common import ( + COMPACTS, + SmokeTestFailureException, + create_test_staff_user, + delete_test_staff_user, + get_api_base_url, + get_staff_user_auth_headers, + load_smoke_test_env, +) + +# This script is used to test the compact configuration functionality against a sandbox environment +# of the Compact Connect API. + +# To run this script, create a smoke_tests_env.json file in the same directory as this script using the +# 'smoke_tests_env_example.json' file as a template. + + +def cleanup_compact_configuration(compact: str): + """ + Clean up compact configuration record for testing using direct DynamoDB calls. + + :param compact: The compact abbreviation + """ + try: + # Delete the compact configuration record directly from DynamoDB + pk = f'{compact}#CONFIGURATION' + sk = f'{compact}#CONFIGURATION' + + config.compact_configuration_dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + print(f'Cleaned up compact configuration for {compact}') + + except ClientError as e: + print(f'Warning: Error cleaning up compact configuration for {compact}: {e}') + + +def cleanup_jurisdiction_configuration(compact: str, jurisdiction: str): + """ + Clean up jurisdiction configuration record for testing using direct DynamoDB calls. + + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction postal abbreviation + """ + try: + # Delete the jurisdiction configuration record directly from DynamoDB + pk = f'{compact}#CONFIGURATION' + sk = f'{compact}#JURISDICTION#{jurisdiction.lower()}' + + config.compact_configuration_dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + print(f'Cleaned up jurisdiction configuration for {jurisdiction} in {compact}') + + except ClientError as e: + print(f'Warning: Error cleaning up jurisdiction configuration for {jurisdiction} in {compact}: {e}') + + +def test_active_member_jurisdictions(): + """ + Test that the active member jurisdictions from cdk.json match the jurisdictions returned by the API. + + :raises SmokeTestFailureException: If the test fails + """ + print('Testing active member jurisdictions...') + + # Get active_compact_member_jurisdictions from cdk.json + with open('cdk.json') as context_file: + cdk_context = json.load(context_file)['context'] + active_member_jurisdictions = cdk_context.get('active_compact_member_jurisdictions', {}) + + if not active_member_jurisdictions: + raise SmokeTestFailureException('No active_compact_member_jurisdictions found in cdk.json') + + for compact in COMPACTS: + # Call the public endpoint to get active member jurisdictions + response = requests.get(url=f'{get_api_base_url()}/v1/public/compacts/{compact}/jurisdictions', timeout=10) + + if response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to GET active member jurisdictions for compact {compact}. Response: {response.json()}' + ) + + # Get the API response jurisdictions + api_jurisdictions = response.json() + api_jurisdiction_abbrs = [j['postalAbbreviation'].lower() for j in api_jurisdictions] + + # Get the expected jurisdictions from cdk.json + expected_jurisdictions = active_member_jurisdictions.get(compact, []) + expected_jurisdiction_abbrs = [j.lower() for j in expected_jurisdictions] + + # Verify that the active member jurisdictions match + if sorted(api_jurisdiction_abbrs) != sorted(expected_jurisdiction_abbrs): + raise SmokeTestFailureException( + f'Active member jurisdictions mismatch for compact {compact}. ' + f'Expected: {sorted(expected_jurisdiction_abbrs)}, ' + f'Got: {sorted(api_jurisdiction_abbrs)}' + ) + + print(f'Successfully verified active member jurisdictions for compact {compact}') + + +def test_compact_configuration(): + """ + Test that a compact admin can update and retrieve compact configuration. + + :return: The compact configuration response for use in other tests + :raises SmokeTestFailureException: If the test fails + """ + print('Testing compact configuration...') + + # Create a test compact admin user + compact = COMPACTS[0] # Use the first compact for testing + test_email = f'test-compact-admin-{compact}@ccSmokeTestFakeEmail.com' + permissions = {'actions': {'admin'}, 'jurisdictions': {}} + + user_sub = None + try: + # Create the test user + user_sub = create_test_staff_user( + email=test_email, + compact=compact, + jurisdiction=None, + permissions=permissions, + ) + + # Get auth headers for the test user + headers = get_staff_user_auth_headers(test_email) + + # Clean up any existing compact configuration from previous test runs + cleanup_compact_configuration(compact) + + # Create test compact configuration data + notification_email = config.smoke_test_notification_email + compact_config = { + 'licenseeRegistrationEnabled': False, + 'compactOperationsTeamEmails': [notification_email], + 'compactAdverseActionsNotificationEmails': [notification_email], + 'configuredStates': [], + } + + # PUT the compact configuration + put_response = requests.put( + url=f'{get_api_base_url()}/v1/compacts/{compact}', headers=headers, json=compact_config, timeout=10 + ) + + if put_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to PUT compact configuration for compact {compact}. Response: {put_response.json()}' + ) + + print(f'Successfully PUT compact configuration for {compact}') + + # now set the compact configuration licenseeRegistrationEnabled to true + # and verify that the compact configuration is updated + compact_config['licenseeRegistrationEnabled'] = True + compact_put_response = requests.put( + url=f'{get_api_base_url()}/v1/compacts/{compact}', headers=headers, json=compact_config, timeout=10 + ) + + if compact_put_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to PUT compact configuration to set licenseeRegistrationEnabled to true. ' + f'Response: {compact_put_response.json()}' + ) + + print('Successfully PUT compact configuration to set licenseeRegistrationEnabled to true') + + # GET the compact configuration + get_response = requests.get(url=f'{get_api_base_url()}/v1/compacts/{compact}', headers=headers, timeout=10) + + if get_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to GET compact configuration for compact {compact}. Response: {get_response.json()}' + ) + + # Verify that the configuration matches what we set + config_response = get_response.json() + + # the only fields not present in the original put are the compactAbbr and compactName + # which are set by the path parameters in the request and returned by the API + compact_config['compactAbbr'] = config_response['compactAbbr'] + compact_config['compactName'] = config_response['compactName'] + + # Compare the entire configuration at once + # Check if there are any differences between expected and actual configuration + differences = [] + for key, expected_value in compact_config.items(): + if key not in config_response: + differences.append(f'Missing key in response: {key}') + elif config_response[key] != expected_value: + differences.append(f'Value mismatch for {key}: Expected {expected_value}, Got {config_response[key]}') + + # Check for extra keys in the response + for key in config_response: + if key not in compact_config: + differences.append(f'Extra key in response: {key}') + + if differences: + raise SmokeTestFailureException('Configuration mismatch:\n' + '\n'.join(differences)) + + print(f'Successfully verified compact configuration for {compact}') + + # return the config response to be used in other tests + return config_response + + finally: + # Clean up the test user + if user_sub: + delete_test_staff_user(test_email, user_sub, compact) + + +def test_jurisdiction_configuration(jurisdiction: str = 'az', recreate_compact_config: bool = False): + """ + Test that a state admin can update and retrieve jurisdiction configuration. + + :return: The jurisdiction configuration response for use in other tests + :raises SmokeTestFailureException: If the test fails + """ + print('Testing jurisdiction configuration...') + + # Create a test state admin user with compact admin permissions for simplicity + compact = COMPACTS[0] # Use the first compact for testing + test_email = f'test-state-admin-{jurisdiction}@ccSmokeTestFakeEmail.com' + permissions = {'actions': {'admin'}, 'jurisdictions': {jurisdiction: {'admin'}}} + + user_sub = None + try: + # Create the test user + user_sub = create_test_staff_user( + email=test_email, + compact=compact, + jurisdiction=jurisdiction, + permissions=permissions, + ) + + # Get auth headers for the test user + headers = get_staff_user_auth_headers(test_email) + + if recreate_compact_config: + test_compact_configuration() + # Clean up any existing configurations from previous test runs + cleanup_jurisdiction_configuration(compact, jurisdiction) + + # Get license types for the compact + with open('cdk.json') as context_file: + cdk_context = json.load(context_file)['context'] + license_types = cdk_context.get('license_types', {}).get(compact, []) + + if not license_types: + raise SmokeTestFailureException(f'No license types found for compact {compact}') + + # Create test jurisdiction configuration data + notification_email = config.smoke_test_notification_email + jurisdiction_config = { + 'jurisdictionOperationsTeamEmails': [notification_email], + 'jurisdictionAdverseActionsNotificationEmails': [notification_email], + 'licenseeRegistrationEnabled': True, + } + + # PUT the jurisdiction configuration + put_response = requests.put( + url=f'{get_api_base_url()}/v1/compacts/{compact}/jurisdictions/{jurisdiction}', + headers=headers, + json=jurisdiction_config, + timeout=10, + ) + + if put_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to PUT jurisdiction configuration for {jurisdiction} in {compact}. ' + f'Response: {put_response.json()}' + ) + + print(f'Successfully PUT jurisdiction configuration for {jurisdiction} in {compact}') + + # GET the jurisdiction configuration + get_response = requests.get( + url=f'{get_api_base_url()}/v1/compacts/{compact}/jurisdictions/{jurisdiction}', headers=headers, timeout=10 + ) + + if get_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to GET jurisdiction configuration for {jurisdiction} in {compact}. ' + f'Response: {get_response.json()}' + ) + + # Verify that the configuration matches what we set + config_response = get_response.json() + + # Add the fields that are returned by the API but not set in our request + jurisdiction_config['jurisdictionName'] = config_response['jurisdictionName'] + jurisdiction_config['compact'] = config_response['compact'] + jurisdiction_config['postalAbbreviation'] = config_response['postalAbbreviation'] + + # Compare the entire configuration objects + if jurisdiction_config != config_response: + # Find differences for better error reporting + differences = [] + for key in set(jurisdiction_config.keys()) | set(config_response.keys()): + if key not in jurisdiction_config: + differences.append(f"Key '{key}' missing in request but present in response") + elif key not in config_response: + differences.append(f"Key '{key}' present in request but missing in response") + elif jurisdiction_config[key] != config_response[key]: + differences.append( + f"Value mismatch for '{key}': Expected: {jurisdiction_config[key]}, Got: {config_response[key]}" + ) + + raise SmokeTestFailureException( + f'Jurisdiction configuration mismatch for {jurisdiction} in {compact}. ' + f'Differences: {", ".join(differences)}' + ) + + print(f'Successfully verified jurisdiction configuration for {jurisdiction} in {compact}') + + # Verify that the jurisdiction was automatically added to the compact's configuredStates + # since we set licenseeRegistrationEnabled: True + print(f'Verifying that {jurisdiction} was added to compact configuredStates...') + + # Use the same user (which also has compact admin permissions) to check the compact configuration + compact_get_response = requests.get( + url=f'{get_api_base_url()}/v1/compacts/{compact}', headers=headers, timeout=10 + ) + + if compact_get_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to GET compact configuration for verification. Response: {compact_get_response.json()}' + ) + + compact_config_data = compact_get_response.json() + configured_states = compact_config_data.get('configuredStates', []) + + # Check if our jurisdiction was added with isLive: False + jurisdiction_found = False + for state in configured_states: + if state.get('postalAbbreviation') == jurisdiction.lower(): + jurisdiction_found = True + if state.get('isLive') is not False: + raise SmokeTestFailureException( + f'Expected jurisdiction {jurisdiction} to have isLive: false in configuredStates, ' + f'but got isLive: {state.get("isLive")}' + ) + break + + if not jurisdiction_found: + raise SmokeTestFailureException( + f'Expected jurisdiction {jurisdiction} to be automatically added to configuredStates ' + f'when licenseeRegistrationEnabled was set to true, but it was not found. ' + f'configuredStates: {configured_states}' + ) + + print(f'Successfully verified that {jurisdiction} was added to configuredStates with isLive: false') + + # Now set the jurisdiction to live for use by other smoke tests + print(f'Setting {jurisdiction} to live in configuredStates...') + + # Update the configuredStates to set our jurisdiction to live + updated_configured_states = [] + for state in configured_states: + if state.get('postalAbbreviation') == jurisdiction.lower(): + # Set this jurisdiction to live + updated_state = state.copy() + updated_state['isLive'] = True + updated_configured_states.append(updated_state) + else: + updated_configured_states.append(state) + + # Prepare the compact configuration update with the jurisdiction set to live + compact_config_data['configuredStates'] = updated_configured_states + + # remove fields not expected by PUT endpoint + del compact_config_data['compactName'] + del compact_config_data['compactAbbr'] + + # PUT the updated compact configuration + compact_put_response = requests.put( + url=f'{get_api_base_url()}/v1/compacts/{compact}', + headers=headers, + json=compact_config_data, + timeout=10, + ) + + if compact_put_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to PUT compact configuration to set {jurisdiction} to live. ' + f'Response: {compact_put_response.json()}' + ) + + print(f'Successfully updated compact configuration to set {jurisdiction} to live') + + # Get compact config one last time to verify state is now set to live + compact_get_response = requests.get( + url=f'{get_api_base_url()}/v1/compacts/{compact}', headers=headers, timeout=10 + ) + + if compact_get_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to GET compact configuration for verification. Response: {compact_get_response.json()}' + ) + + compact_config_data = compact_get_response.json() + configured_states = compact_config_data.get('configuredStates', []) + + # now verify that the jurisdiction is live + jurisdiction_found = False + for state in configured_states: + if state.get('postalAbbreviation') == jurisdiction.lower(): + jurisdiction_found = True + if not state.get('isLive'): + raise SmokeTestFailureException( + f'Expected jurisdiction {jurisdiction} to have isLive: true in configuredStates, ' + f'but got isLive: {state.get("isLive")}' + ) + break + + if not jurisdiction_found: + raise SmokeTestFailureException( + f'Expected jurisdiction {jurisdiction} to be live in configuredStates, ' + f'but it was not found. configuredStates: {configured_states}' + ) + + print(f'Successfully verified that {jurisdiction} is live in configuredStates') + + # return the config response to be used in other tests + return config_response + finally: + # Clean up the test user + if user_sub: + delete_test_staff_user(test_email, user_sub, compact) + + +if __name__ == '__main__': + load_smoke_test_env() + test_active_member_jurisdictions() + test_compact_configuration() + # for the smoke tests, we set two jurisdictions to live for license and privilege smoke tests + test_jurisdiction_configuration('az') + test_jurisdiction_configuration('al') diff --git a/backend/social-work-app/tests/smoke/config.py b/backend/social-work-app/tests/smoke/config.py new file mode 100644 index 0000000000..a86ee8bde1 --- /dev/null +++ b/backend/social-work-app/tests/smoke/config.py @@ -0,0 +1,91 @@ +import json +import logging +import os +from functools import cached_property + +import boto3 +from aws_lambda_powertools import Logger + +logging.basicConfig() +logger = Logger() +logger.setLevel(logging.DEBUG if os.environ.get('DEBUG', 'false').lower() == 'true' else logging.INFO) + + +class _Config: + @property + def api_base_url(self): + return os.environ['CC_TEST_API_BASE_URL'] + + @property + def state_api_base_url(self): + return os.environ['CC_TEST_STATE_API_BASE_URL'] + + @property + def state_auth_url(self): + return os.environ['CC_TEST_STATE_AUTH_URL'] + + @property + def cognito_state_auth_user_pool_id(self): + return os.environ['CC_TEST_COGNITO_STATE_AUTH_USER_POOL_ID'] + + @property + def environment_name(self): + return os.environ['ENVIRONMENT_NAME'] + + @property + def aws_region(self): + return os.environ['AWS_DEFAULT_REGION'] + + @property + def license_upload_rollback_step_function_arn(self): + return os.environ['CC_TEST_ROLLBACK_STEP_FUNCTION_ARN'] + + @property + def provider_user_dynamodb_table(self): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_PROVIDER_DYNAMO_TABLE_NAME']) + + @property + def data_events_dynamodb_table(self): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME']) + + @property + def staff_users_dynamodb_table(self): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME']) + + @property + def compact_configuration_dynamodb_table(self): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_COMPACT_CONFIGURATION_DYNAMO_TABLE_NAME']) + + @property + def compact_configuration_table_name(self): + return os.environ['CC_TEST_COMPACT_CONFIGURATION_DYNAMO_TABLE_NAME'] + + @property + def cognito_staff_user_client_id(self): + return os.environ['CC_TEST_COGNITO_STAFF_USER_POOL_CLIENT_ID'] + + @property + def cognito_staff_user_pool_id(self): + return os.environ['CC_TEST_COGNITO_STAFF_USER_POOL_ID'] + + @property + def test_provider_id(self): + return os.environ['CC_TEST_PROVIDER_ID'] + + @property + def smoke_test_notification_email(self): + return os.environ['CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL'] + + @cached_property + def cognito_client(self): + return boto3.client('cognito-idp') + + +def load_smoke_test_env(): + with open(os.path.join(os.path.dirname(__file__), 'smoke_tests_env.json')) as env_file: + env_vars = json.load(env_file) + os.environ.update(env_vars) + + +load_smoke_test_env() +config = _Config() diff --git a/backend/social-work-app/tests/smoke/cosm-al-mock-licenses.csv b/backend/social-work-app/tests/smoke/cosm-al-mock-licenses.csv new file mode 100644 index 0000000000..b6d4649a68 --- /dev/null +++ b/backend/social-work-app/tests/smoke/cosm-al-mock-licenses.csv @@ -0,0 +1,21 @@ +ssn,licenseNumber,licenseType,licenseStatus,licenseStatusName,compactEligibility,givenName,middleName,familyName,suffix,dateOfIssuance,dateOfRenewal,dateOfExpiration,dateOfBirth,homeAddressStreet1,homeAddressStreet2,homeAddressCity,homeAddressState,homeAddressPostalCode,emailAddress,phoneNumber +000-00-0000,5E-069O0ZC,esthetician,active,ACTIVE_IN_RENEWAL,eligible,Jill,David,Miles,,2017-10-10,2025-07-30,2026-07-30,1983-11-22,5462 Nguyen Island,,North Sean,al,36735,kevin51@example.net,+11407622081 +000-00-0001,3PK315088A,cosmetologist,active,ACTIVE,eligible,Robert,Leah,Bean,,1993-03-24,2026-03-03,2027-03-03,1969-12-01,468 Clinton Way Suite 834,,East Rose,al,36037,erinhunt@example.net,+14039797941 +000-00-0002,52C5W157,esthetician,active,ACTIVE_IN_RENEWAL,eligible,Angela,Juan,Haynes,,1992-09-21,2026-01-23,2027-01-23,1960-06-06,42862 Chris Club Suite 397,,North Jennifer,al,36604,,+16723238353 +000-00-0003,V78XD6--O9236,esthetician,active,ACTIVE_IN_RENEWAL,eligible,Brad,Michelle,Dickerson,,1951-12-01,2026-01-25,2027-01-25,1928-02-06,24259 Kyle Alley,Apt. 539,Watkinsport,al,35446,julian22@example.net,+11123093698 +000-00-0004,4W85177K95,esthetician,active,ACTIVE,eligible,Gregory,Michele,Reeves,,2021-06-07,2026-03-09,2027-03-09,1996-06-07,8857 Caitlin Ranch,Apt. 332,Connerbury,al,35046,,+19286855165 +000-00-0005,-2VS8V06-7-2,cosmetologist,active,ACTIVE,eligible,Adam,Stephen,Sawyer,,2010-09-02,2025-03-19,2026-03-19,1985-08-13,04144 Kyle Skyway,Apt. 437,Richardsfort,al,36585,kendraabbott@example.org,+13877233850 +000-00-0006,D2501XAAMJ,esthetician,active,,eligible,Matthew,Tracy,Wilson,,2010-09-07,2025-06-14,2026-06-14,1972-03-06,4394 Alison Plaza Apt. 227,Apt. 238,East Kevinstad,al,35024,lindapatterson@example.net,+15169695462 +000-00-0007,28R-7DU415E0243G7BA,esthetician,active,,eligible,James,Emily,Carter,,1981-07-08,2025-10-22,2026-10-22,1956-09-10,120 Torres Bridge,Apt. 627,South Mistyhaven,al,36712,,+17777102362 +000-00-0008,S4-1KB,cosmetologist,active,ACTIVE,eligible,Veronica,Tyrone,Doyle,,2026-02-28,2026-02-28,2027-02-28,1995-10-04,89914 Copeland Crossroad,Apt. 972,West Leslieton,al,35760,,+13240716278 +000-00-0009,2-P-76U52-L01,esthetician,active,ACTIVE_IN_RENEWAL,eligible,Elizabeth,Margaret,Reeves,,1986-08-20,2026-03-09,2027-03-09,1952-10-17,95612 Hurst Corners,,Juanport,al,35943,,+17016289648 +000-00-0010,7UW92YL,esthetician,active,ACTIVE,eligible,Patricia,Arthur,Green,,2026-03-16,2026-03-16,2027-03-16,1994-11-14,699 Miller Light Apt. 700,Suite 171,Jonesburgh,al,35545,,+13858142717 +000-00-0011,F9W-3,esthetician,active,,eligible,Phillip,Phillip,Baird,,2026-03-16,2026-03-16,2027-03-16,2021-09-25,3877 Timothy Mission Suite 650,Apt. 842,Manuelchester,al,36510,,+15751990790 +000-00-0012,Q919KAM3--J16-,cosmetologist,active,ACTIVE,eligible,Andrew,Lindsay,Watts,,2026-03-16,2026-03-16,2027-03-16,1989-07-29,519 Small Camp Apt. 226,,Lake Meghanville,al,35336,jenniferwagner@example.net,+11000703911 +000-00-0013,246BLC--8B---B-D99,esthetician,active,ACTIVE,eligible,Randall,Maria,Forbes,,1960-08-28,2025-12-26,2026-12-26,1932-10-22,3331 Mary Terrace Suite 043,,Jonesbury,al,35944,,+15795699221 +000-00-0014,S01-17X53-F982N348,esthetician,active,ACTIVE,eligible,Morgan,Heather,Brown,,2012-07-26,2025-05-19,2026-05-19,1987-10-25,892 Shaffer Ridge,Suite 360,West Kelly,al,36076,,+16620969797 +000-00-0015,7Z1-C81I26Z--983,esthetician,active,,eligible,John,Arthur,Berry,,1980-11-28,2025-09-21,2026-09-21,1955-04-16,9560 Cindy Ford Suite 730,Apt. 014,East Chrischester,al,36791,,+12575189497 +000-00-0016,0-33-90-217C8SL9,esthetician,active,ACTIVE_IN_RENEWAL,eligible,Eric,Jessica,Lambert,,1994-11-05,2025-03-21,2026-03-21,1962-05-14,92179 Hernandez Parkways,Suite 769,Pattersonchester,al,36851,,+16760795876 +000-00-0017,6-33-J30P,esthetician,active,,eligible,Wendy,Selena,Thompson,,2026-03-16,2026-03-16,2027-03-16,2023-12-28,125 Maria Manors,,South Rebecca,al,35681,,+16041464783 +000-00-0018,L-L2OP98,esthetician,active,ACTIVE,eligible,Bradley,Jennifer,Mueller,,2026-03-16,2026-03-16,2027-03-16,2002-10-09,3557 Gonzalez Ramp Suite 266,,North Thomas,al,36902,brownkenneth@example.net,+14886195207 +000-00-0019,-50XT0KK445B-6,esthetician,active,,eligible,Jeremy,Melanie,Colon,,2026-03-16,2026-03-16,2027-03-16,2020-07-15,0893 Stephen Pike Apt. 049,,North Anthonyside,al,35446,kayla62@example.com,+17764987123 diff --git a/backend/social-work-app/tests/smoke/encumbrance_smoke_tests.py b/backend/social-work-app/tests/smoke/encumbrance_smoke_tests.py new file mode 100644 index 0000000000..b63740c993 --- /dev/null +++ b/backend/social-work-app/tests/smoke/encumbrance_smoke_tests.py @@ -0,0 +1,775 @@ +#!/usr/bin/env python3 +""" +Smoke tests for encumbrance functionality. + +This script tests the end-to-end encumbrance workflow for both licenses and privileges, +including setting encumbrances and lifting them through the API endpoints. + +This script assumes your test environment has a live jurisdiction for generating at least one privilege +record. You can set the value of the live jurisdiction in the LIVE_JURISDICTION constant +""" + +import time + +import requests +from smoke_common import ( + SmokeTestFailureException, + call_provider_details_endpoint, + config, + create_test_staff_user, + delete_test_staff_user, + get_all_provider_database_records, + get_provider_user_dynamodb_table, + get_provider_user_records, + get_staff_user_auth_headers, + load_smoke_test_env, + logger, +) + +ENCUMBRANCE_SMOKE_COMPACT = 'socw' +LIVE_JURISDICTION = 'az' + + +def clean_adverse_actions(): + """ + Clean up any existing adverse action records for the provider to start in a clean state. + """ + logger.info('Cleaning up existing adverse action records...') + + all_records = get_all_provider_database_records(ENCUMBRANCE_SMOKE_COMPACT, config.test_provider_id) + + # Filter for adverse action records + adverse_action_records = [record for record in all_records if record.get('type') == 'adverseAction'] + + if not adverse_action_records: + logger.info('No adverse action records found to clean up') + return + + # Delete each adverse action record + dynamodb_table = get_provider_user_dynamodb_table() + for record in adverse_action_records: + pk = record['pk'] + sk = record['sk'] + logger.info(f'Deleting adverse action record: {pk} / {sk}') + dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + + logger.info(f'Cleaned up {len(adverse_action_records)} adverse action records') + + +def _remove_encumbered_status_from_license_and_provider(): + all_records = get_all_provider_database_records(ENCUMBRANCE_SMOKE_COMPACT, config.test_provider_id) + + for record in all_records: + if record.get('type') == 'license' or record.get('type') == 'provider': + if record.get('encumberedStatus') == 'encumbered': + logger.info( + f'Removing encumbered status from {record.get("type")} {record.get("pk")} / {record.get("sk")}' + ) + dynamodb_table = get_provider_user_dynamodb_table() + dynamodb_table.update_item( + Key={'pk': record['pk'], 'sk': record['sk']}, + UpdateExpression='SET encumberedStatus = :unencumbered', + ExpressionAttributeValues={':unencumbered': 'unencumbered'}, + ) + + +def setup_test_environment(): + """ + Set up the test environment by cleaning any previous adverse actions. + """ + logger.info('Setting up test environment...') + + clean_adverse_actions() + + # remove encumbered status from license and provider if present + _remove_encumbered_status_from_license_and_provider() + + logger.info('Test environment setup complete') + + +class EncumbranceTestHelper: + """Helper class to manage encumbrance test operations with pre-configured staff users and URLs.""" + + def __init__(self): + """ + Initialize the helper with provider data and set up all necessary resources. + """ + # Get provider data + self.compact = ENCUMBRANCE_SMOKE_COMPACT + self.provider_id = config.test_provider_id + + # Get jurisdiction information from privilege + # Query database directly for privilege records + provider_user_records = get_provider_user_records(self.compact, self.provider_id) + + # Get license record + provider_license = provider_user_records.find_best_license_in_current_known_licenses() + + if not provider_license: + raise SmokeTestFailureException('License not found for provider') + + self.privilege_jurisdiction = LIVE_JURISDICTION + self.license_jurisdiction = provider_license.jurisdiction + self.license_type = provider_license.licenseType + self.license_type_abbreviation = provider_license.licenseTypeAbbreviation + + # Track created users for cleanup + self.created_staff_users = [] + + # Create staff users for both jurisdictions + self.privilege_jurisdiction_staff_user = self._create_privilege_jurisdiction_staff_user() + self.license_jurisdiction_staff_user = self._create_license_jurisdiction_staff_user() + + def _create_privilege_jurisdiction_staff_user(self) -> dict: + """Create and return privilege jurisdiction staff user info.""" + email, user_sub = self._create_test_staff_user_for_encumbrance(self.compact, self.privilege_jurisdiction) + headers = get_staff_user_auth_headers(email) + + # Track for cleanup + self.created_staff_users.append((email, user_sub, self.compact)) + + return {'email': email, 'user_sub': user_sub, 'headers': headers} + + def _create_license_jurisdiction_staff_user(self) -> dict: + """Create and return license jurisdiction staff user info.""" + email, user_sub = self._create_test_staff_user_for_encumbrance(self.compact, self.license_jurisdiction) + headers = get_staff_user_auth_headers(email) + + # Track for cleanup + self.created_staff_users.append((email, user_sub, self.compact)) + + return {'email': email, 'user_sub': user_sub, 'headers': headers} + + def get_provider_details(self) -> dict: + """Get provider details for the smoke-test provider.""" + return call_provider_details_endpoint( + self.get_license_staff_admin_headers(), + self.compact, + self.provider_id, + ) + + def get_privilege_staff_admin_headers(self) -> dict: + """Get authentication headers for privilege jurisdiction staff user.""" + return self.privilege_jurisdiction_staff_user['headers'] + + def get_license_staff_admin_headers(self) -> dict: + """Get authentication headers for license jurisdiction staff user.""" + return self.license_jurisdiction_staff_user['headers'] + + def encumber_license(self, request_body: dict) -> dict: + """ + Encumber the license. + """ + return self._call_license_encumbrance_endpoint(request_body) + + def encumber_privilege(self, request_body: dict) -> dict: + """ + Encumber the privilege. + """ + return self._call_privilege_encumbrance_endpoint(request_body) + + def lift_license_encumbrance(self, request_body: dict, encumbrance_id: str) -> dict: + """ + Lift the license. + """ + return self._call_license_encumbrance_endpoint(request_body, encumbrance_id) + + def lift_privilege_encumbrance(self, request_body: dict, encumbrance_id: str) -> dict: + """ + Lift the privilege. + """ + return self._call_privilege_encumbrance_endpoint(request_body, encumbrance_id) + + def _call_license_encumbrance_endpoint(self, request_body: dict, encumbrance_id: str = None) -> dict: + """ + Call the license encumbrance endpoint and verify 200 status. + + :param request_body: The request body for the API call + :param encumbrance_id: Optional encumbrance ID for PATCH operations (lifting) + + :returns: The response JSON + + :raises: SmokeTestFailureException`: If the API call fails + """ + url = self._generate_license_encumbrance_url(encumbrance_id) + + if encumbrance_id: + # PATCH operation for lifting encumbrance + response = requests.patch( + url, headers=self.get_license_staff_admin_headers(), json=request_body, timeout=10 + ) + else: + # POST operation for creating encumbrance + response = requests.post(url, headers=self.get_license_staff_admin_headers(), json=request_body, timeout=10) + + if response.status_code != 200: + operation = 'lift' if encumbrance_id else 'create' + raise SmokeTestFailureException(f'Failed to {operation} license encumbrance. Response: {response.json()}') + + return response.json() + + def _call_privilege_encumbrance_endpoint(self, request_body: dict, encumbrance_id: str = None) -> dict: + """ + Call the privilege encumbrance endpoint and verify 200 status. + + :param request_body: The request body for the API call + :param encumbrance_id: Optional encumbrance ID for PATCH operations (lifting) + + :returns: The response JSON + + :raises: SmokeTestFailureException`: If the API call fails + """ + url = self._generate_privilege_encumbrance_url(encumbrance_id) + + if encumbrance_id: + # PATCH operation for lifting encumbrance + response = requests.patch( + url, headers=self.get_privilege_staff_admin_headers(), json=request_body, timeout=10 + ) + else: + # POST operation for creating encumbrance + response = requests.post( + url, headers=self.get_privilege_staff_admin_headers(), json=request_body, timeout=10 + ) + + if response.status_code != 200: + operation = 'lift' if encumbrance_id else 'create' + raise SmokeTestFailureException(f'Failed to {operation} privilege encumbrance. Response: {response.json()}') + + return response.json() + + def validate_license_encumbered_state(self, expected_status: str = 'encumbered'): + """Validate license encumbered status and related fields.""" + # Get all provider records directly from DynamoDB + provider_user_records = get_provider_user_records(self.compact, self.provider_id) + + # Get the specific license record + license_record = provider_user_records.get_specific_license_record( + self.license_jurisdiction, self.license_type_abbreviation + ) + + if not license_record: + raise SmokeTestFailureException('License not found after encumbrance operation') + + actual_status = license_record.encumberedStatus + if actual_status != expected_status: + raise SmokeTestFailureException( + f"License encumberedStatus should be '{expected_status}', got: {actual_status}" + ) + + return license_record + + def validate_provider_encumbered_state(self, expected_status: str = 'encumbered'): + """Validate provider encumbered status.""" + # Get all provider records directly from DynamoDB + provider_user_records = get_provider_user_records(self.compact, self.provider_id) + provider_record = provider_user_records.get_provider_record() + + if provider_record.encumberedStatus != expected_status: + raise SmokeTestFailureException( + f"Provider encumberedStatus should be '{expected_status}', got: {provider_record.encumberedStatus}" + ) + + def get_license_adverse_actions(self): + """Get all license adverse actions for this provider.""" + # Get all provider records directly from DynamoDB + provider_user_records = get_provider_user_records(self.compact, self.provider_id) + + # Get adverse actions for this specific license + adverse_actions = provider_user_records.get_adverse_action_records_for_license( + self.license_jurisdiction, self.license_type_abbreviation + ) + + # Convert to dict format for compatibility with existing code + return [aa.to_dict() for aa in adverse_actions] + + def get_privilege_adverse_actions(self): + """Get all privilege adverse actions for this provider.""" + # Get all provider records directly from DynamoDB + provider_user_records = get_provider_user_records(self.compact, self.provider_id) + + # Get adverse actions for this specific privilege + adverse_actions = provider_user_records.get_adverse_action_records_for_privilege( + self.privilege_jurisdiction, self.license_type_abbreviation + ) + + # Convert to dict format for compatibility with existing code + return [aa.to_dict() for aa in adverse_actions] + + @staticmethod + def verify_adverse_action_matches_request(adverse_actions: list[dict], request_payload: dict) -> dict: + """ + Verify that at least one adverse action matches the request payload. + + :param adverse_actions: List of adverse action records + :param request_payload: The request payload that was sent (containing encumbranceType and + clinicalPrivilegeActionCategories) + :return: The matching adverse action record + :raises SmokeTestFailureException: If no matching adverse action is found + """ + expected_encumbrance_type = request_payload.get('encumbranceType') + expected_categories = set(request_payload.get('clinicalPrivilegeActionCategories', [])) + + logger.info( + f'Verifying adverse action matches request: encumbranceType={expected_encumbrance_type}, ' + f'categories={expected_categories}' + ) + + matching_actions = [] + for adverse_action in adverse_actions: + action_type = adverse_action.get('encumbranceType') + action_categories = set(adverse_action.get('clinicalPrivilegeActionCategories', [])) + + if action_type == expected_encumbrance_type and action_categories == expected_categories: + matching_actions.append(adverse_action) + + if not matching_actions: + raise SmokeTestFailureException( + f'No adverse action found matching request payload. ' + f'Expected encumbranceType="{expected_encumbrance_type}" and ' + f'categories={expected_categories}. ' + f'Found adverse actions: {adverse_actions}' + ) + + logger.info(f'✅ Found {len(matching_actions)} matching adverse action(s)') + return matching_actions[0] + + def verify_license_adverse_action_matches_request(self, request_payload: dict) -> dict: + """ + Verify that a license adverse action matches the request payload. + + :param request_payload: The request payload that was sent + :return: The matching adverse action record + """ + license_adverse_actions = self.get_license_adverse_actions() + return self.verify_adverse_action_matches_request(license_adverse_actions, request_payload) + + def verify_privilege_adverse_action_matches_request(self, request_payload: dict) -> dict: + """ + Verify that a privilege adverse action matches the request payload. + + :param request_payload: The request payload that was sent + :return: The matching adverse action record + """ + privilege_adverse_actions = self.get_privilege_adverse_actions() + return self.verify_adverse_action_matches_request(privilege_adverse_actions, request_payload) + + def get_privilege_adverse_action_by_id(self, adverse_action_id: str): + privilege_adverse_actions = self.get_privilege_adverse_actions() + matching_actions = [aa for aa in privilege_adverse_actions if aa['adverseActionId'] == adverse_action_id] + if not matching_actions: + raise SmokeTestFailureException(f'No matching adverse action found for ID: {adverse_action_id}') + return matching_actions[0] + + def verify_privilege_adverse_action_not_lifted(self, adverse_action_id: str) -> None: + """ + Verify that a privilege adverse action has not been lifted. + + :param adverse_action_id: The id of the adverse action + :return: The matching adverse action record + """ + matching_adverse_action = self.get_privilege_adverse_action_by_id(adverse_action_id) + lift_date = matching_adverse_action.get('effectiveLiftDate') + if lift_date is not None: + raise SmokeTestFailureException( + f'Adverse action has unexpected lift date for ID: {adverse_action_id}. effectiveLiftDate: {lift_date}' + ) + + def verify_privilege_adverse_action_lifted(self, adverse_action_id: str) -> None: + """ + Verify that a privilege adverse action has been lifted. + + :param adverse_action_id: The id of the adverse action + :return: The matching adverse action record + """ + matching_adverse_action = self.get_privilege_adverse_action_by_id(adverse_action_id) + lift_date = matching_adverse_action.get('effectiveLiftDate') + if lift_date is None: + raise SmokeTestFailureException(f'Adverse action is missing expected lift date for ID:{adverse_action_id}') + + def _generate_license_encumbrance_url(self, encumbrance_id: str = None): + """Generate license encumbrance URL.""" + base_url = ( + f'{config.api_base_url}/v1/compacts/{self.compact}/providers/{self.provider_id}' + f'/licenses/jurisdiction/{self.license_jurisdiction}/licenseType/{self.license_type_abbreviation}/encumbrance' + ) + if encumbrance_id: + return f'{base_url}/{encumbrance_id}' + return base_url + + def _generate_privilege_encumbrance_url(self, encumbrance_id: str = None): + """Generate privilege encumbrance URL.""" + base_url = ( + f'{config.api_base_url}/v1/compacts/{self.compact}/providers/{self.provider_id}' + f'/privileges/jurisdiction/{self.privilege_jurisdiction}/licenseType/{self.license_type_abbreviation}/encumbrance' + ) + if encumbrance_id: + return f'{base_url}/{encumbrance_id}' + return base_url + + def _create_test_staff_user_for_encumbrance(self, compact: str, jurisdiction: str): + """Create a test staff user with permissions to create and lift encumbrances.""" + email = f'test-encumbrance-admin-{jurisdiction}@ccSmokeTestFakeEmail.com' + user_sub = create_test_staff_user( + email=email, + compact=compact, + jurisdiction=jurisdiction, + permissions={'actions': {}, 'jurisdictions': {jurisdiction: {'admin'}}}, + ) + return email, user_sub + + def wait_for_downstream_processing(self, total_periods: int = 6, period_length: int = 10): + """Wait for downstream processing to complete between operations.""" + for i in range(total_periods): + logger.info(f'pausing for downstream processing to complete: {i + 1}/{total_periods}') + time.sleep(period_length) + + def cleanup_staff_users(self): + """Clean up all created staff users.""" + for email, user_sub, compact in self.created_staff_users: + try: + delete_test_staff_user(email, user_sub, compact) + except Exception as e: # noqa: BLE001 + logger.warning(f'Failed to clean up staff user {email}: {e}') + self.created_staff_users.clear() + + +def test_license_encumbrance_workflow(): + """ + Test the complete license encumbrance workflow: + 1. Encumber a license twice + 2. Encumber privilege and the associated adverse action record is created + 3. Lift one encumbrance (license should remain encumbered) + 4. Lift the final encumbrance (license should become unencumbered) + 5. Verify that the associated privilege is still encumbered (associated adverse action record is not lifted) + 6. Lift encumbrance from privilege + 7. Verify privilege is unencumbered + """ + logger.info('Starting license encumbrance workflow test...') + # remove adverse action records from previous tests + clean_adverse_actions() + helper = EncumbranceTestHelper() + + try: + # Step 1: Encumber the license twice + logger.info('Step 1: Encumbering license two times...') + + encumbrance_body = { + 'encumbranceEffectiveDate': '2024-11-11', + 'encumbranceType': 'surrender of license', + 'clinicalPrivilegeActionCategories': ['fraud'], + } + + # First encumbrance + helper.encumber_license(encumbrance_body) + logger.info('First license encumbrance created successfully') + + # Verify provider state after first encumbrance + provider_user_records = get_provider_user_records(helper.compact, helper.provider_id) + updated_license = provider_user_records.get_specific_license_record( + helper.license_jurisdiction, helper.license_type_abbreviation + ) + if not updated_license: + raise SmokeTestFailureException('License not found after encumbrance') + + if updated_license.encumberedStatus != 'encumbered': + raise SmokeTestFailureException( + f"License encumberedStatus should be 'encumbered', got: {updated_license.encumberedStatus}" + ) + + if updated_license.compactEligibility != 'ineligible': + raise SmokeTestFailureException( + f"License compactEligibility should be 'ineligible', got: {updated_license.compactEligibility}" + ) + + if updated_license.licenseStatus != 'inactive': + raise SmokeTestFailureException( + f"License licenseStatus should be 'inactive', got: {updated_license.licenseStatus}" + ) + + # Check provider status + provider_record = provider_user_records.get_provider_record() + if provider_record.encumberedStatus != 'encumbered': + raise SmokeTestFailureException( + f"Provider encumberedStatus should be 'encumbered', got: {provider_record.encumberedStatus}" + ) + + if provider_record.compactEligibility != 'ineligible': + raise SmokeTestFailureException( + f"Provider compactEligibility should be 'ineligible', got: {provider_record.compactEligibility}" + ) + + # Verify adverse action exists + license_adverse_actions = helper.get_license_adverse_actions() + if len(license_adverse_actions) != 1: + raise SmokeTestFailureException(f'Expected 1 license adverse action, found: {len(license_adverse_actions)}') + + first_adverse_action_id = license_adverse_actions[0]['adverseActionId'] + + # Verify the adverse action matches the request payload + helper.verify_license_adverse_action_matches_request(encumbrance_body) + logger.info('First license encumbrance verified successfully') + + # Second encumbrance + second_encumbrance_body = { + 'encumbranceEffectiveDate': '2025-01-01', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['consumer harm'], + } + helper.encumber_license(second_encumbrance_body) + logger.info('Second license encumbrance created successfully') + + # Verify we now have two adverse actions + license_adverse_actions = helper.get_license_adverse_actions() + if len(license_adverse_actions) != 2: + raise SmokeTestFailureException( + f'Expected 2 license adverse actions, found: {len(license_adverse_actions)}' + ) + + second_adverse_action_id = next( + aa['adverseActionId'] for aa in license_adverse_actions if aa['adverseActionId'] != first_adverse_action_id + ) + + # Verify the second adverse action matches the request payload + helper.verify_license_adverse_action_matches_request(second_encumbrance_body) + logger.info('Second license encumbrance verified successfully') + + # Step 2: Encumber privilege + privilege_encumbrance_body = { + 'encumbranceEffectiveDate': '2025-05-09', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['other'], + } + + helper.encumber_privilege(privilege_encumbrance_body) + logger.info('Privilege encumbrance created successfully') + + # Verify the privilege adverse action matches the request payload + helper.verify_privilege_adverse_action_matches_request(privilege_encumbrance_body) + logger.info('Privilege encumbrance verified successfully') + + # Step 3: Lift first encumbrance (license should remain encumbered) + logger.info('Step 3: Lifting first license encumbrance...') + + lift_body = { + 'effectiveLiftDate': '2025-05-05', + } + + helper.lift_license_encumbrance(lift_body, first_adverse_action_id) + logger.info('First license encumbrance lifted successfully') + + # Verify license is still encumbered + helper.validate_license_encumbered_state('encumbered') + + logger.info('Verified license remains encumbered after lifting first encumbrance') + + # Also verify the provider record is still encumbered + helper.validate_provider_encumbered_state('encumbered') + + logger.info('Verified provider remains encumbered after lifting first encumbrance') + + # wait 1 minute for downstream processing to complete + # this keeps the lifting events isolated from each other + helper.wait_for_downstream_processing() + + # Step 4: Lift final encumbrance (license should become unencumbered) + logger.info('Step 4: Lifting final license encumbrance...') + + lift_body = { + 'effectiveLiftDate': '2025-05-25', + } + + helper.lift_license_encumbrance(lift_body, second_adverse_action_id) + logger.info('Final license encumbrance lifted successfully') + + # Verify license is now unencumbered + helper.validate_license_encumbered_state('unencumbered') + + # Verify provider is still encumbered (due to privilege encumbrance) + helper.validate_provider_encumbered_state('encumbered') + + # Step 5: Verify that the associated privilege is still encumbered + logger.info('Verifying associated privilege is still encumbered...') + + privilege_adverse_actions = helper.get_privilege_adverse_actions() + + if len(privilege_adverse_actions) != 1: + raise SmokeTestFailureException( + f'Expected 1 privilege adverse action, found: {len(privilege_adverse_actions)}' + ) + + privilege_adverse_action_id = privilege_adverse_actions[0]['adverseActionId'] + helper.verify_privilege_adverse_action_not_lifted(privilege_adverse_action_id) + + # Step 6: Lift the privilege encumbrance + logger.info('Step 6: Lifting privilege encumbrance...') + lift_body = {'effectiveLiftDate': '2023-01-25'} + helper.lift_privilege_encumbrance(lift_body, privilege_adverse_action_id) + logger.info('Privilege encumbrance lifted successfully') + + # Step 7: Verify privilege becomes 'unencumbered' + logger.info('Step 7: Verifying privilege becomes unencumbered...') + helper.verify_privilege_adverse_action_lifted(adverse_action_id=privilege_adverse_action_id) + helper.verify_privilege_adverse_action_lifted(privilege_adverse_action_id) + logger.info('Verified privilege is now unencumbered') + + # Verify provider is now unencumbered + helper.validate_provider_encumbered_state('unencumbered') + + logger.info('License encumbrance workflow test completed successfully') + + # Wait for downstream processing to complete, including notification handlers + # This prevents race conditions where notification handlers from previous lift operations + # might still be processing when the license becomes unencumbered + logger.info('Waiting for downstream processing and notification handlers to complete...') + helper.wait_for_downstream_processing() + + finally: + # Clean up all created staff users + helper.cleanup_staff_users() + + +def test_privilege_encumbrance_workflow(): + """ + Test the complete privilege encumbrance workflow: + 1. Encumber a privilege twice + 2. Lift one encumbrance (privilege should remain encumbered) + 3. Lift the final encumbrance (privilege should become unencumbered) + """ + logger.info('Starting privilege encumbrance workflow test...') + clean_adverse_actions() + helper = EncumbranceTestHelper() + + try: + # Step 1: Encumber the privilege twice + logger.info('Step 1: Encumbering privilege twice...') + + encumbrance_body = { + 'encumbranceEffectiveDate': '2024-12-12', + 'encumbranceType': 'revocation', + 'clinicalPrivilegeActionCategories': ['fraud'], + } + + # First encumbrance + helper.encumber_privilege(encumbrance_body) + logger.info('First privilege encumbrance created successfully') + + # Check provider status to ensure it shows encumbered status + helper.validate_provider_encumbered_state('encumbered') + + # Verify adverse action exists + privilege_adverse_actions = helper.get_privilege_adverse_actions() + if len(privilege_adverse_actions) != 1: + raise SmokeTestFailureException( + f'Expected 1 privilege adverse action, found: {len(privilege_adverse_actions)}' + ) + + first_adverse_action_id = privilege_adverse_actions[0]['adverseActionId'] + + # Verify the adverse action matches the request payload + helper.verify_privilege_adverse_action_matches_request(encumbrance_body) + logger.info('First privilege encumbrance verified successfully') + + # Second encumbrance + second_encumbrance_body = { + 'encumbranceEffectiveDate': '2025-02-02', + 'encumbranceType': 'suspension', + 'clinicalPrivilegeActionCategories': ['consumer harm'], + } + helper.encumber_privilege(second_encumbrance_body) + logger.info('Second privilege encumbrance created successfully') + + # Verify we now have two adverse actions + privilege_adverse_actions = helper.get_privilege_adverse_actions() + if len(privilege_adverse_actions) != 2: + raise SmokeTestFailureException( + f'Expected 2 privilege adverse actions, found: {len(privilege_adverse_actions)}' + ) + + second_adverse_action_id = next( + aa['adverseActionId'] + for aa in privilege_adverse_actions + if aa['adverseActionId'] != first_adverse_action_id + ) + + # Verify the second adverse action matches the request payload + helper.verify_privilege_adverse_action_matches_request(second_encumbrance_body) + logger.info('Second privilege encumbrance verified successfully') + + # Step 2: Lift first encumbrance (privilege should remain encumbered) + logger.info('Step 2: Lifting first privilege encumbrance...') + + lift_body = { + 'effectiveLiftDate': '2025-03-03', + } + + helper.lift_privilege_encumbrance(lift_body, first_adverse_action_id) + logger.info('First privilege encumbrance lifted successfully') + + # Verify first privilege encumbrance is lifted + helper.verify_privilege_adverse_action_lifted(first_adverse_action_id) + + # Verify second privilege is still encumbered + helper.verify_privilege_adverse_action_not_lifted(second_adverse_action_id) + + # Also verify the provider record is still encumbered + helper.validate_provider_encumbered_state('encumbered') + + logger.info('Verified privilege remains encumbered after lifting first encumbrance') + + # wait 1 minute for downstream processing to complete + # this keeps the lifting events isolated from each other + helper.wait_for_downstream_processing() + + # Step 3: Lift final encumbrance (privilege should become unencumbered) + logger.info('Step 3: Lifting final privilege encumbrance...') + + lift_body = { + 'effectiveLiftDate': '2025-04-04', + } + + helper.lift_privilege_encumbrance(lift_body, second_adverse_action_id) + logger.info('Final privilege encumbrance lifted successfully') + + # Verify second encumbrance is now lifted + helper.verify_privilege_adverse_action_lifted(second_adverse_action_id) + + # Also verify the provider record is now unencumbered + helper.validate_provider_encumbered_state('unencumbered') + + logger.info('Privilege encumbrance workflow test completed successfully') + + finally: + # Clean up all created staff users + helper.cleanup_staff_users() + + +def run_encumbrance_smoke_tests(): + """ + Run the complete suite of encumbrance smoke tests. + """ + logger.info('Starting encumbrance smoke tests...') + + try: + # Setup test environment + setup_test_environment() + + # Run license encumbrance tests + test_license_encumbrance_workflow() + + # Run privilege encumbrance tests + test_privilege_encumbrance_workflow() + + logger.info('All encumbrance smoke tests completed successfully!') + + except Exception as e: + logger.error(f'Encumbrance smoke tests failed: {str(e)}') + raise + + +if __name__ == '__main__': + # Load environment variables from smoke_tests_env.json + load_smoke_test_env() + + # Run the complete test suite + run_encumbrance_smoke_tests() diff --git a/backend/social-work-app/tests/smoke/investigation_smoke_tests.py b/backend/social-work-app/tests/smoke/investigation_smoke_tests.py new file mode 100755 index 0000000000..6997152b68 --- /dev/null +++ b/backend/social-work-app/tests/smoke/investigation_smoke_tests.py @@ -0,0 +1,502 @@ +#!/usr/bin/env python3 +""" +Smoke tests for investigation functionality. + +This script tests the end-to-end investigation workflow for both licenses and privileges, +including creating investigations and closing them through the API endpoints. +""" + +import time + +import requests +from smoke_common import ( + SmokeTestFailureException, + call_provider_details_endpoint, + config, + create_test_staff_user, + delete_test_staff_user, + get_all_provider_database_records, + get_license_type_abbreviation, + get_provider_user_dynamodb_table, + get_staff_user_auth_headers, + load_smoke_test_env, + logger, +) + +INVESTIGATION_SMOKE_COMPACT = 'socw' + + +def _fetch_provider_details(auth_headers: dict) -> dict: + """Staff GET provider details for the smoke-test provider (CC_TEST_PROVIDER_ID).""" + return call_provider_details_endpoint(auth_headers, INVESTIGATION_SMOKE_COMPACT, config.test_provider_id) + + +def clean_investigation_records(): + """ + Clean up any existing investigation and encumbrance records for the provider to start in a clean state. + """ + logger.info('Cleaning up existing investigation and encumbrance records...') + + # Get all provider database records + all_records = get_all_provider_database_records() + + for record in all_records: + if record.get('type') == 'license' or record.get('type') == 'privilege': + if record.get('investigationStatus') == 'underInvestigation': + logger.info( + f'Removing investigation and encumbrance status from {record.get("type")} ' + f'{record.get("pk")} / {record.get("sk")}' + ) + dynamodb_table = get_provider_user_dynamodb_table() + dynamodb_table.update_item( + Key={'pk': record['pk'], 'sk': record['sk']}, + UpdateExpression='REMOVE investigationStatus, encumbranceStatus', + ) + + # Filter for investigation and encumbrance records + investigation_records = [record for record in all_records if record.get('type') == 'investigation'] + encumbrance_records = [record for record in all_records if record.get('type') == 'adverseAction'] + + # Filter for investigation and encumbrance update records + investigation_update_records = [ + record + for record in all_records + if record.get('type') in ['privilegeUpdate', 'licenseUpdate'] and record.get('updateType') == 'investigation' + ] + encumbrance_update_records = [ + record + for record in all_records + if record.get('type') in ['privilegeUpdate', 'licenseUpdate'] and record.get('updateType') == 'encumbrance' + ] + + if ( + not investigation_records + and not encumbrance_records + and not investigation_update_records + and not encumbrance_update_records + ): + logger.info('No investigation or encumbrance records found to clean up') + return + + # Delete each investigation and encumbrance record + dynamodb_table = get_provider_user_dynamodb_table() + + for record in investigation_records: + pk = record['pk'] + sk = record['sk'] + logger.info(f'Deleting investigation record: {pk} / {sk}') + dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + + for record in encumbrance_records: + pk = record['pk'] + sk = record['sk'] + logger.info(f'Deleting encumbrance record: {pk} / {sk}') + dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + + for record in investigation_update_records: + pk = record['pk'] + sk = record['sk'] + logger.info(f'Deleting investigation update record: {pk} / {sk}') + dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + + for record in encumbrance_update_records: + pk = record['pk'] + sk = record['sk'] + logger.info(f'Deleting encumbrance update record: {pk} / {sk}') + dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + + logger.info( + f'Cleaned up {len(investigation_records)} investigation records, ' + f'{len(encumbrance_records)} encumbrance records, ' + f'{len(investigation_update_records)} investigation update records, and ' + f'{len(encumbrance_update_records)} encumbrance update records' + ) + + +def setup_test_environment(): + """ + Set up the test environment by cleaning investigations. + """ + logger.info('Setting up test environment...') + + # Clean up any existing investigations + clean_investigation_records() + + logger.info('Test environment setup complete') + + +def _get_license_data_from_provider_response(provider_data: dict, jurisdiction: str, license_type: str): + """Get license data from provider response.""" + return next( + ( + lic + for lic in provider_data['licenses'] + if lic['jurisdiction'] == jurisdiction and lic['licenseType'] == license_type + ), + None, + ) + + +def _get_privilege_data_from_provider_response(provider_data: dict, jurisdiction: str, license_type: str): + """Get privilege data from provider response.""" + return next( + ( + priv + for priv in provider_data['privileges'] + if priv['jurisdiction'] == jurisdiction and priv['licenseType'] == license_type + ), + None, + ) + + +def _verify_no_investigation_exists(record_type: str, jurisdiction: str, license_type: str, auth_headers: dict): + """ + Verify that no open investigation records exist in the database and no investigation status or objects on the + record. + + :param record_type: 'privilege' or 'license' + :param jurisdiction: The jurisdiction of the record + :param license_type: The license type of the record + :param auth_headers: Staff user auth headers + """ + # Check database for open investigation records + all_records = get_all_provider_database_records() + existing_investigations = [ + record for record in all_records if record.get('type') == 'investigation' and record.get('closeDate') is None + ] + + if existing_investigations: + raise SmokeTestFailureException('Open investigation already exists before creation test') + + # Check API for investigation status + provider_data = _fetch_provider_details(auth_headers) + + if record_type == 'privilege': + record_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) + else: + record_data = _get_license_data_from_provider_response(provider_data, jurisdiction, license_type) + + if not record_data: + raise SmokeTestFailureException(f'{record_type.title()} not found before investigation creation') + + if record_data.get('investigationStatus') is not None: + raise SmokeTestFailureException( + f'Expected {record_type} to not have investigation status, ' + f'but got: {record_data.get("investigationStatus")}' + ) + + if record_data.get('investigations'): + raise SmokeTestFailureException('Investigation objects still exist in API response') + + +def _verify_investigation_exists(record_type: str, jurisdiction: str, license_type: str, auth_headers: dict): + """ + Verify that an open investigation exists and the record has investigation status. + + :param record_type: 'privilege' or 'license' + :param jurisdiction: The jurisdiction of the record + :param license_type: The license type of the record + :param auth_headers: Staff Bearer auth headers + :return: The investigation ID + """ + # Check database for investigation records + all_records = get_all_provider_database_records() + investigation_records = [ + record + for record in all_records + if record.get('type') == 'investigation' + and record.get('investigationAgainst') == record_type + and record.get('jurisdiction') == jurisdiction + and record.get('licenseType') == license_type + and record.get('closeDate') is None + ] + + if not investigation_records: + raise SmokeTestFailureException(f'No open {record_type} investigation found to close') + + # Check API for investigation status + provider_data = _fetch_provider_details(auth_headers) + + if record_type == 'privilege': + record_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) + else: + record_data = _get_license_data_from_provider_response(provider_data, jurisdiction, license_type) + + if not record_data: + raise SmokeTestFailureException(f'{record_type.title()} not found before investigation closing') + + if record_data.get('investigationStatus') != 'underInvestigation': + raise SmokeTestFailureException( + f'Expected {record_type} to have investigation status "underInvestigation" before closing, ' + f'but got: {record_data.get("investigationStatus")}' + ) + + if not record_data.get('investigations'): + raise SmokeTestFailureException('Investigation object not found in API response before closing') + + return investigation_records[0]['investigationId'] + + +def test_create_privilege_investigation(auth_headers): + """Test creating a privilege investigation.""" + logger.info('Testing privilege investigation creation...') + + provider_data = _fetch_provider_details(auth_headers) + provider_id = provider_data['providerId'] + compact = provider_data['compact'] + jurisdiction = provider_data['privileges'][0]['jurisdiction'] + license_type = provider_data['privileges'][0]['licenseType'] + license_type_abbreviation = get_license_type_abbreviation(license_type) + + _verify_no_investigation_exists('privilege', jurisdiction, license_type, auth_headers) + + # Create investigation (empty body required) + response = requests.post( + f'{config.api_base_url}/v1/compacts/{compact}/providers/{provider_id}/privileges/jurisdiction/{jurisdiction}' + f'/licenseType/{license_type_abbreviation}/investigation', + json={}, + headers=auth_headers, + timeout=30, + ) + + if response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to create privilege investigation: {response.status_code} - {response.text}' + ) + + logger.info('Privilege investigation created successfully') + + # Wait for the investigation to be processed and DynamoDB eventual consistency + time.sleep(5) + + _verify_investigation_exists('privilege', jurisdiction, license_type, auth_headers) + + +def test_create_license_investigation(auth_headers): + """Test creating a license investigation.""" + logger.info('Testing license investigation creation...') + + provider_data = _fetch_provider_details(auth_headers) + provider_id = provider_data['providerId'] + compact = provider_data['compact'] + jurisdiction = provider_data['licenseJurisdiction'] + license_type = provider_data['licenses'][0]['licenseType'] + license_type_abbreviation = get_license_type_abbreviation(license_type) + + _verify_no_investigation_exists('license', jurisdiction, license_type, auth_headers) + + # Create investigation (empty body required) + response = requests.post( + f'{config.api_base_url}/v1/compacts/{compact}/providers/{provider_id}/licenses/jurisdiction/{jurisdiction}' + f'/licenseType/{license_type_abbreviation}/investigation', + json={}, + headers=auth_headers, + timeout=30, + ) + + if response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to create license investigation: {response.status_code} - {response.text}' + ) + + logger.info('License investigation created successfully') + + # Wait for the investigation to be processed and DynamoDB eventual consistency + time.sleep(5) + + _verify_investigation_exists('license', jurisdiction, license_type, auth_headers) + + +def test_close_privilege_investigation(auth_headers): + """Test closing a privilege investigation.""" + logger.info('Testing privilege investigation closing...') + + provider_data = _fetch_provider_details(auth_headers) + provider_id = provider_data['providerId'] + compact = provider_data['compact'] + jurisdiction = provider_data['privileges'][0]['jurisdiction'] + license_type = provider_data['privileges'][0]['licenseType'] + license_type_abbreviation = get_license_type_abbreviation(license_type) + + investigation_id = _verify_investigation_exists('privilege', jurisdiction, license_type, auth_headers) + + # Close investigation (no encumbrance) + response = requests.patch( + f'{config.api_base_url}/v1/compacts/{compact}/providers/{provider_id}/privileges/jurisdiction/{jurisdiction}' + f'/licenseType/{license_type_abbreviation}/investigation/{investigation_id}', + json={'action': 'close'}, + headers=auth_headers, + timeout=30, + ) + + if response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to close privilege investigation: {response.status_code} - {response.text}' + ) + + logger.info('Privilege investigation closed successfully') + + # Wait for the investigation to be processed and DynamoDB eventual consistency + time.sleep(5) + + _verify_no_investigation_exists('privilege', jurisdiction, license_type, auth_headers) + + +def test_close_license_investigation(auth_headers): + """Test closing a license investigation.""" + logger.info('Testing license investigation closing...') + + provider_data = _fetch_provider_details(auth_headers) + provider_id = provider_data['providerId'] + compact = provider_data['compact'] + jurisdiction = provider_data['licenseJurisdiction'] + license_type = provider_data['licenses'][0]['licenseType'] + license_type_abbreviation = get_license_type_abbreviation(license_type) + + investigation_id = _verify_investigation_exists('license', jurisdiction, license_type, auth_headers) + + # Close investigation (no encumbrance) + response = requests.patch( + f'{config.api_base_url}/v1/compacts/{compact}/providers/{provider_id}/licenses/jurisdiction/{jurisdiction}' + f'/licenseType/{license_type_abbreviation}/investigation/{investigation_id}', + headers=auth_headers, + json={'action': 'close'}, + timeout=30, + ) + + if response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to close license investigation: {response.status_code} - {response.text}' + ) + + logger.info('License investigation closed successfully') + + # Wait for the investigation to be processed and DynamoDB eventual consistency + time.sleep(5) + + _verify_no_investigation_exists('license', jurisdiction, license_type, auth_headers) + + +def test_close_privilege_investigation_with_encumbrance(auth_headers): + """Test closing a privilege investigation with encumbrance creation.""" + logger.info('Testing privilege investigation closing with encumbrance...') + + provider_data = _fetch_provider_details(auth_headers) + provider_id = provider_data['providerId'] + compact = provider_data['compact'] + jurisdiction = provider_data['privileges'][0]['jurisdiction'] + license_type = provider_data['privileges'][0]['licenseType'] + license_type_abbreviation = get_license_type_abbreviation(license_type) + + # Verify initial state: an open investigation should exist + investigation_id = _verify_investigation_exists('privilege', jurisdiction, license_type, auth_headers) + + # Verify privilege is not already encumbered (no adverse actions) + privilege_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) + if privilege_data.get('adverseActions'): + raise SmokeTestFailureException( + f'Expected privilege to not have adverse actions before closing with encumbrance, ' + f'but got: {privilege_data.get("adverseActions")}' + ) + + # Close investigation with encumbrance + close_data = { + 'action': 'close', + 'encumbrance': { + 'encumbranceEffectiveDate': '2024-01-15', + 'encumbranceType': 'revocation', + 'clinicalPrivilegeActionCategories': ['consumer harm'], + }, + } + + response = requests.patch( + f'{config.api_base_url}/v1/compacts/{compact}/providers/{provider_id}/privileges/jurisdiction/{jurisdiction}' + f'/licenseType/{license_type_abbreviation}/investigation/{investigation_id}', + json=close_data, + headers=auth_headers, + timeout=30, + ) + + if response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to close privilege investigation with encumbrance: {response.status_code} - {response.text}' + ) + + logger.info('Privilege investigation closed with encumbrance successfully') + + # Wait for the investigation to be processed and DynamoDB eventual consistency + time.sleep(5) + + _verify_no_investigation_exists('privilege', jurisdiction, license_type, auth_headers) + # Verify encumbrance was created (adverse action exists) + provider_data = _fetch_provider_details(auth_headers) + privilege_data = _get_privilege_data_from_provider_response(provider_data, jurisdiction, license_type) + + if not privilege_data.get('adverseActions'): + raise SmokeTestFailureException( + f'Expected privilege to have adverse actions after closing with encumbrance, ' + f'but got: {privilege_data.get("adverseActions")}' + ) + + logger.info('Privilege investigation closing with encumbrance verified successfully') + + +def main(): + """Run all investigation smoke tests.""" + logger.info('Starting investigation smoke tests...') + + # Initialize variables for cleanup + staff_user_email = None + staff_user_sub = None + + try: + # Load test environment + load_smoke_test_env() + + # Set up test environment + setup_test_environment() + + # Create test staff user + staff_user_email = 'test-investigation-admin@example.com' + staff_user_sub = create_test_staff_user( + email=staff_user_email, + compact='socw', + jurisdiction='az', + permissions={ + 'actions': {'admin'}, + 'jurisdictions': {'al': {'admin'}, 'az': {'admin'}, 'co': {'admin'}, 'ky': {'admin'}}, + }, + ) + + # Get staff user auth headers once for reuse + auth_headers = get_staff_user_auth_headers(staff_user_email) + + # Run tests + setup_test_environment() + test_create_privilege_investigation(auth_headers) + test_close_privilege_investigation(auth_headers) + + # Test closing with encumbrance + setup_test_environment() + test_create_privilege_investigation(auth_headers) + test_close_privilege_investigation_with_encumbrance(auth_headers) + + # Test closing a license investigation + setup_test_environment() + test_create_license_investigation(auth_headers) + test_close_license_investigation(auth_headers) + + except Exception as e: + logger.error(f'Investigation smoke tests failed: {str(e)}') + raise + finally: + # Clean up test staff user + if staff_user_email and staff_user_sub: + delete_test_staff_user(staff_user_email, staff_user_sub, 'socw') + clean_investigation_records() + + logger.info('All investigation smoke tests passed!') + + +if __name__ == '__main__': + main() diff --git a/backend/social-work-app/tests/smoke/license_upload_smoke_tests.py b/backend/social-work-app/tests/smoke/license_upload_smoke_tests.py new file mode 100644 index 0000000000..de5c79f810 --- /dev/null +++ b/backend/social-work-app/tests/smoke/license_upload_smoke_tests.py @@ -0,0 +1,360 @@ +# ruff: noqa: T201 we use print statements for smoke testing +#!/usr/bin/env python3 +import time +from datetime import UTC, datetime, timedelta + +import requests +from boto3.dynamodb.conditions import Key +from compact_configuration_smoke_tests import test_jurisdiction_configuration +from config import config, logger +from smoke_common import ( + SmokeTestFailureException, + call_provider_details_endpoint, + create_test_app_client, + create_test_staff_user, + delete_test_app_client, + delete_test_staff_user, + get_client_auth_headers, + get_data_events_dynamodb_table, + get_provider_user_dynamodb_table, + get_staff_user_auth_headers, + load_smoke_test_env, + wait_for_provider_creation, +) + +COMPACT = 'socw' + +# This script can be run locally to test the license upload/ingest flow against a sandbox environment. +# License POST uses the state API (CC_TEST_STATE_API_BASE_URL) with a short-lived Cognito app client +# (CC_TEST_STATE_AUTH_URL, CC_TEST_COGNITO_STATE_AUTH_USER_POOL_ID); provider query/GET use the internal API +# (CC_TEST_API_BASE_URL) with a staff user. Configure smoke_tests_env.json from smoke_tests_env_example.json. + +# Developer note: this smoke test intentionally polls up to 12 minutes (60s interval) because the +# preprocess and ingest SQS event source mappings currently use 5-minute max batching windows. +# If faster runtime is needed, manually lower those event source mapping batching windows in the +# target environment before running this test. + +# Note that by design, developers do not have the ability to delete records from the SSN DynamoDB table, +# so this script does not delete the created SSN records as part of cleanup. + +TEST_STAFF_USER_EMAIL = 'testStaffUserLicenseUploader@smokeTestFakeEmail.com' +TEST_APP_CLIENT_NAME = 'test-license-upload-smoke-client' +HOME_STATE_CHANGE_MOCK_SSN = '999-88-8888' +HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME = 'Jane' +HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME = 'TestSmith' +HOME_STATE_CHANGE_LICENSE_TYPE = 'cosmetologist' +HOME_STATE_CHANGE_FORMER_JURISDICTION = 'az' +HOME_STATE_CHANGE_NEW_JURISDICTION = 'oh' + + +def _cleanup_test_generated_records(provider_id: str, license_ingest_record_response: dict): + """ + Cleanup all test records except the SSN record, which developers do not have the ability to delete + """ + # Now clean up the records we added + # First, get all provider records to delete + provider_dynamo_table = get_provider_user_dynamodb_table() + provider_record_query_response = provider_dynamo_table.query( + KeyConditionExpression='pk = :pk', ExpressionAttributeValues={':pk': f'{COMPACT}#PROVIDER#{provider_id}'} + ) + + # Delete all provider records + for record in provider_record_query_response.get('Items', []): + provider_dynamo_table.delete_item(Key={'pk': record['pk'], 'sk': record['sk']}) + logger.info('Successfully deleted provider records from provider table') + + # Delete data event records + data_events_table = get_data_events_dynamodb_table() + for record in license_ingest_record_response.get('Items', []): + data_events_table.delete_item(Key={'pk': record['pk'], 'sk': record['sk']}) + logger.info('Successfully deleted license ingest record from data events table') + + +def _build_home_state_change_license_post_body(jurisdiction: str, date_of_issuance: str): + return [ + { + 'licenseNumber': f'{jurisdiction.upper()}-HOME-STATE-TEST', + 'homeAddressPostalCode': '68001', + 'givenName': HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME, + 'familyName': HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME, + 'homeAddressStreet1': '123 Home State Test Street', + 'dateOfBirth': '1991-12-10', + 'dateOfIssuance': date_of_issuance, + 'ssn': HOME_STATE_CHANGE_MOCK_SSN, + 'licenseType': HOME_STATE_CHANGE_LICENSE_TYPE, + 'dateOfExpiration': '2050-12-10', + 'homeAddressState': jurisdiction.upper(), + 'homeAddressCity': 'Omaha', + 'compactEligibility': 'eligible', + 'licenseStatus': 'active', + } + ] + + +def _post_license_to_state_api(client_id: str, client_secret: str, jurisdiction: str, post_body: list[dict]): + # Access tokens are short lived, so regenerate before each upload call. + license_upload_auth_headers = get_client_auth_headers(client_id, client_secret, COMPACT, jurisdiction) + post_response = requests.post( + url=f'{config.state_api_base_url}/v1/compacts/{COMPACT}/jurisdictions/{jurisdiction}/licenses', + headers=license_upload_auth_headers, + json=post_body, + timeout=60, + ) + + if post_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to POST home state change license record for {jurisdiction}. Response: {post_response.json()}' + ) + + logger.info(f'Home state change license record successfully uploaded for {jurisdiction}: {post_response.json()}') + + +def _wait_for_home_state_change_event(provider_id: str, max_wait_seconds: int = 720, poll_interval_seconds: int = 60): + data_events_table = get_data_events_dynamodb_table() + max_attempts = max_wait_seconds // poll_interval_seconds + event_pk = f'COMPACT#{COMPACT}#JURISDICTION#{HOME_STATE_CHANGE_NEW_JURISDICTION}' + + for attempt in range(1, max_attempts + 1): + response = data_events_table.query( + KeyConditionExpression=Key('pk').eq(event_pk) + & Key('sk').begins_with('TYPE#provider.homeStateChange#TIME#'), + FilterExpression='providerId = :provider_id', + ExpressionAttributeValues={':provider_id': provider_id}, + ConsistentRead=True, + ) + matching_event = next(iter(response.get('Items', [])), None) + if matching_event: + logger.info(f'Found provider.homeStateChange data event for provider {provider_id}') + return matching_event + + if attempt < max_attempts: + logger.info( + f'provider.homeStateChange event not found yet for provider {provider_id}. ' + f'Attempt {attempt}/{max_attempts}. Retrying in {poll_interval_seconds} seconds.' + ) + time.sleep(poll_interval_seconds) + + return None + + +def _query_license_ingest_events_for_jurisdiction( + jurisdiction: str, provider_id: str, start_time: datetime, end_time: datetime +): + data_events_table = get_data_events_dynamodb_table() + return data_events_table.query( + KeyConditionExpression='pk = :pk AND sk BETWEEN :start_time AND :end_time', + FilterExpression='providerId = :provider_id', + ExpressionAttributeValues={ + ':pk': f'COMPACT#{COMPACT}#JURISDICTION#{jurisdiction}', + ':start_time': f'TYPE#license.ingest#TIME#{int(start_time.timestamp())}', + ':end_time': f'TYPE#license.ingest#TIME#{int(end_time.timestamp())}', + ':provider_id': provider_id, + }, + ConsistentRead=True, + ) + + +def test_home_state_change_notification(staff_headers: dict, client_id: str, client_secret: str): + start_time = datetime.now(tz=UTC) - timedelta(minutes=2) + provider_id = None + try: + _post_license_to_state_api( + client_id=client_id, + client_secret=client_secret, + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, + post_body=_build_home_state_change_license_post_body( + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, date_of_issuance='2024-01-15' + ), + ) + + provider_id = wait_for_provider_creation( + staff_headers=staff_headers, + compact=COMPACT, + given_name=HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME, + family_name=HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME, + max_wait_time=750, + staff_user_email=TEST_STAFF_USER_EMAIL, + poll_interval_seconds=60, + ) + logger.info(f'Found home state change test provider id {provider_id}') + + refreshed_staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + az_provider_details = call_provider_details_endpoint( + headers=refreshed_staff_headers, compact=COMPACT, provider_id=provider_id + ) + az_social_work_licenses = [ + license_record + for license_record in az_provider_details.get('licenses', []) + if license_record.get('licenseType') == HOME_STATE_CHANGE_LICENSE_TYPE + ] + + if len(az_social_work_licenses) != 1: + raise SmokeTestFailureException( + f'Expected one {HOME_STATE_CHANGE_LICENSE_TYPE} license after AZ upload, ' + f'found {len(az_social_work_licenses)}' + ) + + if az_social_work_licenses[0].get('jurisdiction') != HOME_STATE_CHANGE_FORMER_JURISDICTION: + raise SmokeTestFailureException( + 'Expected first home state license jurisdiction to be ' + f'{HOME_STATE_CHANGE_FORMER_JURISDICTION}, found {az_social_work_licenses[0].get("jurisdiction")}' + ) + + if az_provider_details.get('licenseJurisdiction') != HOME_STATE_CHANGE_FORMER_JURISDICTION: + raise SmokeTestFailureException( + 'Expected licenseJurisdiction to be ' + f'{HOME_STATE_CHANGE_FORMER_JURISDICTION} after first upload, ' + f'found {az_provider_details.get("licenseJurisdiction")}' + ) + + _post_license_to_state_api( + client_id=client_id, + client_secret=client_secret, + jurisdiction=HOME_STATE_CHANGE_NEW_JURISDICTION, + post_body=_build_home_state_change_license_post_body( + # upload license that was issued at a later date to trigger home state change + jurisdiction=HOME_STATE_CHANGE_NEW_JURISDICTION, + date_of_issuance='2025-06-15', + ), + ) + + home_state_change_event = _wait_for_home_state_change_event( + provider_id=provider_id, max_wait_seconds=750, poll_interval_seconds=60 + ) + if not home_state_change_event: + raise SmokeTestFailureException( + 'Failed to find provider.homeStateChange data event for the home state change smoke test.' + ) + + refreshed_staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + updated_provider_details = call_provider_details_endpoint( + headers=refreshed_staff_headers, compact=COMPACT, provider_id=provider_id + ) + updated_social_work_licenses = [ + license_record + for license_record in updated_provider_details.get('licenses', []) + if license_record.get('licenseType') == HOME_STATE_CHANGE_LICENSE_TYPE + ] + updated_jurisdictions = {license_record.get('jurisdiction') for license_record in updated_social_work_licenses} + if updated_jurisdictions != {HOME_STATE_CHANGE_FORMER_JURISDICTION, HOME_STATE_CHANGE_NEW_JURISDICTION}: + raise SmokeTestFailureException( + f'ExpectedSocial Worklicenses for both {HOME_STATE_CHANGE_FORMER_JURISDICTION} and ' + f'{HOME_STATE_CHANGE_NEW_JURISDICTION}, found {sorted(updated_jurisdictions)}' + ) + + if updated_provider_details.get('licenseJurisdiction') != HOME_STATE_CHANGE_NEW_JURISDICTION: + raise SmokeTestFailureException( + 'Expected licenseJurisdiction to change to ' + f'{HOME_STATE_CHANGE_NEW_JURISDICTION}, found {updated_provider_details.get("licenseJurisdiction")}' + ) + + logger.info( + 'MANUAL VERIFICATION REQUIRED: check inbox for ' + f'{config.smoke_test_notification_email}. Verify a provider home state change email was sent to ' + f'the former home jurisdiction {HOME_STATE_CHANGE_FORMER_JURISDICTION.upper()} after upload from ' + f'{HOME_STATE_CHANGE_NEW_JURISDICTION.upper()} for provider ' + f'{HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME} {HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME} ({provider_id}).' + ) + finally: + if provider_id: + logger.info('cleaning up test provider records', provider_id=provider_id) + end_time = datetime.now(tz=UTC) + az_license_ingest_events = _query_license_ingest_events_for_jurisdiction( + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, + provider_id=provider_id, + start_time=start_time, + end_time=end_time, + ) + oh_license_ingest_events = _query_license_ingest_events_for_jurisdiction( + jurisdiction=HOME_STATE_CHANGE_NEW_JURISDICTION, + provider_id=provider_id, + start_time=start_time, + end_time=end_time, + ) + home_state_change_events = _query_home_state_change_events_for_provider(provider_id) + _cleanup_home_state_change_generated_records( + provider_id=provider_id, + az_license_ingest_events=az_license_ingest_events, + oh_license_ingest_events=oh_license_ingest_events, + home_state_change_events=home_state_change_events, + ) + else: + logger.info('Skipping provider cleanup because provider id was never discovered.') + + +def _cleanup_home_state_change_generated_records( + provider_id: str, + az_license_ingest_events: dict, + oh_license_ingest_events: dict, + home_state_change_events: list[dict] | None = None, +): + merged_items = [ + *az_license_ingest_events.get('Items', []), + *oh_license_ingest_events.get('Items', []), + ] + _cleanup_test_generated_records(provider_id, {'Items': merged_items}) + + if home_state_change_events: + data_events_table = get_data_events_dynamodb_table() + for home_state_change_event in home_state_change_events: + data_events_table.delete_item( + Key={'pk': home_state_change_event['pk'], 'sk': home_state_change_event['sk']} + ) + logger.info('Successfully deleted provider.homeStateChange event(s) from data events table') + + +def _query_home_state_change_events_for_provider(provider_id: str): + data_events_table = get_data_events_dynamodb_table() + event_pk = f'COMPACT#{COMPACT}#JURISDICTION#{HOME_STATE_CHANGE_NEW_JURISDICTION}' + response = data_events_table.query( + KeyConditionExpression=Key('pk').eq(event_pk) & Key('sk').begins_with('TYPE#provider.homeStateChange#TIME#'), + ConsistentRead=True, + ) + return [item for item in response.get('Items', []) if item.get('providerId') == provider_id] + + +if __name__ == '__main__': + load_smoke_test_env() + + test_jurisdiction_configuration(HOME_STATE_CHANGE_FORMER_JURISDICTION, recreate_compact_config=True) + test_jurisdiction_configuration(HOME_STATE_CHANGE_NEW_JURISDICTION) + + test_user_sub = None + client_id = None + try: + # Create staff user with permission to query providers (internal API) + test_user_sub = create_test_staff_user( + email=TEST_STAFF_USER_EMAIL, + compact=COMPACT, + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, + permissions={ + 'actions': {'admin'}, + 'jurisdictions': { + HOME_STATE_CHANGE_FORMER_JURISDICTION: {'write', 'admin'}, + HOME_STATE_CHANGE_NEW_JURISDICTION: {'write', 'admin'}, + }, + }, + ) + + client_credentials = create_test_app_client( + TEST_APP_CLIENT_NAME, + COMPACT, + jurisdictions=[HOME_STATE_CHANGE_FORMER_JURISDICTION, HOME_STATE_CHANGE_NEW_JURISDICTION], + ) + client_id = client_credentials['client_id'] + client_secret = client_credentials['client_secret'] + home_state_change_staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + test_home_state_change_notification( + staff_headers=home_state_change_staff_headers, + client_id=client_id, + client_secret=client_secret, + ) + logger.info('Home state change notification smoke test passed') + except SmokeTestFailureException as e: + logger.error(f'License record upload smoke test failed: {str(e)}') + finally: + if client_id: + delete_test_app_client(client_id) + if test_user_sub: + # Clean up the test staff user + delete_test_staff_user(TEST_STAFF_USER_EMAIL, user_sub=test_user_sub, compact=COMPACT) diff --git a/backend/social-work-app/tests/smoke/public_search_smoke_tests.py b/backend/social-work-app/tests/smoke/public_search_smoke_tests.py new file mode 100644 index 0000000000..dfbb125638 --- /dev/null +++ b/backend/social-work-app/tests/smoke/public_search_smoke_tests.py @@ -0,0 +1,276 @@ +# ruff: noqa: T201 we use print statements for smoke testing +#!/usr/bin/env python3 +""" +Smoke tests for public license search (unauthenticated). + +POST /v1/public/compacts/{compact}/providers/query and GET /v1/public/compacts/{compact}/providers/{providerId}. +Uses CC_TEST_PROVIDER_ID and mutates the smoke license in DynamoDB; restores state in finally blocks. + +This test assumes the test provider has no existing license in TEST_JURISDICTION. If this is not the case, +you can change the TEST_JURISDICTION variable to a jurisdiction where the test provider does not have a license. + +Run from repo root with social-work-app cwd, or set paths like other smoke scripts: + + cd backend/social-work-app && python tests/smoke/public_search_smoke_tests.py +""" + +from __future__ import annotations + +import copy + +from config import config, logger +from smoke_common import ( + SmokeTestFailureException, + call_public_get_provider, + call_public_query_providers, + get_all_provider_database_records, + get_license_type_abbreviation, + get_most_recently_issued_or_renewed_license, + get_provider_user_dynamodb_table, + load_smoke_test_env, + wait_for_opensearch_sync, +) + +COMPACT = 'socw' +EXPIRED_DATE_FOR_TEST = '2020-05-05' +# set older date of issuance and renewal for the test license that should be excluded from the public search results +TEST_DATE_OF_ISSUANCE = '2013-05-05' +TEST_DATE_OF_RENEWAL = '2014-05-05' +TEST_JURISDICTION = 'az' +TEST_LICENSE_NUMBER = 'SMOKE-TEST-LICENSE' + + +def _ensure_no_existing_license_in_test_jurisdiction(license_records: list[dict]) -> None: + for record in license_records: + if str(record.get('jurisdiction', '')).lower() == TEST_JURISDICTION: + raise SmokeTestFailureException( + 'Smoke test prerequisite failed: the configured test provider already has at least one ' + f'license in jurisdiction {TEST_JURISDICTION}. Set TEST_JURISDICTION to a jurisdiction where ' + 'the test provider has no license, then re-run.' + ) + + +def _assert_license_eligibility_for_smoke_license( + *, + provider_id: str, + license_number: str, + expected_license_eligibility: str, +) -> None: + """Public query by license number; assert ``licenseEligibility`` for the smoke provider's row.""" + matching_license_rows = call_public_query_providers( + COMPACT, + license_number_filter=license_number, + provider_id_filter=provider_id, + page_size=25, + ) + if not matching_license_rows: + raise SmokeTestFailureException( + f'Public query returned no rows for provider {provider_id} (licenseNumber={license_number!r})' + ) + license_row = matching_license_rows[0] + actual_eligibility = license_row.get('licenseEligibility') + if actual_eligibility != expected_license_eligibility: + raise SmokeTestFailureException( + f'Expected licenseEligibility {expected_license_eligibility!r} for provider {provider_id}, ' + f'got {actual_eligibility!r}' + ) + + +def test_public_search_endpoints_returns_details_of_provider() -> dict: + """ + Public query by smoke license number for the configured test provider; verify eligible search row + and public GET license compactEligibility. + """ + provider_id = config.test_provider_id + database_records = get_all_provider_database_records(COMPACT, provider_id) + license_records = [record for record in database_records if record.get('type') == 'license'] + logger.info(f'License record count: {len(license_records)}') + _ensure_no_existing_license_in_test_jurisdiction(license_records) + smoke_license_record = get_most_recently_issued_or_renewed_license(license_records) + license_number = smoke_license_record.get('licenseNumber') + if not license_number: + raise SmokeTestFailureException('Smoke license record has no licenseNumber for public query') + + logger.info('Running public query endpoint test') + matching_license_rows = call_public_query_providers( + COMPACT, + license_number_filter=license_number, + provider_id_filter=provider_id, + ) + if not matching_license_rows: + raise SmokeTestFailureException( + f'Public query returned no rows for provider {provider_id} (licenseNumber={license_number})' + ) + license_row = matching_license_rows[0] + if license_row.get('licenseEligibility') != 'eligible': + raise SmokeTestFailureException( + f'Expected licenseEligibility eligible for provider {provider_id}, ' + f'got {license_row.get("licenseEligibility")!r}' + ) + + public_provider_detail = call_public_get_provider(COMPACT, provider_id) + licenses_from_get = public_provider_detail.get('licenses') or [] + # get the license from the public GET response that matches the smoke license record + matching_license_from_detail_response = next( + ( + license_payload + for license_payload in licenses_from_get + if str(license_payload.get('jurisdiction', '')).lower() == smoke_license_record.get('jurisdiction') + and license_payload.get('licenseType') == smoke_license_record.get('licenseType') + ), + None, + ) + if matching_license_from_detail_response is None: + raise SmokeTestFailureException( + f'Matching license not found for provider {provider_id} from public GET response' + ) + if matching_license_from_detail_response.get('compactEligibility') != 'eligible': + raise SmokeTestFailureException( + f'Expected compactEligibility eligible on public GET license, got ' + f'{matching_license_from_detail_response.get("compactEligibility")!r}' + ) + + logger.info('Public search smoke: baseline query + GET checks passed') + + return { + 'provider_id': provider_id, + 'license_number': license_number, + 'license_pk': smoke_license_record['pk'], + 'license_sk': smoke_license_record['sk'], + 'original_date_of_expiration': smoke_license_record['dateOfExpiration'], + 'original_jurisdiction_uploaded_compact_eligibility': smoke_license_record[ + 'jurisdictionUploadedCompactEligibility' + ], + } + + +def test_public_query_endpoint_returns_ineligible_license_if_license_is_expired(provider_context: dict) -> None: + provider_user_table = get_provider_user_dynamodb_table() + license_partition_and_sort_key = {'pk': provider_context['license_pk'], 'sk': provider_context['license_sk']} + original_date_of_expiration = provider_context['original_date_of_expiration'] + + try: + logger.info('Updating license expiration date to expired date') + provider_user_table.update_item( + Key=license_partition_and_sort_key, + UpdateExpression='SET dateOfExpiration = :exp', + ExpressionAttributeValues={':exp': EXPIRED_DATE_FOR_TEST}, + ) + wait_for_opensearch_sync() + _assert_license_eligibility_for_smoke_license( + provider_id=provider_context['provider_id'], + license_number=provider_context['license_number'], + expected_license_eligibility='ineligible', + ) + logger.info('expiration ineligibility test passed') + finally: + logger.info('Restoring license expiration date to original value') + provider_user_table.update_item( + Key=license_partition_and_sort_key, + UpdateExpression='SET dateOfExpiration = :exp', + ExpressionAttributeValues={':exp': original_date_of_expiration}, + ) + wait_for_opensearch_sync() + _assert_license_eligibility_for_smoke_license( + provider_id=provider_context['provider_id'], + license_number=provider_context['license_number'], + expected_license_eligibility='eligible', + ) + logger.info('license expiration date restored to original value') + + +def test_public_query_endpoint_returns_ineligible_license_if_license_is_marked_by_jurisdiction_as_ineligible( + provider_context: dict, +) -> None: + provider_user_table = get_provider_user_dynamodb_table() + license_partition_and_sort_key = {'pk': provider_context['license_pk'], 'sk': provider_context['license_sk']} + original_jurisdiction_uploaded_compact_eligibility = provider_context[ + 'original_jurisdiction_uploaded_compact_eligibility' + ] + + try: + logger.info('Updating license jurisdiction uploaded compact eligibility to ineligible') + provider_user_table.update_item( + Key=license_partition_and_sort_key, + UpdateExpression='SET jurisdictionUploadedCompactEligibility = :ineligible', + ExpressionAttributeValues={':ineligible': 'ineligible'}, + ) + wait_for_opensearch_sync() + _assert_license_eligibility_for_smoke_license( + provider_id=provider_context['provider_id'], + license_number=provider_context['license_number'], + expected_license_eligibility='ineligible', + ) + logger.info('jurisdiction ineligibility test passed') + finally: + logger.info('Restoring license jurisdiction uploaded compact eligibility to original value') + provider_user_table.update_item( + Key=license_partition_and_sort_key, + UpdateExpression='SET jurisdictionUploadedCompactEligibility = :j', + ExpressionAttributeValues={':j': original_jurisdiction_uploaded_compact_eligibility}, + ) + wait_for_opensearch_sync() + _assert_license_eligibility_for_smoke_license( + provider_id=provider_context['provider_id'], + license_number=provider_context['license_number'], + expected_license_eligibility='eligible', + ) + logger.info('license jurisdiction uploaded compact eligibility restored to original value') + + +def test_public_lookup_does_not_match_against_old_license_records(provider_context: dict) -> None: + """ + Inserts a license with older issuance/renewal and verifies that it is not included in the public search results. + """ + table = get_provider_user_dynamodb_table() + source_key = {'pk': provider_context['license_pk'], 'sk': provider_context['license_sk']} + response = table.get_item(Key=source_key) + + clone = copy.deepcopy(response['Item']) + license_type = clone['licenseType'] + license_type_abbr = get_license_type_abbreviation(license_type) + + clone['dateOfIssuance'] = TEST_DATE_OF_ISSUANCE + clone['dateOfRenewal'] = TEST_DATE_OF_RENEWAL + clone['licenseNumber'] = TEST_LICENSE_NUMBER + clone['jurisdiction'] = TEST_JURISDICTION + + clone['sk'] = f'{COMPACT}#PROVIDER#license/{TEST_JURISDICTION}/{license_type_abbr}#' + + new_key: dict[str, str] | None = None + try: + table.put_item(Item=clone) + new_key = {'pk': clone['pk'], 'sk': clone['sk']} + wait_for_opensearch_sync() + + matching_rows = call_public_query_providers( + COMPACT, + license_number_filter=TEST_LICENSE_NUMBER, + provider_id_filter=provider_context['provider_id'], + ) + if matching_rows: + raise SmokeTestFailureException( + f'Expected no public query rows for test license {TEST_LICENSE_NUMBER!r}, got {matching_rows}' + ) + + public_provider_detail = call_public_get_provider(COMPACT, provider_context['provider_id']) + for lic in public_provider_detail.get('licenses'): + if lic.get('licenseNumber') == TEST_LICENSE_NUMBER: + raise SmokeTestFailureException(f'Public GET unexpectedly included test license: {lic}') + + logger.info('Public search old license exclusion test passed') + finally: + if new_key is not None: + logger.info(f'Deleting test license {new_key}') + table.delete_item(Key=new_key) + + +if __name__ == '__main__': + load_smoke_test_env() + provider_context = test_public_search_endpoints_returns_details_of_provider() + test_public_query_endpoint_returns_ineligible_license_if_license_is_expired(provider_context=provider_context) + test_public_query_endpoint_returns_ineligible_license_if_license_is_marked_by_jurisdiction_as_ineligible( + provider_context=provider_context + ) + test_public_lookup_does_not_match_against_old_license_records(provider_context=provider_context) + logger.info('All public search smoke tests completed successfully.') diff --git a/backend/social-work-app/tests/smoke/query_provider_smoke_tests.py b/backend/social-work-app/tests/smoke/query_provider_smoke_tests.py new file mode 100644 index 0000000000..624533bf24 --- /dev/null +++ b/backend/social-work-app/tests/smoke/query_provider_smoke_tests.py @@ -0,0 +1,214 @@ +# ruff: noqa: S101 T201 we use asserts and print statements for smoke testing +import json + +import requests +from config import config, logger +from deepdiff import DeepDiff +from smoke_common import ( + SmokeTestFailureException, + call_provider_users_me_endpoint, + create_test_staff_user, + delete_test_staff_user, + get_staff_user_auth_headers, + load_smoke_test_env, +) + +# This script can be run locally to test the Query/Get Provider flow against a sandbox environment of the Compact +# Connect API. It requires that you have a provider user set up in the same compact of the sandbox environment. +# Your sandbox account must also be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json +# file, which allows you to log in users using the boto3 Cognito client. + +# The staff user should be created **without** any 'readPrivate' permissions, as this flow is intended to test +# the general provider data retrieval flow. + +# To run this script, create a smoke_tests_env.json file in the same directory as this script using the +# 'smoke_tests_env_example.json' file as a template. + + +TEST_STAFF_USER_EMAIL = 'testStaffUserQuerySmokeTests@smokeTestFakeEmail.com' + + +def get_general_provider_user_data_smoke_test(): + """ + Verifies that a provider record can be fetched from the GET provider users endpoint with private fields sanitized. + + Step 1: Get the provider id of the provider user profile information. + Step 2: The staff user calls the GET provider users endpoint with the provider id. + Step 3: Verify the Provider response matches the profile. + """ + # Step 1: Get the provider id of the provider user profile information. + test_user_profile = call_provider_users_me_endpoint() + provider_id = provider_user_profile['providerId'] + compact = provider_user_profile['compact'] + + # Step 2: The staff user calls the GET provider users endpoint with the provider id. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + + get_provider_response = requests.get( + url=config.api_base_url + f'/v1/compacts/{compact}/providers/{provider_id}', + headers=staff_users_headers, + timeout=10, + ) + + if get_provider_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to query provider. Response: {get_provider_response.json()}') + logger.info('Received success response from GET endpoint') + + # Step 3: Verify the Provider response matches the profile. + get_provider_general_provider_object = get_provider_response.json() + + # verify the ssn is NOT in the response + if 'ssn' in get_provider_general_provider_object: + raise SmokeTestFailureException(f'unexpected ssn field returned. Response: {get_provider_response.json()}') + + # remove the fields from the user profile that are not in the query response + test_user_profile.pop('ssnLastFour', None) + test_user_profile.pop('dateOfBirth', None) + test_user_profile.pop('encumberedStatus', None) + for provider_license in test_user_profile['licenses']: + provider_license.pop('ssnLastFour', None) + provider_license.pop('dateOfBirth', None) + provider_license.pop('encumberedStatus', None) + for history_event in provider_license['history']: + history_event['previous'].pop('ssnLastFour', None) + history_event['previous'].pop('dateOfBirth', None) + history_event['previous'].pop('encumberedStatus', None) + + if get_provider_general_provider_object != test_user_profile: + formatted_test_user_profile = json.dumps(test_user_profile, sort_keys=True, indent=4) + formatted_get_provider_response = json.dumps(get_provider_general_provider_object, sort_keys=True, indent=4) + logger.error( + 'Provider object does not match the profile.', + provider_profile=formatted_test_user_profile, + get_provider_response=formatted_get_provider_response, + diff=DeepDiff(test_user_profile, get_provider_general_provider_object), + ) + raise SmokeTestFailureException('Get provider object response does not match the profile.') + logger.info('Successfully fetched expected provider records.') + + +def query_provider_user_smoke_test(): + """ + Verifies that a provider record can be queried . + + Step 1: Get the provider id of the provider user profile information. + Step 2: Have the staff user query for that provider using the profile information. + Step 3: Verify the Provider response matches the profile. + """ + + # Step 1: Get the provider id of the provider user profile information. + test_user_profile = call_provider_users_me_endpoint() + provider_id = provider_user_profile['providerId'] + compact = provider_user_profile['compact'] + + # Step 2: Have the staff user query for that provider using the profile information. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + post_body = {'query': {'providerId': provider_id}} + + post_response = requests.post( + url=config.api_base_url + f'/v1/compacts/{compact}/providers/query', + headers=staff_users_headers, + json=post_body, + timeout=10, + ) + + if post_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to query provider. Response: {post_response.json()}') + logger.info('Received success response from query endpoint') + # Step 3: Verify the Provider response matches the profile. + providers = post_response.json()['providers'] + if not providers: + raise SmokeTestFailureException(f'No providers returned by query. Response: {post_response.json()}') + + provider_object = providers[0] + + # verify the ssn is NOT in the response + if 'ssn' in provider_object: + raise SmokeTestFailureException(f'unexpected ssn field returned. Response: {post_response.json()}') + + # remove the fields from the user profile that are not in the query response + test_user_profile.pop('ssnLastFour', None) + test_user_profile.pop('dateOfBirth', None) + test_user_profile.pop('licenses') + test_user_profile.pop('privileges') + test_user_profile.pop('encumberedStatus', None) + + if provider_object != test_user_profile: + raise SmokeTestFailureException( + f'Provider list object does not match the profile.\n{DeepDiff(test_user_profile, provider_object)}' + ) + + logger.info('Successfully queried expected provider record.') + + +def get_provider_data_with_read_private_access_smoke_test(test_staff_user_id: str): + """ + Verifies that a staff user can read private fields of a provider record if they have the 'readPrivate' permission. + + Step 1: Update the staff user's permissions using the PATCH '/v1/staff-users/me/permissions' endpoint to include + the 'readPrivate' permission. + Step 2: Generate a new token and call the GET provider users endpoint with the new token. + Step 3: Verify the Provider response matches the profile. + """ + + # Step 1: Get the provider user profile information. + test_user_profile = call_provider_users_me_endpoint() + provider_id = provider_user_profile['providerId'] + compact = provider_user_profile['compact'] + # Step 1: Update the staff user's permissions using the PATCH '/v1/staff-users/me/permissions' endpoint. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + patch_body = {'permissions': {'socw': {'actions': {'readPrivate': True}}}} + patch_response = requests.patch( + url=config.api_base_url + f'/v1/compacts/{compact}/staff-users/{test_staff_user_id}', + headers=staff_users_headers, + json=patch_body, + timeout=10, + ) + + if patch_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to PATCH staff user permissions. Response: {patch_response.json()}') + logger.info('Successfully updated staff user permissions.') + + # Step 2: Generate a new token and call the GET provider users endpoint with the new token. + staff_users_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + get_provider_response = requests.get( + url=config.api_base_url + f'/v1/compacts/{compact}/providers/{provider_id}', + headers=staff_users_headers, + timeout=10, + ) + + if get_provider_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to GET staff user. Response: {get_provider_response.json()}') + + logger.info('Received success response from GET endpoint') + + # Step 3: Verify the Provider response matches the profile. + provider_object = get_provider_response.json() + if provider_object != test_user_profile: + raise SmokeTestFailureException( + f'Provider object does not match the profile.\n{DeepDiff(test_user_profile, provider_object)}' + ) + + logger.info('Successfully fetched expected user profile.') + + +if __name__ == '__main__': + load_smoke_test_env() + provider_user_profile = call_provider_users_me_endpoint() + provider_compact = provider_user_profile['compact'] + # ensure the test staff user is in the same compact as the test provider user without 'readPrivate' permissions + test_user_sub = create_test_staff_user( + email=TEST_STAFF_USER_EMAIL, + compact=provider_compact, + jurisdiction='oh', + permissions={'actions': {'admin'}, 'jurisdictions': {'oh': {'write', 'admin'}}}, + ) + try: + get_general_provider_user_data_smoke_test() + query_provider_user_smoke_test() + get_provider_data_with_read_private_access_smoke_test(test_staff_user_id=test_user_sub) + logger.info('Query provider smoke tests passed') + except SmokeTestFailureException as e: + logger.error(f'Query provider smoke tests failed: {str(e)}') + finally: + delete_test_staff_user(TEST_STAFF_USER_EMAIL, user_sub=test_user_sub, compact=provider_compact) diff --git a/backend/social-work-app/tests/smoke/rollback_license_upload_smoke_tests.py b/backend/social-work-app/tests/smoke/rollback_license_upload_smoke_tests.py new file mode 100644 index 0000000000..11f8707d25 --- /dev/null +++ b/backend/social-work-app/tests/smoke/rollback_license_upload_smoke_tests.py @@ -0,0 +1,805 @@ +# ruff: noqa: T201 we use print statements for smoke testing +#!/usr/bin/env python3 +import json +import time +import uuid +from datetime import UTC, datetime, timedelta + +import boto3 +import requests +from config import config, logger +from smoke_common import ( + LicenseData, + LicenseUpdateData, + SmokeTestFailureException, + create_test_app_client, + create_test_staff_user, + delete_test_app_client, + delete_test_staff_user, + get_api_base_url, + get_client_auth_headers, + get_provider_user_records, + get_staff_user_auth_headers, + load_smoke_test_env, +) + +""" +Test to verify that license records can be rolled back using rollback step function + +Note that these tests upload license records into the system +""" + +COMPACT = 'socw' +JURISDICTION = 'az' +TEST_STAFF_USER_EMAIL = 'testStaffUserLicenseRollback@smokeTestFakeEmail.com' +TEST_APP_CLIENT_NAME = 'test-license-rollback-client' + +LICENSE_TYPE = 'cosmetologist' + +# Test configuration +NUM_LICENSES_TO_UPLOAD = 100 +BATCH_SIZE = 100 # Upload in batches of 100 (single batch at default scale) +# First upload: API returns after SQS enqueue; provider rows appear as preprocess + ingest drain (queue + batch size). +FIRST_UPLOAD_PROVIDER_INGEST_MAX_WAIT_SEC = 180 +# Second upload re-uses existing providers; wait_for_all_providers_created returns immediately while ingest +# (SQS/Lambda) may still be writing license_update rows — buffer before polling, then retry below. +SECOND_UPLOAD_INGEST_BUFFER_SEC = 30 +LICENSE_UPDATE_VERIFY_MAX_RETRIES = 12 +LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC = 20 + +# Global list to track all provider IDs for cleanup +ALL_PROVIDER_IDS = [] + + +def upload_test_license_batch( + auth_headers: dict, + batch_start_index: int, + batch_size: int, + street_address: str = '123 Test Street', + *, + family_name: str, +): + """ + Upload a batch of test license records. + + :param auth_headers: Authentication headers for app client + :param batch_start_index: Starting index for this batch + :param batch_size: Number of licenses to upload in this batch + :param street_address: Street address to use + :return: List of license records that were uploaded + """ + licenses_batch = [] + + for i in range(batch_start_index, batch_start_index + batch_size): + # Generate unique data for each license + license_data = { + 'licenseNumber': f'ROLLBACK-TEST-{i:04d}', + 'homeAddressPostalCode': '68001', + 'givenName': f'TestProvider{i:04d}', + # Per-run family name isolates the provider query from leftover "RollbackTest*" rows in shared sandboxes. + 'familyName': family_name, + 'homeAddressStreet1': street_address, + 'dateOfBirth': '1985-01-01', + 'dateOfIssuance': '2020-01-01', + 'ssn': f'555-50-{i:04d}', # Incrementing SSN with padded zeros + 'licenseType': LICENSE_TYPE, + 'dateOfExpiration': '2050-12-10', + 'homeAddressState': 'AZ', + 'homeAddressCity': 'Phoenix', + 'compactEligibility': 'eligible', + 'licenseStatus': 'active', + } + licenses_batch.append(license_data) + + # Upload the batch + logger.info( + f'Uploading batch of {len(licenses_batch)} licenses' + f' (indices {batch_start_index}-{batch_start_index + batch_size - 1})' + ) + + post_response = requests.post( + url=f'{config.state_api_base_url}/v1/compacts/{COMPACT}/jurisdictions/{JURISDICTION}/licenses', + headers=auth_headers, + json=licenses_batch, + timeout=60, # Longer timeout for batch uploads + ) + + if post_response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to upload license batch {batch_start_index}. Response: {post_response.json()}' + ) + + logger.info(f'Successfully uploaded batch {batch_start_index}-{batch_start_index + batch_size - 1}') + return licenses_batch + + +def upload_test_licenses( + auth_headers: dict, + num_licenses: int, + batch_size: int, + street_address: str = '123 Test Street', + *, + family_name: str, +): + """ + Upload test license records in batches. + + :param auth_headers: Authentication headers for app client + :param num_licenses: Total number of licenses to upload + :param batch_size: Number of licenses per batch + :param street_address: Street address to use + :return: Tuple of (all uploaded license data, upload start time, upload end time) + """ + all_licenses = [] + + logger.info(f'Starting upload of {num_licenses} test licenses in batches of {batch_size}') + + for batch_start in range(0, num_licenses, batch_size): + current_batch_size = min(batch_size, num_licenses - batch_start) + batch_licenses = upload_test_license_batch( + auth_headers, batch_start, current_batch_size, street_address, family_name=family_name + ) + all_licenses.extend(batch_licenses) + + # Small delay between batches to avoid rate limiting + if batch_start + current_batch_size < num_licenses: + time.sleep(2) + + # wait for several minutes for all licenses to propagate in the system + logger.info(f'Completed upload of {len(all_licenses)} licenses') + + return all_licenses + + +def verify_license_update_records_created(provider_ids, retry_count: int = 0): + """ + Checks all provider ids for license update records, if none are found, adds to list to retry + and retries after a delay + :param provider_ids: List of provider IDs to check + :param retry_count: Current retry count + :return: None + """ + provider_ids_to_retry = [] + for provider_id in provider_ids: + provider_user_records = get_provider_user_records(COMPACT, provider_id) + if len(provider_user_records.get_all_license_update_records()) == 0: + logger.info(f'no license update records found for provider {provider_id}. Will retry.') + provider_ids_to_retry.append(provider_id) + + if provider_ids_to_retry: + if retry_count >= LICENSE_UPDATE_VERIFY_MAX_RETRIES: + raise SmokeTestFailureException( + f'failed to find license update records for {len(provider_ids_to_retry)} providers after ' + f'{LICENSE_UPDATE_VERIFY_MAX_RETRIES} retries ' + f'({LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC}s between retries)' + ) + time.sleep(LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC) + logger.info( + f'retrying {len(provider_ids_to_retry)} providers after {LICENSE_UPDATE_VERIFY_RETRY_SLEEP_SEC} seconds...' + ) + verify_license_update_records_created(provider_ids_to_retry, retry_count + 1) + else: + logger.info('all license update records found') + + +def wait_for_all_providers_created( + staff_headers: dict, + expected_count: int, + max_wait_time: int = 120, + *, + family_name: str, +): + """ + Wait for all provider records to be created from uploaded licenses. + + :param staff_headers: Authentication headers for staff user + :param expected_count: Expected number of providers to be created + :param max_wait_time: Maximum time to wait in seconds (default: 120) + :return: List of all provider IDs matching family_name+jurisdiction (length should match expected_count when + the run is isolated via a unique family_name). + """ + logger.info(f'Waiting for {expected_count} provider records to be created (familyName={family_name})...') + + start_time = time.time() + check_interval = 5 + + all_provider_ids: set[str] = set() + while time.time() - start_time < max_wait_time: + page_num = 1 + last_key = None + # Collect all providers across all pages + while True: + query_body = { + 'query': {'familyName': family_name, 'jurisdiction': JURISDICTION}, + 'pagination': {'pageSize': 100}, + } + if last_key: + query_body['pagination']['lastKey'] = last_key + + query_response = requests.post( + url=f'{get_api_base_url()}/v1/compacts/{COMPACT}/providers/query', + headers=staff_headers, + json=query_body, + timeout=30, + ) + + if query_response.status_code != 200: + logger.warning( + f'Query failed with status {query_response.status_code}: {query_response.json()} Retrying...' + ) + break + + response_data = query_response.json() + providers = response_data.get('providers', []) + pagination = response_data.get('pagination', {}) + + # Collect provider IDs from this page and add to set + page_provider_ids = [p['providerId'] for p in providers] + all_provider_ids.update(page_provider_ids) + + logger.info( + f'Page {page_num}: Found {len(page_provider_ids)} providers ' + f'(total: {len(all_provider_ids)}/{expected_count})' + ) + + # Check if there are more pages + last_key = pagination.get('lastKey') + if not last_key: + # No more pages + break + + page_num += 1 + + num_found = len(all_provider_ids) + logger.info( + f'Found {num_found}/{expected_count} providers with family name "{family_name}" ' + f'in jurisdiction "{JURISDICTION}" (across {page_num} pages)' + ) + + if num_found > expected_count: + raise SmokeTestFailureException( + f'More providers ({num_found}) than uploads ({expected_count}) matched the query; ' + f'(family_name="{family_name}", jurisdiction="{JURISDICTION}", compact="{COMPACT}"). ' + ) + + if num_found == expected_count: + logger.info(f'All {expected_count} providers found!') + return list(all_provider_ids) + + elapsed = time.time() - start_time + if elapsed < max_wait_time: + logger.info(f'Waiting {check_interval}s for remaining providers... (elapsed: {elapsed:.1f}s)') + time.sleep(check_interval) + + # Timeout reached - make one final query to get the latest results + raise SmokeTestFailureException(f'Timeout reached waiting for providers after {max_wait_time}s.') + + +def start_rollback_step_function( + step_function_arn: str, + compact: str, + jurisdiction: str, + start_datetime: datetime, + end_datetime: datetime, +): + """ + Start the license upload rollback step function. + + :param step_function_arn: ARN of the step function + :param compact: Compact abbreviation + :param jurisdiction: Jurisdiction abbreviation + :param start_datetime: Start of rollback time window + :param end_datetime: End of rollback time window + :return: Execution ARN + """ + sfn_client = boto3.client('stepfunctions') + + # Generate unique execution name + execution_name = f'smoke-test-rollback-{int(datetime.now(tz=UTC).timestamp())}' + + input_data = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'startDateTime': start_datetime.isoformat(), + 'endDateTime': end_datetime.isoformat(), + 'rollbackReason': 'Smoke test validation of rollback functionality', + } + + logger.info(f'Starting step function execution: {execution_name}') + logger.info(f'Input: {json.dumps(input_data, indent=2)}') + + response = sfn_client.start_execution( + stateMachineArn=step_function_arn, + name=execution_name, + input=json.dumps(input_data), + ) + + execution_arn = response['executionArn'] + logger.info(f'Step function started. Execution ARN: {execution_arn}') + + return execution_arn + + +def wait_for_step_function_completion(execution_arn: str, max_wait_time: int = 3600): + """ + Poll the step function until it completes. + + :param execution_arn: ARN of the step function execution + :param max_wait_time: Maximum time to wait in seconds (default: 3600 = 1 hour) + :return: Final execution status and output + """ + sfn_client = boto3.client('stepfunctions') + + logger.info('Waiting for step function to complete...') + start_time = time.time() + check_interval = 30 + + while time.time() - start_time < max_wait_time: + response = sfn_client.describe_execution(executionArn=execution_arn) + + status = response['status'] + logger.info(f'Step function status: {status}') + + if status == 'SUCCEEDED': + output = json.loads(response['output']) + elapsed = time.time() - start_time + logger.info(f'Step function completed successfully after {elapsed:.1f}s') + return status, output + if status in ['FAILED', 'TIMED_OUT', 'ABORTED']: + raise SmokeTestFailureException( + f'Step function execution failed with status: {status}. ' + f'Error: {response.get("error", "N/A")}, Cause: {response.get("cause", "N/A")}' + ) + + # Still running + time.sleep(check_interval) + + raise SmokeTestFailureException(f'Step function did not complete within {max_wait_time}s timeout') + + +def get_rollback_results_from_s3(results_s3_key: str): + """ + Retrieve rollback results from S3. + + :param results_s3_key: S3 URI or key to the results file + :return: Parsed results data + """ + s3_client = boto3.client('s3') + + # Format: s3://bucket-name/key + parts = results_s3_key.replace('s3://', '').split('/', 1) + bucket_name = parts[0] + key = parts[1] + + logger.info(f'Retrieving results from S3: {bucket_name}/{key}') + + response = s3_client.get_object(Bucket=bucket_name, Key=key) + results_json = response['Body'].read().decode('utf-8') + results = json.loads(results_json) + + logger.info('Retrieved results from S3') + return results + + +def create_encumbrance_update_for_provider(provider_id: str, compact: str, license_jurisdiction: str): + """ + Manually create a license encumbrance update record to test skip conditions. + + :param provider_id: The provider ID + :param compact: The compact abbreviation + :param license_jurisdiction: The jurisdiction of the license + """ + + license_type_abbr = 'cos' + # Use current time or specified time + now = datetime.now(tz=UTC) + + # First, query the actual license record to get the previous state + license_sk = f'{compact}#PROVIDER#license/{license_jurisdiction}/{license_type_abbr}#' + + try: + response = config.provider_user_dynamodb_table.get_item( + Key={'pk': f'{compact}#PROVIDER#{provider_id}', 'sk': license_sk} + ) + license_record_item = response.get('Item') + + if not license_record_item: + raise SmokeTestFailureException(f'License record not found for provider {provider_id}') + + # Load the license record using the schema to get properly typed data + license_record = LicenseData.from_database_record(license_record_item) + + except Exception as e: + logger.error(f'Failed to retrieve license record for provider {provider_id}: {str(e)}') + raise + + # Create a license encumbrance update record using LicenseUpdateData + # This ensures proper schema validation and field generation (including SK hash) + update_data = LicenseUpdateData.create_new( + { + 'type': 'licenseUpdate', + 'updateType': 'encumbrance', + 'providerId': provider_id, + 'compact': compact, + 'jurisdiction': license_jurisdiction, + 'licenseType': LICENSE_TYPE, + 'createDate': now, + 'effectiveDate': now, + 'previous': license_record.to_dict(), + 'updatedValues': { + 'encumberedStatus': 'encumbered', + }, + } + ) + + # Serialize to database record format + update_record = update_data.serialize_to_database_record() + + config.provider_user_dynamodb_table.put_item(Item=update_record) + logger.info(f'Created encumbrance update record for provider {provider_id} with createDate {now.isoformat()}') + + +def delete_all_provider_records(provider_ids: list[str], compact: str): + """ + Delete all records for the given provider IDs. + + :param provider_ids: List of provider IDs to delete + :param compact: The compact abbreviation + """ + logger.info(f'Starting cleanup of {len(provider_ids)} provider records...') + + for i, provider_id in enumerate(provider_ids): + if i % 100 == 0: + logger.info(f'Cleaned up {i}/{len(provider_ids)} provider records') + + try: + # Query all records for this provider + response = config.provider_user_dynamodb_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': f'{compact}#PROVIDER#{provider_id}'}, + ) + + # Delete all records in batches + with config.provider_user_dynamodb_table.batch_writer() as batch: + for item in response.get('Items', []): + batch.delete_item(Key={'pk': item['pk'], 'sk': item['sk']}) + except Exception as e: # noqa: BLE001 + logger.warning(f'Failed to delete records for provider {provider_id}: {str(e)}') + + logger.info(f'✅ Completed cleanup of {len(provider_ids)} provider records') + + +def verify_rollback_results(results: dict, expected_provider_count: int, expected_skipped_count: int = 0): + """ + Verify the rollback results match expected format and counts. + + :param results: Rollback results from S3 + :param expected_provider_count: Expected number of providers rolled back (reverted) + :param expected_skipped_count: Expected number of providers that should be skipped + """ + logger.info('Verifying rollback results...') + + # Verify structure + required_keys = ['revertedProviderSummaries', 'skippedProviderDetails', 'failedProviderDetails'] + for key in required_keys: + if key not in results: + raise SmokeTestFailureException(f'Missing required key in results: {key}') + + # Check counts + reverted = results['revertedProviderSummaries'] + skipped = results['skippedProviderDetails'] + failed = results['failedProviderDetails'] + + num_reverted = len(reverted) + num_skipped = len(skipped) + num_failed = len(failed) + + logger.info('Rollback summary:') + logger.info(f' - Reverted: {num_reverted}') + logger.info(f' - Skipped: {num_skipped}') + logger.info(f' - Failed: {num_failed}') + + # Verify skipped count matches expectation + if num_skipped != expected_skipped_count: + logger.error(f'Found {num_skipped} skipped providers, expected {expected_skipped_count}:') + for detail in skipped[:5]: # Show first 5 + logger.error(f'Details for skipped provider: {detail["providerId"]}', skipped=detail) + raise SmokeTestFailureException(f'Expected {expected_skipped_count} skipped providers but found {num_skipped}') + + if num_failed > 0: + logger.error(f'Found {num_failed} failed providers:') + for detail in failed[:5]: # Show first 5 + logger.error(f'Details for failed provider: {detail["providerId"]}', failed=detail) + raise SmokeTestFailureException(f'Expected 0 failed providers but found {num_failed}') + + # Verify we got the expected number of reverted providers + if num_reverted != expected_provider_count: + raise SmokeTestFailureException( + f'Expected {expected_provider_count} reverted providers but found {num_reverted}' + ) + + # Verify the reverted provider has the expected structure + for i, summary in enumerate(reverted): + if 'providerId' not in summary: + raise SmokeTestFailureException(f'Reverted provider summary {i} missing providerId') + if 'licensesReverted' not in summary: + raise SmokeTestFailureException(f'Reverted provider summary {i} missing licensesReverted') + if 'updatesDeleted' not in summary: + raise SmokeTestFailureException(f'Reverted provider summary {i} missing updatesDeleted') + + # Verify each license was deleted (not reverted to previous state) + licenses_reverted = summary['licensesReverted'] + if len(licenses_reverted) != 1: + raise SmokeTestFailureException( + f'Expected 1 license reverted for provider {summary["providerId"]}, found {len(licenses_reverted)}' + ) + + license_action = licenses_reverted[0]['action'] + if license_action != 'DELETE': + raise SmokeTestFailureException( + f'Expected license action "DELETE" but found "{license_action}" for provider {summary["providerId"]}' + ) + + # Verify that update records were deleted (should have at least 1 from the re-upload) + updates_deleted = summary['updatesDeleted'] + if len(updates_deleted) < 1: + raise SmokeTestFailureException( + f'Expected at least 1 update record deleted for provider {summary["providerId"]}, ' + f'found {len(updates_deleted)}' + ) + + logger.info('✅ Rollback results verification passed') + + +def verify_providers_deleted_from_database(results: dict, compact: str): + """ + Verify that all provider records were actually deleted from DynamoDB. + + :param results: Rollback results containing provider IDs + :param compact: Compact abbreviation + """ + logger.info('Verifying providers were deleted from database...') + + reverted_summaries = results['revertedProviderSummaries'] + + for i, summary in enumerate(reverted_summaries): + if i % 100 == 0: + logger.info(f'Verified deletion for {i}/{len(reverted_summaries)} providers') + + provider_id = summary['providerId'] + + # Try to get provider records - should return empty or raise exception + provider_user_records = get_provider_user_records(compact, provider_id) + + # Check if any records exist + all_records = provider_user_records.provider_records + if all_records: + raise SmokeTestFailureException( + f'Provider {provider_id} still has {len(all_records)} records in database after rollback' + ) + + logger.info(f'✅ Verified {len(reverted_summaries)} providers were deleted from database') + + +def rollback_license_upload_smoke_test(): + """ + Main smoke test for license upload rollback functionality. + + Steps: + 1. Upload test license records (first time) + 2. Upload test license records again with different address (creates update records) + 3. Wait for all providers to be created AND verify license update records exist in DynamoDB + 4. Store all provider IDs for cleanup + 5. Create privilege for first provider (should be skipped) + 6. Create encumbrance update for second provider (should be skipped) + 7. Start rollback step function + 8. Wait for step function completion + 9. Retrieve and verify results from S3 + 10. Verify providers were deleted from database (except 2 skipped) + 11. Clean up remaining test records + """ + global ALL_PROVIDER_IDS + + # Get environment configuration + step_function_arn = config.license_upload_rollback_step_function_arn + + if not step_function_arn: + raise SmokeTestFailureException('CC_TEST_ROLLBACK_STEP_FUNCTION_ARN environment variable not set') + + # staff user to query providers + staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + + # Create test app client for authentication + client_credentials = create_test_app_client(TEST_APP_CLIENT_NAME, COMPACT, JURISDICTION) + client_id = client_credentials['client_id'] + client_secret = client_credentials['client_secret'] + + skipped_provider_ids = [] + + try: + # Get authentication headers using app client + auth_headers = get_client_auth_headers(client_id, client_secret, COMPACT, JURISDICTION) + + run_family_name = f'RollbackTest-{uuid.uuid4().hex[:8]}' + logger.info(f'Run-scoped familyName for uploads and queries: {run_family_name}') + + # Step 1: Upload test licenses (first time) + logger.info('=' * 80) + logger.info('STEP 1: Uploading test licenses (first time)') + logger.info('=' * 80) + + first_upload_start_time = datetime.now(tz=UTC) + uploaded_licenses = upload_test_licenses( + auth_headers, + NUM_LICENSES_TO_UPLOAD, + BATCH_SIZE, + street_address='123 Test Street', + family_name=run_family_name, + ) + first_upload_end_time = datetime.now(tz=UTC) + logger.info( + f'First upload time window: {first_upload_start_time.isoformat()} to {first_upload_end_time.isoformat()}' + ) + + # Wait for first upload's license records to be created before second upload + logger.info('=' * 80) + logger.info('Waiting for first upload providers and license records to be created...') + logger.info('=' * 80) + time.sleep(10) + wait_for_all_providers_created( + staff_headers, + len(uploaded_licenses), + max_wait_time=FIRST_UPLOAD_PROVIDER_INGEST_MAX_WAIT_SEC, + family_name=run_family_name, + ) + logger.info('✅ All first upload license records have been created') + + # Step 2: Upload test licenses again with different address to create update records + logger.info('=' * 80) + logger.info('STEP 2: Uploading test licenses again with different address (creates update records)') + logger.info('=' * 80) + + upload_test_licenses( + auth_headers, + NUM_LICENSES_TO_UPLOAD, + BATCH_SIZE, + street_address='456 Updated Street', + family_name=run_family_name, + ) + + logger.info('Second upload completed - update records should be created') + + # Step 3: Wait for providers to be created and update records to propagate + logger.info('=' * 80) + logger.info('STEP 3: Waiting for provider records and update records to be created') + logger.info('=' * 80) + + logger.info(f'Waiting {SECOND_UPLOAD_INGEST_BUFFER_SEC}s for second-upload ingest') + time.sleep(SECOND_UPLOAD_INGEST_BUFFER_SEC) + + provider_ids = wait_for_all_providers_created( + staff_headers, + len(uploaded_licenses), + family_name=run_family_name, + ) + + # Store all provider IDs globally for cleanup + ALL_PROVIDER_IDS = provider_ids.copy() + + logger.info('Checking for license update records.') + verify_license_update_records_created(provider_ids) + # Capture end time after verifying update records exist + second_upload_end_time = datetime.now(tz=UTC) + + logger.info(f'Found {len(provider_ids)} provider records') + + # Step 4: Create encumbrance update for second provider (should be skipped in rollback) + logger.info('=' * 80) + logger.info('STEP 4: Creating encumbrance update for second provider to test skip condition') + logger.info('=' * 80) + + second_provider_id = provider_ids[1] + create_encumbrance_update_for_provider(second_provider_id, COMPACT, JURISDICTION) + skipped_provider_ids.append(second_provider_id) + logger.info(f'Created encumbrance update for provider {second_provider_id} - should be skipped in rollback') + + # Brief wait to ensure the manually created records are written + logger.info('Waiting briefly for test records to propagate...') + time.sleep(5) + + # Step 5: Start rollback step function + logger.info('=' * 80) + logger.info('STEP 5: Starting rollback step function') + logger.info('=' * 80) + + rollback_start = first_upload_start_time + # Add buffer to end time window to ensure we catch all uploads + rollback_end = second_upload_end_time + timedelta(minutes=5) + + execution_arn = start_rollback_step_function( + step_function_arn=step_function_arn, + compact=COMPACT, + jurisdiction=JURISDICTION, + start_datetime=rollback_start, + end_datetime=rollback_end, + ) + + # Step 6: Wait for step function completion + logger.info('=' * 80) + logger.info('STEP 6: Waiting for step function to complete') + logger.info('=' * 80) + + status, output = wait_for_step_function_completion(execution_arn) + + logger.info(f'Step function output: {json.dumps(output, indent=2)}') + + # Step 7: Retrieve and verify results from S3 + logger.info('=' * 80) + logger.info('STEP 7: Retrieving and verifying results from S3') + logger.info('=' * 80) + + results_s3_key = output.get('resultsS3Key') + if not results_s3_key: + raise SmokeTestFailureException('No resultsS3Key in step function output') + + results = get_rollback_results_from_s3(results_s3_key) + + # Expect all providers reverted except for the 1 skipped + expected_reverted = NUM_LICENSES_TO_UPLOAD - 1 + expected_skipped = 1 + verify_rollback_results(results, expected_reverted, expected_skipped) + + # Step 8: Verify providers deleted from database (except the skipped one) + logger.info('=' * 80) + logger.info('STEP 8: Verifying providers were deleted from database') + logger.info('=' * 80) + + verify_providers_deleted_from_database(results, COMPACT) + + # Step 9: Clean up the skipped provider records + logger.info('=' * 80) + logger.info('STEP 9: Cleaning up skipped provider records') + logger.info('=' * 80) + + delete_all_provider_records(skipped_provider_ids, COMPACT) + + logger.info('=' * 80) + logger.info('✅ ALL TESTS PASSED') + logger.info('=' * 80) + except Exception as e: + logger.error(f'Test failed: {str(e)}') + # If test failed, we need to clean up all provider records + if ALL_PROVIDER_IDS: + logger.info('=' * 80) + logger.info('CLEANUP: Test failed, cleaning up all provider records') + logger.info('=' * 80) + delete_all_provider_records(ALL_PROVIDER_IDS, COMPACT) + raise + finally: + # Clean up the test app client + delete_test_app_client(client_id) + + +if __name__ == '__main__': + load_smoke_test_env() + + # Create staff user with permission to upload licenses and run rollback + test_user_sub = create_test_staff_user( + email=TEST_STAFF_USER_EMAIL, + compact=COMPACT, + jurisdiction=JURISDICTION, + permissions={'actions': {'admin'}, 'jurisdictions': {JURISDICTION: {'write', 'admin'}}}, + ) + + try: + rollback_license_upload_smoke_test() + logger.info('🎉 License upload rollback smoke test completed successfully!') + except SmokeTestFailureException as e: + logger.error(f'❌ License upload rollback smoke test failed: {str(e)}') + raise + except Exception as e: + logger.error(f'❌ Unexpected error during smoke test: {str(e)}', exc_info=True) + raise + finally: + # Clean up the test staff user + delete_test_staff_user(TEST_STAFF_USER_EMAIL, user_sub=test_user_sub, compact=COMPACT) diff --git a/backend/social-work-app/tests/smoke/smoke_common.py b/backend/social-work-app/tests/smoke/smoke_common.py new file mode 100644 index 0000000000..395d8e838d --- /dev/null +++ b/backend/social-work-app/tests/smoke/smoke_common.py @@ -0,0 +1,635 @@ +import json +import os +import sys +import time + +import boto3 +import requests +from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError +from config import config, logger + + +class SmokeTestFailureException(Exception): + """Custom exception to raise when a smoke test fails.""" + + def __init__(self, message): + super().__init__(message) + + +provider_data_path = os.path.join('lambdas', 'python', 'staff-users') +common_lib_path = os.path.join('lambdas', 'python', 'common') +sys.path.append(provider_data_path) +sys.path.append(common_lib_path) + +with open('cdk.json') as context_file: + _context = json.load(context_file)['context'] +JURISDICTIONS = _context['jurisdictions'] +COMPACTS = _context['compacts'] +LICENSE_TYPES = _context['license_types'] + +os.environ['COMPACTS'] = json.dumps(COMPACTS) +os.environ['JURISDICTIONS'] = json.dumps(JURISDICTIONS) +os.environ['LICENSE_TYPES'] = json.dumps(LICENSE_TYPES) + +# We have to import this after we've added the common lib to our path and environment +from cc_common.data_model.provider_record_util import ProviderRecordUtility, ProviderUserRecords # noqa: E402 F401 + +# importing this here so it can be easily referenced in the rollback upload tests +from cc_common.data_model.schema.license import LicenseData, LicenseUpdateData # noqa: E402 F401 +from cc_common.data_model.schema.user.record import UserRecordSchema # noqa: E402 + +_TEST_STAFF_USER_PASSWORD = 'TestPass123!' # noqa: S105 test credential for test staff user +_TEMP_STAFF_PASSWORD = 'TempPass123!' # noqa: S105 temporary password for creating test staff users + + +def _create_staff_user_in_cognito(*, email: str) -> str: + """ + Creates a staff user in Cognito and returns the user's sub. + """ + + def get_sub_from_attributes(user_attributes: list): + for attribute in user_attributes: + if attribute['Name'] == 'sub': + return attribute['Value'] + raise ValueError('Failed to find user sub!') + + try: + user_data = config.cognito_client.admin_create_user( + UserPoolId=config.cognito_staff_user_pool_id, + Username=email, + UserAttributes=[{'Name': 'email', 'Value': email}], + TemporaryPassword=_TEMP_STAFF_PASSWORD, + ) + logger.info(f"Created staff user, '{email}'. Setting password.") + # set this to simplify login flow for user + config.cognito_client.admin_set_user_password( + UserPoolId=config.cognito_staff_user_pool_id, + Username=email, + Password=_TEST_STAFF_USER_PASSWORD, + Permanent=True, + ) + logger.info(f"Set password for staff user, '{email}' in Cognito. New user data: {user_data}") + return get_sub_from_attributes(user_data['User']['Attributes']) + + except ClientError as e: + if e.response['Error']['Code'] == 'UsernameExistsException': + logger.info(f"Staff user, '{email}', already exists in Cognito. Getting user data.") + user_data = config.cognito_client.admin_get_user( + UserPoolId=config.cognito_staff_user_pool_id, Username=email + ) + return get_sub_from_attributes(user_data['UserAttributes']) + + raise e + + +def delete_test_staff_user(email: str, user_sub: str, compact: str): + """Deletes a test staff user from Cognito. + + :param email: The email address of the staff user to delete + :param user_sub: The user's sub ID + :param compact: The compact identifier + """ + try: + logger.info(f"Deleting staff user from cognito, '{email}'") + config.cognito_client.admin_delete_user(UserPoolId=config.cognito_staff_user_pool_id, Username=email) + # now clean up the user record in DynamoDB + pk = f'USER#{user_sub}' + sk = f'COMPACT#{compact}' + logger.info(f"Deleting staff user record from DynamoDB, PK: '{pk}', SK: '{sk}'") + config.staff_users_dynamodb_table.delete_item(Key={'pk': pk, 'sk': sk}) + logger.info(f"Deleted staff user, '{email}', from Cognito and DynamoDB") + except ClientError as e: + logger.error(f"Failed to delete staff user data, '{email}': {str(e)}") + raise e + + +def create_test_staff_user(*, email: str, compact: str, jurisdiction: str, permissions: dict): + """Creates a test staff user in Cognito, stores their data in DynamoDB, and returns their user sub id. + + :param email: The email address of the staff user to create + :param compact: The compact identifier + :param jurisdiction: The jurisdiction identifier + :param permissions: The permissions dictionary for the user + :return: The staff user's sub ID + """ + logger.info(f"Creating staff user, '{email}', in {compact}/{jurisdiction}") + user_attributes = {'email': email, 'familyName': 'Dokes', 'givenName': 'Joe'} + sub = _create_staff_user_in_cognito(email=email) + schema = UserRecordSchema() + config.staff_users_dynamodb_table.put_item( + Item=schema.dump( + { + 'type': 'user', + 'userId': sub, + 'compact': compact, + 'attributes': user_attributes, + 'permissions': permissions, + 'status': 'active', + }, + ), + ) + logger.info(f'Created staff user record in DynamoDB. User data: {user_attributes}') + + return sub + + +def get_user_tokens(email, password=_TEST_STAFF_USER_PASSWORD, is_staff=False): + """ + Gets Cognito tokens for a user. + { + 'IdToken': 'string', + 'AccessToken': 'string', + 'RefreshToken': 'string', + 'ExpiresIn': 123, + 'TokenType': 'string', + 'NewDeviceMetadata': { + 'DeviceKey': 'string', + 'DeviceGroupKey': 'string' + } + } + """ + try: + logger.info('Getting tokens for user: ' + email + ' user type: ' + ('staff' if is_staff else 'provider')) + response = config.cognito_client.admin_initiate_auth( + UserPoolId=config.cognito_staff_user_pool_id if is_staff else config.cognito_provider_user_pool_id, + ClientId=config.cognito_staff_user_client_id if is_staff else config.cognito_provider_user_client_id, + AuthFlow='ADMIN_USER_PASSWORD_AUTH', + AuthParameters={'USERNAME': email, 'PASSWORD': password}, + ) + + return response['AuthenticationResult'] + + except ClientError as e: + logger.info(f'Failed to get tokens for user {email}: {str(e)}') + raise e + + +def get_staff_user_auth_headers(username: str, password: str = _TEST_STAFF_USER_PASSWORD): + tokens = get_user_tokens(username, password, is_staff=True) + return { + 'Authorization': 'Bearer ' + tokens['AccessToken'], + } + + +def get_license_type_abbreviation(license_type: str): + """ + Gets the abbreviation for a specific license type. + """ + all_license_types = [] + for compact in LICENSE_TYPES: + all_license_types.extend(LICENSE_TYPES[compact]) + return next((lt['abbreviation'] for lt in all_license_types if lt['name'] == license_type), None) + + +def get_api_base_url(): + return os.environ['CC_TEST_API_BASE_URL'] + + +def get_provider_user_dynamodb_table(): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_PROVIDER_DYNAMO_TABLE_NAME']) + + +def get_rate_limiting_dynamodb_table(): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME']) + + +def get_ssn_dynamodb_table(): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_SSN_DYNAMO_TABLE_NAME']) + + +def get_data_events_dynamodb_table(): + return boto3.resource('dynamodb').Table(os.environ['CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME']) + + +def get_lambda_client(): + return boto3.client('lambda') + + +def load_smoke_test_env(): + with open(os.path.join(os.path.dirname(__file__), 'smoke_tests_env.json')) as env_file: + env_vars = json.load(env_file) + os.environ.update(env_vars) + + +def call_provider_details_endpoint(headers: dict, compact: str, provider_id: str) -> dict: + """GET /v1/compacts/{compact}/providers/{provider_id} with staff (or other) auth headers.""" + url = f'{config.api_base_url}/v1/compacts/{compact}/providers/{provider_id}' + response = requests.get(url=url, headers=headers, timeout=10) + + if response.status_code != 200: + try: + body = response.json() + except Exception: # noqa: BLE001 + body = response.text + raise SmokeTestFailureException(f'Failed to GET provider details. Response: {body}') + return response.json() + + +def wait_for_opensearch_sync() -> None: + """Wait for provider table changes to propagate into OpenSearch (best-effort fixed delay).""" + seconds = 30 + logger.info(f'Waiting {seconds}s for changes to propagate in OpenSearch...') + time.sleep(seconds) + + +def get_most_recently_issued_or_renewed_license(licenses: list[dict]) -> dict: + return ProviderRecordUtility.find_most_recently_issued_or_renewed_license(licenses) + + +_PUBLIC_QUERY_INTERNAL_MAX_PAGES = 500 + + +def call_public_query_providers( + compact: str, + *, + provider_id_filter: str, + first_name_filter: str | None = None, + last_name_filter: str | None = None, + license_number_filter: str | None = None, + page_size: int = 100, + timeout: int = 30, +) -> list[dict]: + """ + POST /v1/public/compacts/{compact}/providers/query (no auth). + + Builds query body from provided filters + """ + url = f'{config.api_base_url}/v1/public/compacts/{compact}/providers/query' + headers = {'Content-Type': 'application/json'} + query_parameters: dict = {} + if first_name_filter: + query_parameters['givenName'] = first_name_filter + if last_name_filter: + query_parameters['familyName'] = last_name_filter + if license_number_filter: + query_parameters['licenseNumber'] = license_number_filter + + pagination_state: dict = {'pageSize': page_size} + matching_license_rows: list[dict] = [] + + for _page_index in range(_PUBLIC_QUERY_INTERNAL_MAX_PAGES): + request_body = {'query': query_parameters, 'pagination': pagination_state} + response = requests.post(url=url, headers=headers, json=request_body, timeout=timeout) + if response.status_code != 200: + raise SmokeTestFailureException(f'Failed POST public query providers. Response: {response.json()}') + page_response_body = response.json() + + for provider_license_row in page_response_body.get('providers') or []: + if provider_id_filter and provider_license_row.get('providerId') == provider_id_filter: + matching_license_rows.append(provider_license_row) + + last_pagination_key = (page_response_body.get('pagination') or {}).get('lastKey') + if not last_pagination_key: + break + pagination_state = {**pagination_state, 'lastKey': last_pagination_key} + + return matching_license_rows + + +def call_public_get_provider(compact: str, provider_id: str, *, timeout: int = 30) -> dict: + """GET /v1/public/compacts/{compact}/providers/{provider_id} (no auth).""" + url = f'{config.api_base_url}/v1/public/compacts/{compact}/providers/{provider_id}' + response = requests.get(url=url, timeout=timeout) + + if response.status_code != 200: + raise SmokeTestFailureException(f'Failed GET public provider. Response: {response.json()}') + return response.json() + + +def get_all_provider_database_records(compact: str = 'socw', provider_id: str = None): + + if provider_id is None: + provider_id = config.test_provider_id + + logger.info('Querying records for provider', provider_id=provider_id) + items: list = [] + last_evaluated_key = None + while True: + pagination = {'ExclusiveStartKey': last_evaluated_key} if last_evaluated_key else {} + query_result = config.provider_user_dynamodb_table.query( + KeyConditionExpression=Key('pk').eq(f'{compact}#PROVIDER#{provider_id}'), + **pagination, + ) + items.extend(query_result.get('Items', [])) + last_evaluated_key = query_result.get('LastEvaluatedKey') + if not last_evaluated_key: + break + + return items + + +def get_provider_user_records(compact: str, provider_id: str) -> ProviderUserRecords: + """ + Get all provider records from DynamoDB and return as ProviderUserRecords utility class. + + :param compact: The compact identifier + :param provider_id: The provider's ID + :return: ProviderUserRecords instance containing all records for this provider + """ + # Query the provider database for all records + resp = {'Items': []} + last_evaluated_key = None + while True: + pagination = {'ExclusiveStartKey': last_evaluated_key} if last_evaluated_key else {} + # Grab all records under the provider partition + query_resp = config.provider_user_dynamodb_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq(f'{compact}#PROVIDER#{provider_id}'), + ConsistentRead=True, + **pagination, + ) + + resp['Items'].extend(query_resp.get('Items', [])) + + last_evaluated_key = query_resp.get('LastEvaluatedKey') + if not last_evaluated_key: + break + + return ProviderUserRecords(resp['Items']) + + +def upload_license_record(staff_headers: dict, compact: str, jurisdiction: str, data_overrides: dict = None): + """Upload a license record using the API with default test data that can be overridden. + + :param staff_headers: Authentication headers for staff user + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction abbreviation + :param data_overrides: Dict of fields to override in the default license data + :return: The API response JSON + """ + # Default test license data + default_license_data = { + 'licenseNumber': 'TEST-LIC-123', + 'homeAddressPostalCode': '68001', + 'givenName': 'TestProvider', + 'familyName': 'LicenseDeactivation', + 'homeAddressStreet1': '123 Test Street', + 'dateOfBirth': '1985-01-01', + 'dateOfIssuance': '2020-01-01', + 'ssn': '999-99-9999', + 'licenseType': 'cosmetologist', + 'dateOfExpiration': '2050-01-01', + 'homeAddressState': 'AZ', + 'homeAddressCity': 'Omaha', + 'licenseStatus': 'active', + 'compactEligibility': 'eligible', + 'emailAddress': 'test-license@example.com', + 'phoneNumber': '+15551234567', + } + + # Apply any overrides + if data_overrides: + default_license_data.update(data_overrides) + + post_body = [default_license_data] + + logger.info( + f'Uploading license record for {jurisdiction} with status "{default_license_data.get("licenseStatus")}"' + ) + + post_response = requests.post( + url=f'{config.api_base_url}/v1/compacts/{compact}/jurisdictions/{jurisdiction}/licenses', + headers=staff_headers, + json=post_body, + timeout=30, + ) + + if post_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to upload license record. Response: {post_response.json()}') + + logger.info(f'License record successfully uploaded with status "{default_license_data.get("licenseStatus")}"') + return post_response.json() + + +def query_provider_by_name(staff_headers: dict, compact: str, given_name: str, family_name: str): + """Query for a provider by name and return the provider ID if found. + + :param staff_headers: Authentication headers for staff user + :param compact: The compact abbreviation + :param given_name: Provider's given name + :param family_name: Provider's family name + :return: The provider ID if found, None otherwise + """ + query_body = {'query': {'familyName': family_name, 'givenName': given_name}} + + query_response = requests.post( + url=f'{config.api_base_url}/v1/compacts/{compact}/providers/query', + headers=staff_headers, + json=query_body, + timeout=10, + ) + + if query_response.status_code != 200: + logger.warning(f'Query failed with status {query_response.status_code}') + return None + + providers = query_response.json().get('providers', []) + if providers: + # Return the first provider id in the list (leave it to the smoke tests to uniquely name their test users) + return providers[0].get('providerId') + + return None + + +def wait_for_provider_creation( + staff_headers: dict, + compact: str, + given_name: str, + family_name: str, + max_wait_time: int = 300, + staff_user_email: str | None = None, + poll_interval_seconds: int = 30, +): + """Poll for provider creation after license upload. + + :param staff_headers: Authentication headers for staff user + :param compact: The compact abbreviation + :param given_name: Provider's given name + :param family_name: Provider's family name + :param max_wait_time: Maximum time to wait in seconds (default: 300 = 5 minutes) + :param staff_user_email: Optional staff email; if provided, refresh auth headers on every poll attempt + :param poll_interval_seconds: Poll interval in seconds (default: 30) + :return: The provider ID when found + :raises SmokeTestFailureException: If provider not found within max_wait_time + """ + import time + + logger.info(f'Waiting for provider creation for {given_name} {family_name}...') + + start_time = time.time() + attempts = 0 + max_attempts = max_wait_time // poll_interval_seconds + + while attempts < max_attempts: + attempts += 1 + + headers = get_staff_user_auth_headers(staff_user_email) if staff_user_email else staff_headers + provider_id = query_provider_by_name(headers, compact, given_name, family_name) + if provider_id: + elapsed_time = time.time() - start_time + logger.info(f'✅ Provider found after {elapsed_time:.1f} seconds. Provider ID: {provider_id}') + return provider_id + + if attempts < max_attempts: + logger.info( + f'Attempt {attempts}/{max_attempts}: Provider not found yet. Waiting {poll_interval_seconds} seconds...' + ) + time.sleep(poll_interval_seconds) + + elapsed_time = time.time() - start_time + raise SmokeTestFailureException( + f'Provider not found after {elapsed_time:.1f} seconds. ' + f'The license ingest processing may be taking longer than expected.' + ) + + +def cleanup_test_provider_records(provider_id: str, compact: str): + """Clean up all test records for a provider. + + :param provider_id: The provider's ID + :param compact: The compact abbreviation + """ + try: + # Query for all provider records + provider_record_query_response = config.provider_user_dynamodb_table.query( + KeyConditionExpression=Key('pk').eq(f'{compact}#PROVIDER#{provider_id}') + ) + + # Delete all provider records + deleted_count = 0 + for record in provider_record_query_response.get('Items', []): + config.provider_user_dynamodb_table.delete_item(Key={'pk': record['pk'], 'sk': record['sk']}) + deleted_count += 1 + + logger.info(f'Successfully deleted {deleted_count} provider records from provider table') + + except Exception as e: # noqa: BLE001 + logger.warning(f'Error during cleanup: {str(e)}') + + +def create_test_app_client( + client_name: str, + compact: str, + jurisdiction: str | None = None, + jurisdictions: list[str] | None = None, +): + """ + Create a test app client in Cognito for authentication testing. + + :param client_name: Name for the test app client + :param compact: Compact abbreviation + :param jurisdiction: Jurisdiction abbreviation (backward-compatible single value) + :param jurisdictions: Optional list of jurisdiction abbreviations for write scopes + :return: Dictionary containing client_id and client_secret + """ + logger.info(f'Creating test app client: {client_name}') + + try: + cognito_client = boto3.client('cognito-idp') + jurisdiction_list = jurisdictions if jurisdictions else ([jurisdiction] if jurisdiction else []) + if not jurisdiction_list: + raise SmokeTestFailureException('At least one jurisdiction is required to create a test app client') + + allowed_scopes = [ + f'{compact}/readGeneral', + *[f'{jurisdiction}/{compact}.write' for jurisdiction in jurisdiction_list], + ] + + # Create the user pool client + response = cognito_client.create_user_pool_client( + UserPoolId=config.cognito_state_auth_user_pool_id, + ClientName=client_name, + PreventUserExistenceErrors='ENABLED', + GenerateSecret=True, + TokenValidityUnits={'AccessToken': 'minutes'}, + AccessTokenValidity=15, + AllowedOAuthFlowsUserPoolClient=True, + AllowedOAuthFlows=['client_credentials'], + AllowedOAuthScopes=allowed_scopes, + ) + + user_pool_client = response.get('UserPoolClient', {}) + client_id = user_pool_client.get('ClientId') + client_secret = user_pool_client.get('ClientSecret') + + if not client_id or not client_secret: + raise SmokeTestFailureException('Failed to extract client ID or secret from AWS response') + + logger.info(f'Successfully created test app client with ID: {client_id}') + return {'client_id': client_id, 'client_secret': client_secret} + + except ClientError as e: + error_code = e.response['Error']['Code'] + error_message = e.response['Error']['Message'] + logger.error(f'Failed to create app client: {error_code} - {error_message}') + raise SmokeTestFailureException(f'Failed to create app client: {error_code} - {error_message}') from e + + +def delete_test_app_client(client_id: str): + """Delete the test app client from Cognito.""" + try: + cognito_client = boto3.client('cognito-idp') + cognito_client.delete_user_pool_client(UserPoolId=config.cognito_state_auth_user_pool_id, ClientId=client_id) + logger.info(f'Successfully deleted test app client: {client_id}') + except ClientError as e: + logger.error(f'Failed to delete app client {client_id}: {str(e)}') + # Don't raise here as this is cleanup + + +def get_client_credentials_token(client_id: str, client_secret: str, compact: str, jurisdiction: str): + """ + Get an access token using client credentials flow. + + :param client_id: The client ID + :param client_secret: The client secret + :param compact: Compact abbreviation + :param jurisdiction: Jurisdiction abbreviation + :return: Access token + """ + try: + auth_url = config.state_auth_url + + # Prepare the request data for client credentials flow + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id, + 'client_secret': client_secret, + 'scope': f'{compact}/readGeneral {jurisdiction}/{compact}.write', + } + + headers = {'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'} + + response = requests.post(auth_url, data=data, headers=headers, timeout=10) + + if response.status_code != 200: + raise SmokeTestFailureException( + f'Failed to get access token. Status: {response.status_code}, Response: {response.text}' + ) + + token_data = response.json() + access_token = token_data.get('access_token') + + if not access_token: + raise SmokeTestFailureException('No access token in response') + + logger.info('Successfully obtained access token using client credentials') + return access_token + + except requests.RequestException as e: + logger.error(f'Failed to get client credentials token: {str(e)}') + raise SmokeTestFailureException(f'Failed to get client credentials token: {str(e)}') from e + + +def get_client_auth_headers(client_id: str, client_secret: str, compact: str, jurisdiction: str): + """ + Get authentication headers for client credentials flow. + + :param client_id: The client ID + :param client_secret: The client secret + :param compact: Compact abbreviation + :param jurisdiction: Jurisdiction abbreviation + :return: Headers dictionary with Authorization header + """ + access_token = get_client_credentials_token(client_id, client_secret, compact, jurisdiction) + return {'Authorization': f'Bearer {access_token}'} diff --git a/backend/social-work-app/tests/smoke/smoke_tests_env_example.json b/backend/social-work-app/tests/smoke/smoke_tests_env_example.json new file mode 100644 index 0000000000..b2416e8688 --- /dev/null +++ b/backend/social-work-app/tests/smoke/smoke_tests_env_example.json @@ -0,0 +1,18 @@ +{ + "CC_TEST_API_BASE_URL": "https://api.test.compactconnect.org", + "CC_TEST_STATE_API_BASE_URL": "https://state-api.test.compactconnect.org", + "CC_TEST_STATE_AUTH_URL": "https://compact-connect-state-auth-test.auth.us-east-1.amazoncognito.com/oauth2/token", + "CC_TEST_COGNITO_STATE_AUTH_USER_POOL_ID": "us-east-1_12345", + "CC_TEST_PROVIDER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-ProviderTable12345", + "CC_TEST_COMPACT_CONFIGURATION_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-CompactConfigTable12345", + "CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-RateLimitingTable12345", + "CC_TEST_SSN_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-SSNTable12345", + "CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-DataEventTable1234", + "CC_TEST_STAFF_USER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-StaffUserTable1234", + "CC_TEST_COGNITO_STAFF_USER_POOL_ID": "us-east-1_12345", + "CC_TEST_COGNITO_STAFF_USER_POOL_CLIENT_ID": "72612345", + "CC_TEST_PROVIDER_ID": "exampleProviderId", + "ENVIRONMENT_NAME": "sandboxEnvironmentNamePlaceholder", + "CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL": "smoke-test-notifications@example.com", + "CC_TEST_ROLLBACK_STEP_FUNCTION_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:Sandbox-DisasterRecoveryStack-LicenseUploadRollbackStateMachine" +}