diff --git a/.gitignore b/.gitignore index c9cd3fba4..970004fc9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Idea .idea +# VS Code +.vscode + #OS .DS_Store .tmp diff --git a/backend/bin/compile_requirements.sh b/backend/bin/compile_requirements.sh index f21f9095e..0b4f0a965 100755 --- a/backend/bin/compile_requirements.sh +++ b/backend/bin/compile_requirements.sh @@ -1,21 +1,23 @@ set -e -pip-compile --no-emit-index-url --upgrade multi-account/requirements-dev.in -pip-compile --no-emit-index-url --upgrade multi-account/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/common/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/common/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/custom-resources/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/custom-resources/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/data-events/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/data-events/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/provider-data-v1/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/provider-data-v1/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/purchases/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/purchases/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/staff-user-pre-token/requirements.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/staff-users/requirements-dev.in -pip-compile --no-emit-index-url --upgrade compact-connect/lambdas/python/staff-users/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras multi-account/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras multi-account/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/attestations/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/attestations/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/common/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/common/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/custom-resources/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/custom-resources/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/data-events/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/data-events/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/provider-data-v1/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/provider-data-v1/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/purchases/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/purchases/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/staff-user-pre-token/requirements.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/staff-users/requirements-dev.in +pip-compile --no-emit-index-url --upgrade --no-strip-extras compact-connect/lambdas/python/staff-users/requirements.in bin/sync_deps.sh diff --git a/backend/bin/run_tests.sh b/backend/bin/run_tests.sh index f117ca78d..5ee69511f 100755 --- a/backend/bin/run_tests.sh +++ b/backend/bin/run_tests.sh @@ -25,13 +25,14 @@ if [[ "$LANGUAGE" == 'python' || "$LANGUAGE" == 'all' ]]; then pytest --cov=. --cov-config=.coveragerc tests ) || exit "$?" for dir in \ - compact-connect/lambdas/python/common \ + compact-connect/lambdas/python/attestations \ compact-connect/lambdas/python/custom-resources \ compact-connect/lambdas/python/data-events \ compact-connect/lambdas/python/provider-data-v1 \ compact-connect/lambdas/python/purchases \ compact-connect/lambdas/python/staff-user-pre-token \ compact-connect/lambdas/python/staff-users \ + compact-connect/lambdas/python/common \ multi-account do ( diff --git a/backend/bin/sync_deps.sh b/backend/bin/sync_deps.sh index 9c09b3e98..4e6f33021 100755 --- a/backend/bin/sync_deps.sh +++ b/backend/bin/sync_deps.sh @@ -8,6 +8,8 @@ pip-sync \ multi-account/requirements.txt \ compact-connect/requirements-dev.txt \ compact-connect/requirements.txt \ + compact-connect/lambdas/python/attestations/requirements-dev.txt \ + compact-connect/lambdas/python/attestations/requirements.txt \ compact-connect/lambdas/python/common/requirements-dev.txt \ compact-connect/lambdas/python/common/requirements.txt \ compact-connect/lambdas/python/custom-resources/requirements-dev.txt \ diff --git a/backend/compact-connect/README.md b/backend/compact-connect/README.md index d52f9d80a..52eaf0201 100644 --- a/backend/compact-connect/README.md +++ b/backend/compact-connect/README.md @@ -63,7 +63,7 @@ $ cdk synth ``` For development work there are additional requirements in `requirements-dev.txt` to install with -`pip install -r requirements.txt`. +`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. @@ -118,7 +118,7 @@ its environment: 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` in your environment's context. + 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. diff --git a/backend/compact-connect/bin/create_provider_user.py b/backend/compact-connect/bin/create_provider_user.py new file mode 100755 index 000000000..f0263ea5a --- /dev/null +++ b/backend/compact-connect/bin/create_provider_user.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Provider user generation helper script. Run from `backend/compact-connect`. + +Note: This script requires the boto3 library and the environment variable: +# The provider user pool id +USER_POOL_ID=us-east-1_7zzexample + +The CLI must also be configured with AWS credentials that have appropriate access to Cognito +""" + +import json +import os +import sys + +import boto3 +from botocore.exceptions import ClientError + +with open('cdk.json') as context_file: + _context = json.load(context_file)['context'] + +COMPACTS = _context['compacts'] +USER_POOL_ID = os.environ['USER_POOL_ID'] + + +cognito_client = boto3.client('cognito-idp') + + +def create_cognito_user(*, email: str, compact: str, provider_id: str): + sys.stdout.write(f"Creating new provider user, '{email}', in {compact}") + + 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 = cognito_client.admin_create_user( + UserPoolId=USER_POOL_ID, + Username=email, + UserAttributes=[ + {'Name': 'email', 'Value': email}, + {'Name': 'custom:compact', 'Value': compact}, + {'Name': 'custom:providerId', 'Value': provider_id}, + ], + DesiredDeliveryMediums=['EMAIL'], + ) + return get_sub_from_attributes(user_data['User']['Attributes']) + + except ClientError as e: + if e.response['Error']['Code'] == 'UsernameExistsException': + user_data = cognito_client.admin_get_user(UserPoolId=USER_POOL_ID, Username=email) + return get_sub_from_attributes(user_data['UserAttributes']) + raise + + +if __name__ == '__main__': + from argparse import ArgumentParser + + parser = ArgumentParser( + description='Create a provider user', + epilog='example: bin/create_provider_user.py -e justin@example.org -c octp -p ac5f9901-e4e6-4a2e-8982-27d2517a3ab8', # noqa: E501 line-too-long + ) + parser.add_argument('-e', '--email', help="The new user's email address", required=True) + parser.add_argument('-c', '--compact', help="The new user's compact", required=True, choices=COMPACTS) + parser.add_argument('-p', '--provider-id', help="The new user's associated provider id", required=True) + + args = parser.parse_args() + + create_cognito_user(email=args.email, compact=args.compact, provider_id=args.provider_id) diff --git a/backend/compact-connect/bin/create_staff_user.py b/backend/compact-connect/bin/create_staff_user.py index 499721bcc..fe547068e 100755 --- a/backend/compact-connect/bin/create_staff_user.py +++ b/backend/compact-connect/bin/create_staff_user.py @@ -17,6 +17,7 @@ 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) diff --git a/backend/compact-connect/bin/generate_mock_data.py b/backend/compact-connect/bin/generate_mock_data.py index dd8f0e76c..f7c19a351 100755 --- a/backend/compact-connect/bin/generate_mock_data.py +++ b/backend/compact-connect/bin/generate_mock_data.py @@ -23,16 +23,17 @@ COMPACTS = _context['compacts'] LICENSE_TYPES = _context['license_types'] + os.environ['COMPACTS'] = json.dumps(COMPACTS) os.environ['JURISDICTIONS'] = json.dumps(JURISDICTIONS) -from cc_common.data_model.schema.license import LicensePostSchema # noqa: E402 +from cc_common.data_model.schema.license.api import LicensePostRequestSchema # noqa: E402 # We'll grab three different localizations to provide a variety of names/characters name_faker = Faker(['en_US', 'ja_JP', 'es_MX']) faker = Faker(['en_US']) -schema = LicensePostSchema() +schema = LicensePostRequestSchema() FIELDS = ( 'ssn', @@ -59,7 +60,7 @@ def generate_mock_csv_file(count, *, compact: str, jurisdiction: str = None): - with open('mock-data.csv', 'w', encoding='utf-8') as data_file: + with open(f'{compact}-{jurisdiction}-mock-data.csv', 'w', encoding='utf-8') as data_file: writer = DictWriter(data_file, fieldnames=FIELDS) writer.writeheader() for row in generate_csv_rows(count, compact=compact, jurisdiction=jurisdiction): diff --git a/backend/compact-connect/common_constructs/nodejs_function.py b/backend/compact-connect/common_constructs/nodejs_function.py index 65cf82408..aec44de91 100644 --- a/backend/compact-connect/common_constructs/nodejs_function.py +++ b/backend/compact-connect/common_constructs/nodejs_function.py @@ -71,7 +71,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', ], 'reason': 'The BasicExecutionRole policy is appropriate for these lambdas', @@ -84,7 +84,9 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', # noqa: E501 line-too-long + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], # noqa: E501 line-too-long 'reason': 'This policy is appropriate for the log retention lambda', }, ], @@ -95,7 +97,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': 'This lambda needs to be able to configure log groups across the account, though the' ' actions it is allowed are scoped specifically for this task.', }, diff --git a/backend/compact-connect/common_constructs/python_function.py b/backend/compact-connect/common_constructs/python_function.py index 5839ba156..0038d9a07 100644 --- a/backend/compact-connect/common_constructs/python_function.py +++ b/backend/compact-connect/common_constructs/python_function.py @@ -2,10 +2,10 @@ import os -import stacks.persistent_stack as ps from aws_cdk import Duration, Stack 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 from aws_cdk.aws_lambda import ILayerVersion, Runtime from aws_cdk.aws_lambda_python_alpha import PythonFunction as CdkPythonFunction from aws_cdk.aws_lambda_python_alpha import PythonLayerVersion @@ -14,6 +14,7 @@ from aws_cdk.aws_ssm import StringParameter from cdk_nag import NagSuppressions from constructs import Construct +from stacks import persistent_stack as ps COMMON_PYTHON_LAMBDA_LAYER_SSM_PARAMETER_NAME = '/deployment/lambda/layers/common-python-layer-arn' @@ -31,8 +32,9 @@ def __init__( construct_id: str, *, lambda_dir: str, - log_retention: RetentionDays = RetentionDays.ONE_MONTH, + log_retention: RetentionDays = RetentionDays.INFINITE, alarm_topic: ITopic = None, + role: IRole = None, **kwargs, ): defaults = { @@ -46,6 +48,7 @@ def __init__( entry=os.path.join('lambdas', 'python', lambda_dir), runtime=Runtime.PYTHON_3_12, log_retention=log_retention, + role=role, **defaults, ) self.add_layers(self._get_common_layer()) @@ -72,26 +75,32 @@ def __init__( }, ], ) - NagSuppressions.add_resource_suppressions_by_path( - stack, - path=f'{self.node.path}/ServiceRole/Resource', - suppressions=[ - { - 'id': 'AwsSolutions-IAM4', - 'applies_to': [ - 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', - ], - 'reason': 'The BasicExecutionRole policy is appropriate for these lambdas', - }, - ], - ) + + # If a role is provided from elsewhere for this lambda (role is not None), we don't need to run suppressions for + # the role that this construct normally creates. + if role is None: + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{self.node.path}/ServiceRole/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, path=f'{stack.node.path}/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource', suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', # noqa: E501 line-too-long + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], # noqa: E501 line-too-long 'reason': 'This policy is appropriate for the log retention lambda', }, ], @@ -102,7 +111,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': 'This lambda needs to be able to configure log groups across the account, though the' ' actions it is allowed are scoped specifically for this task.', }, diff --git a/backend/compact-connect/common_constructs/queued_lambda_processor.py b/backend/compact-connect/common_constructs/queued_lambda_processor.py index 02645a5de..4630f77f2 100644 --- a/backend/compact-connect/common_constructs/queued_lambda_processor.py +++ b/backend/compact-connect/common_constructs/queued_lambda_processor.py @@ -1,9 +1,9 @@ -from aws_cdk import Duration +from aws_cdk import Duration, Names 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_lambda_event_sources import SqsEventSource 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 @@ -51,14 +51,46 @@ def __init__( dead_letter_queue=DeadLetterQueue(max_receive_count=max_receive_count, queue=self.dlq), ) - process_function.add_event_source( - SqsEventSource( - self.queue, - batch_size=batch_size, - max_batching_window=max_batching_window, - report_batch_item_failures=True, - ), + # 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. + process_function.add_event_source_mapping( + f'SqsEventSource:{Names.node_unique_id(self.queue.node)}', + 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 ) diff --git a/backend/compact-connect/common_constructs/user_pool.py b/backend/compact-connect/common_constructs/user_pool.py index 3a6545b19..b77366346 100644 --- a/backend/compact-connect/common_constructs/user_pool.py +++ b/backend/compact-connect/common_constructs/user_pool.py @@ -117,7 +117,8 @@ def __init__( # pylint: disable=too-many-arguments def add_ui_client( self, - callback_urls: list[str], + ui_domain_name: str, + environment_context: dict, read_attributes: ClientAttributes, write_attributes: ClientAttributes, ui_scopes: list[OAuthScope] = None, @@ -125,21 +126,54 @@ def add_ui_client( """ Creates an app client for the UI to authenticate with the user pool. - :param callback_urls: The URLs that Cognito allows the UI to redirect to after authentication. + :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}/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}/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=False, + # we allow this in test environments for automated testing + admin_user_password=self.security_profile == SecurityProfile.VULNERABLE, custom=False, user_srp=self.security_profile == SecurityProfile.VULNERABLE, 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, ), diff --git a/backend/compact-connect/compact-config/aslp.yml b/backend/compact-connect/compact-config/aslp.yml index 48ea61585..b0a38c397 100644 --- a/backend/compact-connect/compact-config/aslp.yml +++ b/backend/compact-connect/compact-config/aslp.yml @@ -8,3 +8,77 @@ compactAdverseActionsNotificationEmails: [] compactSummaryReportNotificationEmails: [] activeEnvironments: ["test"] + +attestations: + - attestationId: "jurisprudence-confirmation" + displayName: "Jurisprudence Confirmation." + description: "For displaying the jurisprudence confirmation." + text: |- + I understand that an attestation is a legally binding statement. I understand that providing false information on this application could result in a loss of my licenses and/or privileges. I acknowledge that the Commission may audit jurisprudence attestations at their discretion. + required: true + locale: "en" + - attestationId: "scope-of-practice-attestation" + displayName: "Scope of Practice Attestation" + description: "For displaying the scope of practice attestation." + text: |- + I hereby attest and affirm that I have reviewed, understand, and will abide by this state's scope of practice and all applicable laws and rules when practicing in the state. I understand that the issuance of a Compact Privilege authorizes me to legally practice in the member jurisdiction in accordance with the laws and rules governing practice of my profession in that jurisdiction. + + If I violate the practice act, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years. + required: true + locale: "en" + - attestationId: "personal-information-home-state-attestation" + displayName: "Personal Information Home State Attestation" + description: "For declaring that the applicant is a resident of the state they have listed as their home state." + text: |- + I hereby attest and affirm that this is my personal and licensure information and that I am a resident of the state listed on this page.* + required: true + locale: "en" + - attestationId: "personal-information-address-attestation" + displayName: "Personal Information Address Attestation" + description: "For declaring that the applicant is a resident of the state they have listed as their home state." + text: |- + I hereby attest and affirm that the address information I have provided herein and is my current address. I further consent to accept service of process at this address. I will notify the Commission of a change in my Home State address or email address via updating personal information records in this system. I understand that I am only eligible for a Compact Privilege if I am a licensee in my Home State as defined by the Compact. If I mislead the Compact Commission about my Home State, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years.* + required: true + locale: "en" + - attestationId: "not-under-investigation-attestation" + displayName: "Not Under Investigation Attestation" + description: "For declaring that the applicant is not currently under investigation." + text: |- + I hereby attest and affirm that I am not currently under investigation by any board, agency, department, association, certifying body, or other body. + required: true + locale: "en" + - attestationId: "under-investigation-attestation" + displayName: "Under Investigation Attestation" + description: "For declaring that the applicant is currently under investigation." + text: |- + I hereby attest and affirm that I am currently under investigation by any board, agency, department, association, certifying body, or other body. I understand that if any investigation results in a disciplinary action, my Compact Privileges may be revoked. + required: true + locale: "en" + - attestationId: "discipline-no-current-encumbrance-attestation" + displayName: "No Current Discipline Encumbrance Attestation" + description: "For declaring that the applicant has no encumbrances on any state license." + text: |- + I hereby attest and affirm that I have no encumbrance (any discipline that restricts my full practice or any unmet condition before returning to a full and unrestricted license, including, but not limited, to probation, supervision, completion of a program, and/or completion of CEs) on ANY state license. + required: true + locale: "en" + - attestationId: "discipline-no-prior-encumbrance-attestation" + displayName: "No Discipline Encumbrance For Prior Two Yeats Attestation" + description: "For declaring that the applicant has no encumbrances on any state license within the last two years." + text: |- + I hereby attest and affirm that I have not had any encumbrance on ANY state license within the previous two years from date of this application for a Compact Privilege. + required: true + locale: "en" + - attestationId: "provision-of-true-information-attestation" + displayName: "Provision of True Information Attestation" + description: "For declaring that the applicant has provided true information." + text: |- + I hereby attest and affirm that all information contained in this privilege application is true to the best of my knowledge. + required: true + locale: "en" + - attestationId: "military-affiliation-confirmation-attestation" + displayName: "Military Affiliation Confirmation Attestation" + description: "For declaring that the applicant's military affiliation documentation is accurate." + text: |- + I hereby attest and affirm that my current military status documentation as uploaded to CompactConnect is accurate. + required: true + locale: "en" diff --git a/backend/compact-connect/compact-config/coun.yml b/backend/compact-connect/compact-config/coun.yml index 5bf632e89..9a02de0bf 100644 --- a/backend/compact-connect/compact-config/coun.yml +++ b/backend/compact-connect/compact-config/coun.yml @@ -7,3 +7,77 @@ compactOperationsTeamEmails: [] compactAdverseActionsNotificationEmails: [] compactSummaryReportNotificationEmails: [] activeEnvironments: [] + +attestations: + - attestationId: "jurisprudence-confirmation" + displayName: "Jurisprudence Confirmation." + description: "For displaying the jurisprudence confirmation." + text: |- + I understand that an attestation is a legally binding statement. I understand that providing false information on this application could result in a loss of my licenses and/or privileges. I acknowledge that the Commission may audit jurisprudence attestations at their discretion. + required: true + locale: "en" + - attestationId: "scope-of-practice-attestation" + displayName: "Scope of Practice Attestation" + description: "For displaying the scope of practice attestation." + text: |- + I hereby attest and affirm that I have reviewed, understand, and will abide by this state's scope of practice and all applicable laws and rules when practicing in the state. I understand that the issuance of a Compact Privilege authorizes me to legally practice in the member jurisdiction in accordance with the laws and rules governing practice of my profession in that jurisdiction. + + If I violate the practice act, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years. + required: true + locale: "en" + - attestationId: "personal-information-home-state-attestation" + displayName: "Personal Information Home State Attestation" + description: "For declaring that the applicant is a resident of the state they have listed as their home state." + text: |- + I hereby attest and affirm that this is my personal and licensure information and that I am a resident of the state listed on this page.* + required: true + locale: "en" + - attestationId: "personal-information-address-attestation" + displayName: "Personal Information Address Attestation" + description: "For declaring that the applicant is a resident of the state they have listed as their home state." + text: |- + I hereby attest and affirm that the address information I have provided herein and is my current address. I further consent to accept service of process at this address. I will notify the Commission of a change in my Home State address or email address via updating personal information records in this system. I understand that I am only eligible for a Compact Privilege if I am a licensee in my Home State as defined by the Compact. If I mislead the Compact Commission about my Home State, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years.* + required: true + locale: "en" + - attestationId: "not-under-investigation-attestation" + displayName: "Not Under Investigation Attestation" + description: "For declaring that the applicant is not currently under investigation." + text: |- + I hereby attest and affirm that I am not currently under investigation by any board, agency, department, association, certifying body, or other body. + required: true + locale: "en" + - attestationId: "under-investigation-attestation" + displayName: "Under Investigation Attestation" + description: "For declaring that the applicant is currently under investigation." + text: |- + I hereby attest and affirm that I am currently under investigation by any board, agency, department, association, certifying body, or other body. I understand that if any investigation results in a disciplinary action, my Compact Privileges may be revoked. + required: true + locale: "en" + - attestationId: "discipline-no-current-encumbrance-attestation" + displayName: "No Current Discipline Encumbrance Attestation" + description: "For declaring that the applicant has no encumbrances on any state license." + text: |- + I hereby attest and affirm that I have no encumbrance (any discipline that restricts my full practice or any unmet condition before returning to a full and unrestricted license, including, but not limited, to probation, supervision, completion of a program, and/or completion of CEs) on ANY state license. + required: true + locale: "en" + - attestationId: "discipline-no-prior-encumbrance-attestation" + displayName: "No Discipline Encumbrance For Prior Two Yeats Attestation" + description: "For declaring that the applicant has no encumbrances on any state license within the last two years." + text: |- + I hereby attest and affirm that I have not had any encumbrance on ANY state license within the previous two years from date of this application for a Compact Privilege. + required: true + locale: "en" + - attestationId: "provision-of-true-information-attestation" + displayName: "Provision of True Information Attestation" + description: "For declaring that the applicant has provided true information." + text: |- + I hereby attest and affirm that all information contained in this privilege application is true to the best of my knowledge. + required: true + locale: "en" + - attestationId: "military-affiliation-confirmation-attestation" + displayName: "Military Affiliation Confirmation Attestation" + description: "For declaring that the applicant's military affiliation documentation is accurate." + text: |- + I hereby attest and affirm that my current military status documentation as uploaded to CompactConnect is accurate. + required: true + locale: "en" diff --git a/backend/compact-connect/compact-config/octp.yml b/backend/compact-connect/compact-config/octp.yml index 863c1c1d1..0da0fb513 100644 --- a/backend/compact-connect/compact-config/octp.yml +++ b/backend/compact-connect/compact-config/octp.yml @@ -7,3 +7,77 @@ compactOperationsTeamEmails: [] compactAdverseActionsNotificationEmails: [] compactSummaryReportNotificationEmails: [] activeEnvironments: ["test"] + +attestations: + - attestationId: "jurisprudence-confirmation" + displayName: "Jurisprudence Confirmation." + description: "For displaying the jurisprudence confirmation." + text: |- + I understand that an attestation is a legally binding statement. I understand that providing false information on this application could result in a loss of my licenses and/or privileges. I acknowledge that the Commission may audit jurisprudence attestations at their discretion. + required: true + locale: "en" + - attestationId: "scope-of-practice-attestation" + displayName: "Scope of Practice Attestation" + description: "For displaying the scope of practice attestation." + text: |- + I hereby attest and affirm that I have reviewed, understand, and will abide by this state's scope of practice and all applicable laws and rules when practicing in the state. I understand that the issuance of a Compact Privilege authorizes me to legally practice in the member jurisdiction in accordance with the laws and rules governing practice of my profession in that jurisdiction. + + If I violate the practice act, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years. + required: true + locale: "en" + - attestationId: "personal-information-home-state-attestation" + displayName: "Personal Information Home State Attestation" + description: "For declaring that the applicant is a resident of the state they have listed as their home state." + text: |- + I hereby attest and affirm that this is my personal and licensure information and that I am a resident of the state listed on this page.* + required: true + locale: "en" + - attestationId: "personal-information-address-attestation" + displayName: "Personal Information Address Attestation" + description: "For declaring that the applicant is a resident of the state they have listed as their home state." + text: |- + I hereby attest and affirm that the address information I have provided herein and is my current address. I further consent to accept service of process at this address. I will notify the Commission of a change in my Home State address or email address via updating personal information records in this system. I understand that I am only eligible for a Compact Privilege if I am a licensee in my Home State as defined by the Compact. If I mislead the Compact Commission about my Home State, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years.* + required: true + locale: "en" + - attestationId: "not-under-investigation-attestation" + displayName: "Not Under Investigation Attestation" + description: "For declaring that the applicant is not currently under investigation." + text: |- + I hereby attest and affirm that I am not currently under investigation by any board, agency, department, association, certifying body, or other body. + required: true + locale: "en" + - attestationId: "under-investigation-attestation" + displayName: "Under Investigation Attestation" + description: "For declaring that the applicant is currently under investigation." + text: |- + I hereby attest and affirm that I am currently under investigation by any board, agency, department, association, certifying body, or other body. I understand that if any investigation results in a disciplinary action, my Compact Privileges may be revoked. + required: true + locale: "en" + - attestationId: "discipline-no-current-encumbrance-attestation" + displayName: "No Current Discipline Encumbrance Attestation" + description: "For declaring that the applicant has no encumbrances on any state license." + text: |- + I hereby attest and affirm that I have no encumbrance (any discipline that restricts my full practice or any unmet condition before returning to a full and unrestricted license, including, but not limited, to probation, supervision, completion of a program, and/or completion of CEs) on ANY state license. + required: true + locale: "en" + - attestationId: "discipline-no-prior-encumbrance-attestation" + displayName: "No Discipline Encumbrance For Prior Two Yeats Attestation" + description: "For declaring that the applicant has no encumbrances on any state license within the last two years." + text: |- + I hereby attest and affirm that I have not had any encumbrance on ANY state license within the previous two years from date of this application for a Compact Privilege. + required: true + locale: "en" + - attestationId: "provision-of-true-information-attestation" + displayName: "Provision of True Information Attestation" + description: "For declaring that the applicant has provided true information." + text: |- + I hereby attest and affirm that all information contained in this privilege application is true to the best of my knowledge. + required: true + locale: "en" + - attestationId: "military-affiliation-confirmation-attestation" + displayName: "Military Affiliation Confirmation Attestation" + description: "For declaring that the applicant's military affiliation documentation is accurate." + text: |- + I hereby attest and affirm that my current military status documentation as uploaded to CompactConnect is accurate. + required: true + locale: "en" diff --git a/backend/compact-connect/docs/README.md b/backend/compact-connect/docs/README.md index 12a5cf928..a53d57eef 100644 --- a/backend/compact-connect/docs/README.md +++ b/backend/compact-connect/docs/README.md @@ -9,15 +9,9 @@ look [here](./design/README.md). The Audiology and Speech Language Pathology, Counseling, and Occupational Therapy compact commissions are collectively building a system to share professional licensure data between their state licensing boards to facilitate participation in their respective occupational licensure compacts. To date, this system is solely composed of a mock API. ## Table of Contents -- **[Mock API](#mock-api)** - **[How to use the API bulk-upload feature](#how-to-use-the-api-bulk-upload-feature)** - **[Open API Specification](#open-api-specification)** -## Mock API -[Back to top](#compact-connect---technical-user-guide) - -This system includes a mock license data API that has two functions: A synchronous license data validation endpoint that can allow users to test their data against the expected data schema and an asynchronous bulk upload function that can allow for uploading a large file to be asynchronously processed. Currently, neither endpoint has any back-end processing and data is immediately discarded. If you wish to test out these endpoints, **please make a point to only send artificial data**. - ## How to use the API bulk-upload feature [Back to top](#compact-connect---technical-user-guide) diff --git a/backend/compact-connect/docs/api-specification/latest-oas30.json b/backend/compact-connect/docs/api-specification/latest-oas30.json index 14d6938d3..c49f99f74 100644 --- a/backend/compact-connect/docs/api-specification/latest-oas30.json +++ b/backend/compact-connect/docs/api-specification/latest-oas30.json @@ -2,7 +2,7 @@ "openapi": "3.0.1", "info": { "title": "LicenseApi", - "version": "2024-12-02T19:04:58Z" + "version": "2025-01-15T17:49:42Z" }, "servers": [ { @@ -74,6 +74,133 @@ } } }, + "/v1/compacts/{compact}/attestations": { + "options": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "204 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + } + }, + "content": {} + } + } + } + }, + "/v1/compacts/{compact}/attestations/{attestationId}": { + "get": { + "parameters": [ + { + "name": "Authorization", + "in": "header", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "attestationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SandboLicenJwkX1M19iehD" + } + } + } + } + }, + "security": [ + { + "SandboxAPIStackLicenseApiProviderUsersPoolAuthorizerEB7523BA": [] + } + ] + }, + "options": { + "parameters": [ + { + "name": "compact", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "attestationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "204 response", + "headers": { + "Access-Control-Allow-Origin": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Methods": { + "schema": { + "type": "string" + } + }, + "Access-Control-Allow-Headers": { + "schema": { + "type": "string" + } + } + }, + "content": {} + } + } + } + }, "/v1/compacts/{compact}/credentials": { "options": { "parameters": [ @@ -135,7 +262,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenM3v1DQsiYo0I" + "$ref": "#/components/schemas/SandboLicenQmuWrxvHrNne" } } }, @@ -147,7 +274,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicennsxzW9stMafI" + "$ref": "#/components/schemas/SandboLicenCKI8MYY04rNP" } } } @@ -313,7 +440,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenOBKe66xJrnUY" + "$ref": "#/components/schemas/SandboLicensqVdeqIDiTUV" } } }, @@ -325,7 +452,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenZpJMDau4cDdN" + "$ref": "#/components/schemas/SandboLicenBHELNobWpOAZ" } } } @@ -419,7 +546,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenC0IHjjHMelH4" + "$ref": "#/components/schemas/SandboLicenkelmvshNMdxq" } } } @@ -540,7 +667,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenZtKJxTXXg5UL" + "$ref": "#/components/schemas/SandboLicenkzXEuzbeBMcD" } } }, @@ -552,7 +679,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenWHrz3Lh8eYZP" + "$ref": "#/components/schemas/SandboLicenk4wMidJp4tSI" } } } @@ -561,9 +688,9 @@ "security": [ { "SandboxAPIStackLicenseApiStaffPoolsAuthorizerD744D6FB": [ - "aslp/read", - "octp/read", - "coun/read" + "aslp/readGeneral", + "octp/readGeneral", + "coun/readGeneral" ] } ] @@ -638,7 +765,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenDdBuFYJj4W5C" + "$ref": "#/components/schemas/SandboLicenPWICF4WAig5B" } } } @@ -647,9 +774,9 @@ "security": [ { "SandboxAPIStackLicenseApiStaffPoolsAuthorizerD744D6FB": [ - "aslp/read", - "octp/read", - "coun/read" + "aslp/readGeneral", + "octp/readGeneral", + "coun/readGeneral" ] } ] @@ -723,7 +850,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenAYFFIt5ce2HI" + "$ref": "#/components/schemas/SandboLicennisEwvBxWQhC" } } } @@ -754,7 +881,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenzMeGyfqdYCbr" + "$ref": "#/components/schemas/SandboLicenDIQiicBLz4mm" } } }, @@ -773,7 +900,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenUL01PEWATEUT" + "$ref": "#/components/schemas/SandboLicenq0WHjwuOL0r5" } } } @@ -858,7 +985,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenUL01PEWATEUT" + "$ref": "#/components/schemas/SandboLicenq0WHjwuOL0r5" } } } @@ -940,7 +1067,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLiceneEbMkNNP0wku" + "$ref": "#/components/schemas/SandboLicenNYOXdr8q1V6F" } } }, @@ -959,7 +1086,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenUL01PEWATEUT" + "$ref": "#/components/schemas/SandboLicenq0WHjwuOL0r5" } } } @@ -1021,7 +1148,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenDdBuFYJj4W5C" + "$ref": "#/components/schemas/SandboLicenPWICF4WAig5B" } } } @@ -1075,7 +1202,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicencgqVAvZnWkMG" + "$ref": "#/components/schemas/SandboLicenVl4LxvKwNI3S" } } }, @@ -1087,7 +1214,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenKugXONwl77RZ" + "$ref": "#/components/schemas/SandboLicenXS5uvP2iI0Bd" } } } @@ -1139,7 +1266,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenvclXpdeW3Y28" + "$ref": "#/components/schemas/SandboLicen3bd9q60ck5Mk" } } }, @@ -1151,7 +1278,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenYgKyaZjnxxpR" + "$ref": "#/components/schemas/SandboLicenyYKFQAIjlFCn" } } } @@ -1207,7 +1334,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenGA3a6Jf5sr6W" + "$ref": "#/components/schemas/SandboLicendmj7qfw2x1tR" } } }, @@ -1219,7 +1346,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicen2gYLvhizuSaW" + "$ref": "#/components/schemas/SandboLicenTrpoUes4Xu35" } } } @@ -1275,7 +1402,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenIlcgezTgAmMg" + "$ref": "#/components/schemas/SandboLicenicEElMwlVowu" } } } @@ -1355,7 +1482,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenUL01PEWATEUT" + "$ref": "#/components/schemas/SandboLicenq0WHjwuOL0r5" } } } @@ -1399,7 +1526,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenj65itQcZP12q" + "$ref": "#/components/schemas/SandboLicencwCJNumn5Our" } } }, @@ -1418,7 +1545,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SandboLicenUL01PEWATEUT" + "$ref": "#/components/schemas/SandboLicenq0WHjwuOL0r5" } } } @@ -1436,31 +1563,114 @@ }, "components": { "schemas": { - "SandboLicenWHrz3Lh8eYZP": { + "SandboLicenBHELNobWpOAZ": { "required": [ - "pagination" + "message" + ], + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "additionalProperties": false + }, + "SandboLicenkzXEuzbeBMcD": { + "required": [ + "query" ], "type": "object", "properties": { "pagination": { "type": "object", "properties": { - "prevLastKey": { - "maxLength": 1024, - "minLength": 1, - "type": "object" - }, "lastKey": { "maxLength": 1024, "minLength": 1, - "type": "object" + "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 privilege/license in a 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" + ] + }, + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string", + "description": "Social security number to look up" + } + }, + "description": "The query parameters" }, "sorting": { "required": [ @@ -1486,26 +1696,124 @@ } }, "description": "How to sort results" + } + }, + "additionalProperties": false + }, + "SandboLicenVl4LxvKwNI3S": { + "required": [ + "affiliationType", + "fileNames" + ], + "type": "object", + "properties": { + "affiliationType": { + "type": "string", + "description": "The type of military affiliation", + "enum": [ + "militaryMember", + "militaryMemberSpouse" + ] }, - "providers": { - "maxLength": 100, + "fileNames": { "type": "array", + "description": "List of military affiliation file names", "items": { - "required": [ - "birthMonthDay", - "compact", - "dateOfBirth", - "dateOfExpiration", - "dateOfUpdate", - "familyName", - "givenName", - "homeAddressCity", - "homeAddressPostalCode", - "homeAddressState", - "homeAddressStreet1", - "licenseJurisdiction", - "licenseType", - "privilegeJurisdictions", + "maxLength": 150, + "type": "string", + "description": "The name of the file being uploaded" + } + } + }, + "additionalProperties": false + }, + "SandboLicenkelmvshNMdxq": { + "required": [ + "fields" + ], + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "url": { + "type": "string" + } + } + }, + "SandboLicenk4wMidJp4tSI": { + "required": [ + "pagination" + ], + "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", + "dateOfBirth", + "dateOfExpiration", + "dateOfUpdate", + "familyName", + "givenName", + "homeAddressCity", + "homeAddressPostalCode", + "homeAddressState", + "homeAddressStreet1", + "licenseJurisdiction", + "licenseType", + "privilegeJurisdictions", "providerId", "ssn", "status", @@ -1747,26 +2055,40 @@ } } }, - "SandboLiceneEbMkNNP0wku": { + "SandboLicennisEwvBxWQhC": { "type": "object", "properties": { - "permissions": { + "pagination": { "type": "object", - "additionalProperties": { + "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", + "userId" + ], "type": "object", "properties": { - "actions": { - "type": "object", - "properties": { - "read": { - "type": "boolean" - }, - "admin": { - "type": "boolean" - } - } - }, - "jurisdictions": { + "permissions": { "type": "object", "additionalProperties": { "type": "object", @@ -1774,17 +2096,72 @@ "actions": { "type": "object", "properties": { - "admin": { + "readPrivate": { "type": "boolean" }, - "write": { + "read": { + "type": "boolean" + }, + "admin": { "type": "boolean" } - }, - "additionalProperties": false + } + }, + "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" } }, "additionalProperties": false @@ -1793,287 +2170,204 @@ }, "additionalProperties": false }, - "SandboLicennsxzW9stMafI": { - "required": [ - "message" - ], - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "A message about the request" - } - } - }, - "SandboLicenC0IHjjHMelH4": { + "SandboLicendmj7qfw2x1tR": { "required": [ - "fields" + "attestations", + "orderInformation", + "selectedJurisdictions" ], "type": "object", "properties": { - "fields": { - "type": "object", - "additionalProperties": { - "type": "string" + "attestations": { + "type": "array", + "description": "List of attestations that the user has agreed to", + "items": { + "required": [ + "attestationId", + "version" + ], + "type": "object", + "properties": { + "attestationId": { + "maxLength": 100, + "type": "string", + "description": "The ID of the attestation" + }, + "version": { + "maxLength": 10, + "pattern": "^\\d+$", + "type": "string", + "description": "The version of the attestation" + } + } } }, - "url": { - "type": "string" - } - } - }, - "SandboLicenOBKe66xJrnUY": { - "maxLength": 100, - "type": "array", - "items": { - "required": [ - "dateOfBirth", - "dateOfExpiration", - "dateOfIssuance", - "dateOfRenewal", - "familyName", - "givenName", - "homeAddressCity", - "homeAddressPostalCode", - "homeAddressState", - "homeAddressStreet1", - "licenseType", - "ssn", - "status" - ], - "type": "object", - "properties": { - "homeAddressStreet2": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "npi": { - "pattern": "^[0-9]{10}$", - "type": "string" - }, - "homeAddressPostalCode": { - "maxLength": 7, - "minLength": 5, - "type": "string" - }, - "givenName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "homeAddressStreet1": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "militaryWaiver": { - "type": "boolean" - }, - "dateOfBirth": { - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string", - "format": "date" - }, - "dateOfIssuance": { - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string", - "format": "date" - }, - "ssn": { - "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", - "type": "string" - }, - "licenseType": { - "type": "string", - "enum": [ - "audiologist", - "speech-language pathologist", - "speech and language pathologist", - "occupational therapist", - "occupational therapy assistant", - "licensed professional counselor", - "licensed mental health counselor", - "licensed clinical mental health counselor", - "licensed professional clinical counselor" - ] - }, - "dateOfExpiration": { - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string", - "format": "date" - }, - "homeAddressState": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "dateOfRenewal": { - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string", - "format": "date" - }, - "familyName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "homeAddressCity": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "middleName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "status": { + "orderInformation": { + "required": [ + "billing", + "card" + ], + "type": "object", + "properties": { + "card": { + "required": [ + "cvv", + "expiration", + "number" + ], + "type": "object", + "properties": { + "number": { + "maxLength": 19, + "minLength": 13, + "type": "string", + "description": "The card number" + }, + "cvv": { + "maxLength": 4, + "minLength": 3, + "type": "string", + "description": "The card cvv" + }, + "expiration": { + "maxLength": 7, + "minLength": 7, + "type": "string", + "description": "The card expiration date" + } + } + }, + "billing": { + "required": [ + "firstName", + "lastName", + "state", + "streetAddress", + "zip" + ], + "type": "object", + "properties": { + "zip": { + "maxLength": 10, + "minLength": 5, + "type": "string", + "description": "The zip code for the card" + }, + "firstName": { + "maxLength": 100, + "minLength": 1, + "type": "string", + "description": "The first name on the card" + }, + "lastName": { + "maxLength": 100, + "minLength": 1, + "type": "string", + "description": "The last name on the card" + }, + "streetAddress": { + "maxLength": 150, + "minLength": 2, + "type": "string", + "description": "The street address for the card" + }, + "streetAddress2": { + "maxLength": 150, + "type": "string", + "description": "The second street address for the card" + }, + "state": { + "maxLength": 2, + "minLength": 2, + "type": "string", + "description": "The state postal abbreviation for the card" + } + } + } + } + }, + "selectedJurisdictions": { + "maxLength": 100, + "type": "array", + "items": { "type": "string", + "description": "Jurisdictions a provider has selected to purchase privileges in.", "enum": [ - "active", - "inactive" + "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" ] } - }, - "additionalProperties": false + } } }, - "SandboLicenj65itQcZP12q": { + "SandboLicenCKI8MYY04rNP": { + "required": [ + "message" + ], "type": "object", "properties": { - "attributes": { - "type": "object", - "properties": { - "givenName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "familyName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - } - }, - "additionalProperties": false + "message": { + "type": "string", + "description": "A message about the request" } - }, - "additionalProperties": false + } }, - "SandboLicenAYFFIt5ce2HI": { - "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", - "userId" - ], - "type": "object", - "properties": { - "permissions": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "actions": { - "type": "object", - "properties": { - "read": { - "type": "boolean" - }, - "admin": { - "type": "boolean" - } - } - }, - "jurisdictions": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "actions": { - "type": "object", - "properties": { - "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" - } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - }, - "SandboLicenzMeGyfqdYCbr": { + "SandboLicenq0WHjwuOL0r5": { "required": [ "attributes", - "permissions" + "permissions", + "userId" ], "type": "object", "properties": { @@ -2085,6 +2379,9 @@ "actions": { "type": "object", "properties": { + "readPrivate": { + "type": "boolean" + }, "read": { "type": "boolean" }, @@ -2101,6 +2398,9 @@ "actions": { "type": "object", "properties": { + "readPrivate": { + "type": "boolean" + }, "admin": { "type": "boolean" }, @@ -2142,286 +2442,169 @@ } }, "additionalProperties": false + }, + "userId": { + "type": "string" } }, "additionalProperties": false }, - "SandboLicenIlcgezTgAmMg": { + "SandboLicenTrpoUes4Xu35": { "required": [ - "items", - "pagination" + "transactionId" ], "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" - } - } + "message": { + "type": "string", + "description": "A message about the transaction" }, - "items": { - "maxLength": 100, - "type": "array", - "items": { - "type": "object", - "oneOf": [ - { - "required": [ - "compactCommissionFee", - "compactName", - "type" - ], - "type": "object", - "properties": { - "compactCommissionFee": { - "required": [ - "feeAmount", - "feeType" - ], - "type": "object", - "properties": { - "feeAmount": { - "type": "number" - }, - "feeType": { - "type": "string", - "enum": [ - "FLAT_RATE" - ] - } - } - }, - "type": { - "type": "string", - "enum": [ - "compact" - ] - }, - "compactName": { - "type": "string", - "description": "The name of the compact" - } - } - }, - { - "required": [ - "jurisdictionFee", - "jurisdictionName", - "jurisprudenceRequirements", - "postalAbbreviation", - "type" - ], - "type": "object", - "properties": { - "militaryDiscount": { - "required": [ - "active", - "discountAmount", - "discountType" - ], - "type": "object", - "properties": { - "active": { - "type": "boolean", - "description": "Whether the military discount is active" - }, - "discountAmount": { - "type": "number", - "description": "The amount of the discount" - }, - "discountType": { - "type": "string", - "description": "The type of discount", - "enum": [ - "FLAT_RATE" - ] - } - } - }, - "postalAbbreviation": { - "type": "string", - "description": "The postal abbreviation of the jurisdiction" - }, - "jurisprudenceRequirements": { - "required": [ - "required" - ], - "type": "object", - "properties": { - "required": { - "type": "boolean", - "description": "Whether jurisprudence requirements exist" - } - } - }, - "jurisdictionName": { - "type": "string", - "description": "The name of the jurisdiction" - }, - "jurisdictionFee": { - "type": "number", - "description": "The fee for the jurisdiction" - }, - "type": { - "type": "string", - "enum": [ - "jurisdiction" - ] - } - } - } - ] - } + "transactionId": { + "type": "string", + "description": "The transaction id for the purchase" } } }, - "SandboLicenKugXONwl77RZ": { - "required": [ - "affiliationType", - "dateOfUpdate", - "dateOfUpload", - "documentUploadFields", - "status" - ], - "type": "object", - "properties": { - "dateOfUpload": { - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string", - "description": "The date the document was uploaded", - "format": "date" - }, - "affiliationType": { - "type": "string", - "description": "The type of military affiliation", - "enum": [ - "militaryMember", - "militaryMemberSpouse" - ] - }, - "fileNames": { - "type": "array", - "description": "List of military affiliation file names", - "items": { + "SandboLicensqVdeqIDiTUV": { + "maxLength": 100, + "type": "array", + "items": { + "required": [ + "dateOfBirth", + "dateOfExpiration", + "dateOfIssuance", + "dateOfRenewal", + "familyName", + "givenName", + "homeAddressCity", + "homeAddressPostalCode", + "homeAddressState", + "homeAddressStreet1", + "licenseType", + "ssn", + "status" + ], + "type": "object", + "properties": { + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "npi": { + "pattern": "^[0-9]{10}$", + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "militaryWaiver": { + "type": "boolean" + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", "type": "string", - "description": "The name of the file being uploaded" - } - }, - "dateOfUpdate": { - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string", - "description": "The date the document was last updated", - "format": "date" - }, - "status": { - "type": "string", - "description": "The status of the military affiliation" - }, - "documentUploadFields": { - "type": "array", - "description": "The fields used to upload documents", - "items": { - "type": "object", - "properties": { - "fields": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "The form fields used to upload the document" - }, - "url": { - "type": "string", - "description": "The url to upload the document to" - } - }, - "description": "The fields used to upload a specific document" + "format": "date" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string" + }, + "licenseType": { + "type": "string", + "enum": [ + "audiologist", + "speech-language pathologist", + "speech and language pathologist", + "occupational therapist", + "occupational therapy assistant", + "licensed professional counselor", + "licensed mental health counselor", + "licensed clinical mental health counselor", + "licensed professional clinical counselor" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] } - } - } - }, - "SandboLicen2gYLvhizuSaW": { - "required": [ - "transactionId" - ], - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "A message about the transaction" }, - "transactionId": { - "type": "string", - "description": "The transaction id for the purchase" - } + "additionalProperties": false } }, - "SandboLicenM3v1DQsiYo0I": { - "required": [ - "apiLoginId", - "processor", - "transactionKey" - ], - "type": "object", - "properties": { - "apiLoginId": { - "maxLength": 100, - "minLength": 1, - "type": "string", - "description": "The api login id for the payment processor" - }, - "transactionKey": { - "maxLength": 100, - "minLength": 1, - "type": "string", - "description": "The transaction key for the payment processor" - }, - "processor": { - "type": "string", - "description": "The type of payment processor", - "enum": [ - "authorize.net" - ] - } - }, - "additionalProperties": false - }, - "SandboLicenvclXpdeW3Y28": { - "required": [ - "status" - ], + "SandboLicencwCJNumn5Our": { "type": "object", "properties": { - "status": { - "type": "string", - "description": "The status to set the military affiliation to.", - "enum": [ - "inactive" - ] + "attributes": { + "type": "object", + "properties": { + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "SandboLicenUL01PEWATEUT": { - "required": [ - "attributes", - "permissions", - "userId" - ], + "SandboLicenNYOXdr8q1V6F": { "type": "object", "properties": { "permissions": { @@ -2432,6 +2615,9 @@ "actions": { "type": "object", "properties": { + "readPrivate": { + "type": "boolean" + }, "read": { "type": "boolean" }, @@ -2448,6 +2634,9 @@ "actions": { "type": "object", "properties": { + "readPrivate": { + "type": "boolean" + }, "admin": { "type": "boolean" }, @@ -2463,15 +2652,71 @@ }, "additionalProperties": false } - }, - "attributes": { - "required": [ - "email", - "familyName", - "givenName" - ], - "type": "object", - "properties": { + } + }, + "additionalProperties": false + }, + "SandboLicenDIQiicBLz4mm": { + "required": [ + "attributes", + "permissions" + ], + "type": "object", + "properties": { + "permissions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "actions": { + "type": "object", + "properties": { + "readPrivate": { + "type": "boolean" + }, + "read": { + "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, @@ -2489,20 +2734,199 @@ } }, "additionalProperties": false + } + }, + "additionalProperties": false + }, + "SandboLicenicEElMwlVowu": { + "required": [ + "items", + "pagination" + ], + "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" + } + } }, - "userId": { - "type": "string" + "items": { + "maxLength": 100, + "type": "array", + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "compactCommissionFee", + "compactName", + "type" + ], + "type": "object", + "properties": { + "compactCommissionFee": { + "required": [ + "feeAmount", + "feeType" + ], + "type": "object", + "properties": { + "feeAmount": { + "type": "number" + }, + "feeType": { + "type": "string", + "enum": [ + "FLAT_RATE" + ] + } + } + }, + "type": { + "type": "string", + "enum": [ + "compact" + ] + }, + "compactName": { + "type": "string", + "description": "The name of the compact" + } + } + }, + { + "required": [ + "jurisdictionFee", + "jurisdictionName", + "jurisprudenceRequirements", + "postalAbbreviation", + "type" + ], + "type": "object", + "properties": { + "militaryDiscount": { + "required": [ + "active", + "discountAmount", + "discountType" + ], + "type": "object", + "properties": { + "active": { + "type": "boolean", + "description": "Whether the military discount is active" + }, + "discountAmount": { + "type": "number", + "description": "The amount of the discount" + }, + "discountType": { + "type": "string", + "description": "The type of discount", + "enum": [ + "FLAT_RATE" + ] + } + } + }, + "postalAbbreviation": { + "type": "string", + "description": "The postal abbreviation of the jurisdiction" + }, + "jurisprudenceRequirements": { + "required": [ + "required" + ], + "type": "object", + "properties": { + "required": { + "type": "boolean", + "description": "Whether jurisprudence requirements exist" + } + } + }, + "jurisdictionName": { + "type": "string", + "description": "The name of the jurisdiction" + }, + "jurisdictionFee": { + "type": "number", + "description": "The fee for the jurisdiction" + }, + "type": { + "type": "string", + "enum": [ + "jurisdiction" + ] + } + } + } + ] + } + } + } + }, + "SandboLicenQmuWrxvHrNne": { + "required": [ + "apiLoginId", + "processor", + "transactionKey" + ], + "type": "object", + "properties": { + "apiLoginId": { + "maxLength": 100, + "minLength": 1, + "type": "string", + "description": "The api login id for the payment processor" + }, + "transactionKey": { + "maxLength": 100, + "minLength": 1, + "type": "string", + "description": "The transaction key for the payment processor" + }, + "processor": { + "type": "string", + "description": "The type of payment processor", + "enum": [ + "authorize.net" + ] } }, "additionalProperties": false }, - "SandboLicencgqVAvZnWkMG": { + "SandboLicenXS5uvP2iI0Bd": { "required": [ "affiliationType", - "fileNames" + "dateOfUpdate", + "dateOfUpload", + "documentUploadFields", + "status" ], "type": "object", "properties": { + "dateOfUpload": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "description": "The date the document was uploaded", + "format": "date" + }, "affiliationType": { "type": "string", "description": "The type of military affiliation", @@ -2515,21 +2939,113 @@ "type": "array", "description": "List of military affiliation file names", "items": { - "maxLength": 150, "type": "string", "description": "The name of the file being uploaded" } - } - }, - "additionalProperties": false - }, - "SandboLicenDdBuFYJj4W5C": { - "required": [ - "birthMonthDay", - "compact", - "dateOfBirth", - "dateOfExpiration", - "dateOfUpdate", + }, + "dateOfUpdate": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "description": "The date the document was last updated", + "format": "date" + }, + "status": { + "type": "string", + "description": "The status of the military affiliation" + }, + "documentUploadFields": { + "type": "array", + "description": "The fields used to upload documents", + "items": { + "type": "object", + "properties": { + "fields": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "The form fields used to upload the document" + }, + "url": { + "type": "string", + "description": "The url to upload the document to" + } + }, + "description": "The fields used to upload a specific document" + } + } + } + }, + "SandboLicenJwkX1M19iehD": { + "type": "object", + "properties": { + "dateCreated": { + "type": "string", + "format": "date-time" + }, + "compact": { + "type": "string", + "enum": [ + "aslp", + "octp", + "coun" + ] + }, + "attestationType": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "attestation" + ] + }, + "version": { + "type": "string" + }, + "required": { + "type": "boolean" + } + } + }, + "SandboLicen3bd9q60ck5Mk": { + "required": [ + "status" + ], + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "The status to set the military affiliation to.", + "enum": [ + "inactive" + ] + } + }, + "additionalProperties": false + }, + "SandboLicenyYKFQAIjlFCn": { + "required": [ + "message" + ], + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "A message about the request" + } + } + }, + "SandboLicenPWICF4WAig5B": { + "required": [ + "birthMonthDay", + "compact", + "dateOfBirth", + "dateOfExpiration", + "dateOfUpdate", "familyName", "givenName", "homeAddressCity", @@ -2628,6 +3144,305 @@ "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" }, + "history": { + "type": "array", + "items": { + "type": "object", + "properties": { + "compact": { + "type": "string", + "enum": [ + "aslp", + "octp", + "coun" + ] + }, + "previous": { + "type": "object", + "properties": { + "licenseJurisdiction": { + "type": "string", + "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" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "compact": { + "type": "string", + "enum": [ + "aslp", + "octp", + "coun" + ] + }, + "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" + }, + "type": { + "type": "string", + "enum": [ + "privilege" + ] + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "dateOfUpdate": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + }, + "jurisdiction": { + "type": "string", + "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" + ] + }, + "updatedValues": { + "type": "object", + "properties": { + "licenseJurisdiction": { + "type": "string", + "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" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "compact": { + "type": "string", + "enum": [ + "aslp", + "octp", + "coun" + ] + }, + "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" + }, + "type": { + "type": "string", + "enum": [ + "privilege" + ] + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "dateOfUpdate": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + }, + "type": { + "type": "string", + "enum": [ + "privilegeUpdate" + ] + }, + "dateOfUpdate": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "updateType": { + "type": "string", + "enum": [ + "renewal", + "deactivation", + "other" + ] + } + } + } + }, "type": { "type": "string", "enum": [ @@ -2940,6 +3755,293 @@ "type": "string", "format": "date" }, + "history": { + "type": "array", + "items": { + "type": "object", + "properties": { + "compact": { + "type": "string", + "enum": [ + "aslp", + "octp", + "coun" + ] + }, + "previous": { + "type": "object", + "properties": { + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "npi": { + "pattern": "^[0-9]{10}$", + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "militaryWaiver": { + "type": "boolean" + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string" + }, + "licenseType": { + "type": "string", + "enum": [ + "audiologist", + "speech-language pathologist", + "speech and language pathologist", + "occupational therapist", + "occupational therapy assistant", + "licensed professional counselor", + "licensed mental health counselor", + "licensed clinical mental health counselor", + "licensed professional clinical counselor" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + }, + "jurisdiction": { + "type": "string", + "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" + ] + }, + "updatedValues": { + "type": "object", + "properties": { + "homeAddressStreet2": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "npi": { + "pattern": "^[0-9]{10}$", + "type": "string" + }, + "homeAddressPostalCode": { + "maxLength": 7, + "minLength": 5, + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressStreet1": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "militaryWaiver": { + "type": "boolean" + }, + "dateOfBirth": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "dateOfIssuance": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string" + }, + "licenseType": { + "type": "string", + "enum": [ + "audiologist", + "speech-language pathologist", + "speech and language pathologist", + "occupational therapist", + "occupational therapy assistant", + "licensed professional counselor", + "licensed mental health counselor", + "licensed clinical mental health counselor", + "licensed professional clinical counselor" + ] + }, + "dateOfExpiration": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "homeAddressState": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "dateOfRenewal": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "homeAddressCity": { + "maxLength": 100, + "minLength": 2, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "inactive" + ] + } + } + }, + "type": { + "type": "string", + "enum": [ + "licenseUpdate" + ] + }, + "dateOfUpdate": { + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string", + "format": "date" + }, + "updateType": { + "type": "string", + "enum": [ + "renewal", + "deactivation", + "other" + ] + } + } + } + }, "type": { "type": "string", "enum": [ @@ -3065,311 +4167,6 @@ ] } } - }, - "SandboLicenZpJMDau4cDdN": { - "required": [ - "message" - ], - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "additionalProperties": false - }, - "SandboLicenZtKJxTXXg5UL": { - "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 privilege/license in a 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" - ] - }, - "ssn": { - "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", - "type": "string", - "description": "Social security number to look up" - } - }, - "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 - }, - "SandboLicenYgKyaZjnxxpR": { - "required": [ - "message" - ], - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "A message about the request" - } - } - }, - "SandboLicenGA3a6Jf5sr6W": { - "required": [ - "orderInformation", - "selectedJurisdictions" - ], - "type": "object", - "properties": { - "orderInformation": { - "required": [ - "billing", - "card" - ], - "type": "object", - "properties": { - "card": { - "required": [ - "cvv", - "expiration", - "number" - ], - "type": "object", - "properties": { - "number": { - "maxLength": 19, - "minLength": 15, - "type": "string", - "description": "The card number" - }, - "cvv": { - "maxLength": 4, - "minLength": 3, - "type": "string", - "description": "The card cvv" - }, - "expiration": { - "maxLength": 7, - "minLength": 7, - "type": "string", - "description": "The card expiration date" - } - } - }, - "billing": { - "required": [ - "firstName", - "lastName", - "state", - "streetAddress", - "zip" - ], - "type": "object", - "properties": { - "zip": { - "maxLength": 10, - "minLength": 5, - "type": "string", - "description": "The zip code for the card" - }, - "firstName": { - "maxLength": 100, - "minLength": 1, - "type": "string", - "description": "The first name on the card" - }, - "lastName": { - "maxLength": 100, - "minLength": 1, - "type": "string", - "description": "The last name on the card" - }, - "streetAddress": { - "maxLength": 150, - "minLength": 2, - "type": "string", - "description": "The street address for the card" - }, - "streetAddress2": { - "maxLength": 150, - "type": "string", - "description": "The second street address for the card" - }, - "state": { - "maxLength": 2, - "minLength": 2, - "type": "string", - "description": "The state postal abbreviation for the card" - } - } - } - } - }, - "selectedJurisdictions": { - "maxLength": 100, - "type": "array", - "items": { - "type": "string", - "description": "Jurisdictions a provider has selected to purchase privileges in.", - "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" - ] - } - } - } } }, "securitySchemes": { diff --git a/backend/compact-connect/docs/design/README.md b/backend/compact-connect/docs/design/README.md index e66ab6639..6698f810c 100644 --- a/backend/compact-connect/docs/design/README.md +++ b/backend/compact-connect/docs/design/README.md @@ -6,6 +6,8 @@ Look here for continued documentation of the back-end design, as it progresses. - **[Compacts and Jurisdictions](#compacts-and-jurisdictions)** - **[License Ingest](#license-ingest)** - **[User Architecture](#user-architecture)** +- **[Data Model](#data-model)** +- **[Attestations](#attestations)** ## Compacts and Jurisdictions @@ -72,29 +74,49 @@ the accompanying [architecture diagram](./users-arch-diagram.pdf) for an illustr ### Staff Users -Staff users come with a variety of different permissions, depending on their role. There are Compact Executive -Directors, Compact ED Staff, Board Executive Directors, Board ED Staff, and CSG Admins, each with different levels -of ability to read and write data, and to administrate users. 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 +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. -#### Compact Executive Directors and Staff +#### 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 ED level staff can have permission to read all compact data as well as to create and manage users and their -permissions. 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. +##### Compact Executive Directors and Staff -#### Board Executive Directors and Staff +Compact ED level staff will typically be granted the following permissions at the compact level: -Board ED level staff can have permission to read all compact data, write data to for their own jurisdiction, and to -create more users that have permissions within their own jurisdiction. They can also delete any user within their -jurisdiction, so long as that user does not have permissions associated with a different compact or jurisdiction. +- `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. + +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 that is not considered private. #### Implementation of Scopes @@ -109,17 +131,22 @@ require more than 100 scopes per resource server. To design around the 100 scope limit, we will have to split authorization into two layers: coarse- and fine-grained. We can rely on the Cognito authorizers to protect our API endpoints based on fewer coarse-grained scopes, then -protect the more fine-grained access within the API endpoint logic. The Staff User pool resource servers will are -configured with `read`, `write`, and `admin` scopes. `read` scopes indicate that the user is allowed to read the -entire compact's licensee data. `write` and `admin` scopes, however, indicate only that the user is allowed to write -or administrate _something_ in the compact respectively, thus giving them access to the write or administrative -API endpoints. We will then rely on the API endpoint logic to refine their access based on the more fine-grained -access scopes. - -To compliment each of the `write` and `admin` scopes, there will be at least one, more specific, scope, to indicate -_what_ within the compact they are allowed to write or administrate, respectively. In the case of `write` scopes, -a jurisdiction-specific scope will control what jurisdiction they are able to write data for (i.e. `al.write` grants -permission to write data for the Alabama jurisdiction). Similarly, `admin` scopes can have a jurisdiction-specific +protect the more fine-grained access within the API endpoint logic. The Staff User pool resource servers are +configured with `readGeneral`, `write`, and `admin` scopes. The `readGeneral` scope is implicitly granted to all users in +the system, and is used to indicate that the user is allowed to read any compact's licensee data that is not considered +private. The `write` and `admin` scopes, however, indicate only that the user is allowed to write or administrate +_something_ in the compact respectively, thus giving them access to the write or administrative API endpoints. We will +then rely on the API endpoint logic to refine their access based on the more fine-grained access scopes. + +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. + +To compliment each of the `write` and `admin` scopes, there will be at least one, more specific, scope, +to indicate _what_ within the compact they are allowed to write or administrate, respectively. In the case of `write` +scopes, a jurisdiction-specific scope will control what jurisdiction they are able to write data for (i.e. `al.write` +grants permission to write data for the Alabama jurisdiction). Similarly, `admin` scopes can have a jurisdiction-specific scope like `al.admin` and can also have a compact-wide scope like `aslp.admin`, which grants permission for a compact executive director to perform the administrative functions for the Audiology and Speech Language Pathology compact. @@ -138,3 +165,100 @@ Licensee users permissions are much simpler as compared to Compact Users. Their Once their identity has been verified and associated with a licensee in the license data system, they will only have permission to view system data that is specific to them. They will be able to apply for and renew privileges to practice across jurisdictions, subject to their eligibility. + +## Data Model +[Back to top](#backend-design) + +Data for the licensed practitioners is housed primarily in a single noSQL (DynamoDB) table, using a +[single table design](https://aws.amazon.com/blogs/database/single-table-vs-multi-table-design-in-amazon-dynamodb/). +The design of the data model is built around expected access patterns for practitioner data, with two main priorities +in mind: +1) Compact data should always be partitioned. This means that, whenever querying the database, it should not be possible + to query for data that may contain records for more than one compact in the response. The intent here is that + CompactConnect should have a deliberately partitioned experience in its data, from the UI, to the API, and all the + way down to the database layer, where a user must always be explicit about which compact's data they are interacting + with. +2) As much as is practical, an access pattern for retrieving practitioner data should be satisfied in a single query. + DynamoDB is designed to have single-millisecond latency for queries, at any scale. If we want a fast, performant + API, we should leverage that performance by deliberately crafting our records so that any set of records we expect + our users to want should be retrieved in a single query. + +### Provider records + +Provider (practitioner) records are stored in the database with each provider having their own partition key, for +example: `"pk": "aslp#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570"`. This allows for the partition to be retrieved +if a user is armed with only a compact and the identifier for the provider. From there, the provider's data is split +into records of multiple types that are distinguished by their sort key: + +Primary information about a provider, as deduced mostly from license data provided by states, is stored in their +`provider` type record and includes things like their name, home address, and date of birth. The provider record uses +a sort key like `"sk": "aslp#PROVIDER"`. + +Each license associated with a provider has the data submitted by a state saved in a record with a sort key like +`"sk": "aslp#PROVIDER#license/oh#"`. This example record would be for a license in Ohio. Each license a state uploads +that is associated with this provider can then be returned by a query for that provider's partition, with a query +key condition that specifies a sort key starting with `aslp#PROVIDER#license/`. + +Similarly, privileges to practice granted to the provider for a state are stored in a record with a sort key like +`"sk": "aslp#PROVIDER#privilege/ne#"`. This example record represents a privilege granted to practice in Nebraska. +The sort key pattern for privileges allows all privilege records to be queried by a sort key starting with +`aslp#PROVIDER#privilege/`. + +In addition to recording the current state of the provider, their licenses and privileges, CompactConnect also stores +historical data for providers, starting the day they are first added to the system. This historical data allows +interested parties to determine the status of a provider's ability to practice in any given member state on any given +day in the past. Any time a provider's status, date of expiration, renewal, or other values like name are changed, +a supporting record is created to track the change. For changes to a license, the record is stored with a sort key like +`aslp#PROVIDER#license/oh#UPDATE#1735232821/1a812bc8f`. This sort key will uniquely represent one particular change, +the time it was effective in the system, and the contents of that change. The last segment of the key is the POSIX +timestamp of the second the change was made followed by a hash of the previous and updated values. Similarly, a change +to a privilege will be represented with a record stored with a sort key like +`aslp#PROVIDER#privilege/ne#UPDATE#1735232821/1a812bc8f`. + +A query for a provider's partition and a sort key starting with `aslp#PROVIDER` would retrieve enough records to +represent all of the provider's licenses, privileges and their complete history from when they were created in +the system. + +## Attestations +[Back to top](#backend-design) + +Attestations are statements that providers must agree to when performing certain actions within the system, such as purchasing privileges. The attestation system is designed to support versioned, localized attestation text that providers must explicitly accept. + +### Storage and Versioning + +Attestations are stored in the compact configuration table with a composite key structure that enables efficient querying of the latest version for a given attestation type and locale: + +``` +PK: {compact}#CONFIGURATION +SK: {compact}#ATTESTATION#{attestationId}#LOCALE#{locale}#VERSION#{version} +``` + +This structure allows us to: +1. Group all attestations for a compact together (via PK) +2. Sort attestations by type, locale, and version (via SK) +3. Query for the latest version of a specific attestation in a given locale using a begins_with condition on the SK + +### Retrieval and Validation + +The system provides a `GET /v1/compacts/{compact}/attestations/{attestationId}` endpoint that returns the latest version of an attestation. This endpoint: +1. Accepts an optional `locale` query parameter (defaults to 'en'). +2. Returns a 404 if no attestation is found for the given type/locale. +3. Always returns the latest version of the attestation. + +When providers perform actions that require attestations (like purchasing privileges), they must: +1. Fetch the latest version of each required attestation +2. Include the attestation IDs and versions in their request +3. The system validates that: + - All required attestations are present + - The provided versions match the latest versions + - No invalid attestation types are included + +### Usage in Privilege Purchases + +When purchasing privileges, providers must accept all required attestations. The purchase endpoint: +1. Validates the attestations array in the request body +2. Verifies each attestation is the latest version +3. Stores the accepted attestations with the privilege record +4. Returns a 400 error if any attestation is invalid or outdated + +This ensures that providers always see and accept the most current version of required attestations, and we maintain an audit trail of which attestation versions were accepted for each privilege purchase. diff --git a/backend/compact-connect/docs/design/high-level-arch-diagram.pdf b/backend/compact-connect/docs/design/high-level-arch-diagram.pdf new file mode 100644 index 000000000..eda1cf59c Binary files /dev/null and b/backend/compact-connect/docs/design/high-level-arch-diagram.pdf differ diff --git a/backend/compact-connect/docs/design/license-events-diagram.pdf b/backend/compact-connect/docs/design/license-events-diagram.pdf new file mode 100644 index 000000000..5a21d0b04 Binary files /dev/null and b/backend/compact-connect/docs/design/license-events-diagram.pdf differ diff --git a/backend/compact-connect/docs/design/license-ingest-diagram.pdf b/backend/compact-connect/docs/design/license-ingest-diagram.pdf index 8c3c1c2c0..6032ce3eb 100644 Binary files a/backend/compact-connect/docs/design/license-ingest-diagram.pdf and b/backend/compact-connect/docs/design/license-ingest-diagram.pdf differ diff --git a/backend/compact-connect/docs/design/transaction_history_processor.pdf b/backend/compact-connect/docs/design/transaction_history_processor.pdf new file mode 100644 index 000000000..dc4e9d000 Binary files /dev/null and b/backend/compact-connect/docs/design/transaction_history_processor.pdf differ diff --git a/backend/compact-connect/docs/design/users-arch-diagram.pdf b/backend/compact-connect/docs/design/users-arch-diagram.pdf index 09b0e2a23..912a3fae0 100644 Binary files a/backend/compact-connect/docs/design/users-arch-diagram.pdf and b/backend/compact-connect/docs/design/users-arch-diagram.pdf differ diff --git a/backend/compact-connect/docs/onboarding/JURISDICTION_COMPACT_ONBOARDING.md b/backend/compact-connect/docs/onboarding/JURISDICTION_COMPACT_ONBOARDING.md index f0f8012e2..e11658b20 100644 --- a/backend/compact-connect/docs/onboarding/JURISDICTION_COMPACT_ONBOARDING.md +++ b/backend/compact-connect/docs/onboarding/JURISDICTION_COMPACT_ONBOARDING.md @@ -172,10 +172,72 @@ compactOperationsTeamEmails: [""] compactAdverseActionsNotificationEmails: [""] compactSummaryReportNotificationEmails: [""] activeEnvironments: ["sandbox", "test"] +attestations: # Required attestations for the compact + - attestationId: "" + displayName: "" + description: "" + text: "" + required: true|false + locale: "en" ``` At deploy time, if the environment name matches one of the files in the `activeEnvironments` list, these configuration files will be written to the database and accessible by the system. +### Configure Compact Attestations +Each compact must define a set of attestations that providers must accept when purchasing privileges. Attestations are legally binding statements that providers must agree to, and they are versioned to ensure providers always see and accept the most current version. The attestations must be defined in the compact configuration file under the `attestations` field. + +The following attestations must be defined for each compact: + +1. **Jurisprudence Confirmation** (`jurisprudence-confirmation`) + - Confirms understanding that attestations are legally binding and false information may result in license/privilege loss + +2. **Scope of Practice** (`scope-of-practice-attestation`) + - Confirms understanding and agreement to abide by the state's scope of practice and applicable laws + - Acknowledges that violations may result in privilege revocation and two-year prohibition + +3. **Personal Information - Home State** (`personal-information-home-state-attestation`) + - Confirms residency in the declared home state + - Verifies personal and licensure information accuracy + +4. **Personal Information - Address** (`personal-information-address-attestation`) + - Confirms current address accuracy and consent to service of process + - Acknowledges requirement to notify Commission of address changes + - Confirms understanding of home state eligibility requirements + +5. **Discipline - No Current Encumbrance** (`discipline-no-current-encumbrance-attestation`) + - Confirms no current disciplinary restrictions on any state license + - Includes probation, supervision, program completion, and CE requirements + +6. **Discipline - No Prior Encumbrance** (`discipline-no-prior-encumbrance-attestation`) + - Confirms no disciplinary restrictions on any state license within the past two years + +7. **Provision of True Information** (`provision-of-true-information-attestation`) + - General attestation that all provided information is true and accurate + +8. **Not Under Investigation** (`not-under-investigation-attestation`) + - Confirms no current investigations by any board or regulatory body + +9. **Under Investigation** (`under-investigation-attestation`) + - Declares current investigation status + - Acknowledges that disciplinary action may result in privilege revocation + +10. **Military Affiliation** (`military-affiliation-confirmation-attestation`) + - Required only for providers with active military affiliation + - Confirms accuracy of uploaded military status documentation + +Each attestation in the configuration must include: +```yaml +attestations: + - attestationId: "" # Unique identifier for the attestation + displayName: "" # Human-readable name + description: "" # Brief description of the attestation's purpose + text: "" # The full text of the attestation + required: true|false # Whether the attestation is required + locale: "en" # Language code (currently only "en" supported) +``` + +The system automatically handles versioning of attestations. When the text, displayName, description, or required status of an attestation changes, the system will automatically increment the version number. Providers must always accept the latest version of each attestation when purchasing privileges. + ## Updating Snapshot Tests to match Configuration Changes In order to ensure that the system is functioning as expected, we have tests in place to verify that the configuration diff --git a/backend/compact-connect/lambdas/nodejs/README.md b/backend/compact-connect/lambdas/nodejs/README.md index 32ad35c0b..2d27942eb 100644 --- a/backend/compact-connect/lambdas/nodejs/README.md +++ b/backend/compact-connect/lambdas/nodejs/README.md @@ -2,9 +2,37 @@ 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. -## Testing -The code in this folder can be tested by running: +## Prerequisites +* **[Node](https://github.com/creationix/nvm#installation) `22.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` diff --git a/backend/compact-connect/lambdas/nodejs/email-notification-service/.gitignore b/backend/compact-connect/lambdas/nodejs/email-notification-service/.gitignore new file mode 100644 index 000000000..a9db021f9 --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/email-notification-service/.gitignore @@ -0,0 +1,3 @@ +*.mjs +*.js +*.js.map diff --git a/backend/compact-connect/lambdas/nodejs/email-notification-service/README.md b/backend/compact-connect/lambdas/nodejs/email-notification-service/README.md new file mode 100644 index 000000000..79f63f3ca --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/email-notification-service/README.md @@ -0,0 +1,33 @@ +# Email Notification Service Lambda + +This package contains code required to generate system emails for users in compact connect, 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 (ie transactionBatchSettlementFailure) + recipientType: // must be one of the following + | 'COMPACT_OPERATIONS_TEAM' // compactOperationsTeamEmails + | 'COMPACT_ADVERSE_ACTIONS' // compactAdverseActionsNotificationEmails + | 'COMPACT_SUMMARY_REPORT' // compactSummaryReportNotificationEmails + | 'JURISDICTION_OPERATIONS_TEAM' // jurisdictionOperationsTeamEmails + | 'JURISDICTION_ADVERSE_ACTIONS' // jurisdictionAdverseActionsNotificationEmails + | 'JURISDICTION_SUMMARY_REPORT' // jurisdictionSummaryReportNotificationEmails + | '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/compact-connect/lambdas/nodejs/email-notification-service/email-notification-service-lambda.ts b/backend/compact-connect/lambdas/nodejs/email-notification-service/email-notification-service-lambda.ts new file mode 100644 index 000000000..78f3086e9 --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/email-notification-service/email-notification-service-lambda.ts @@ -0,0 +1,76 @@ +import type { LambdaInterface } from '@aws-lambda-powertools/commons/lib/esm/types'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESClient } from '@aws-sdk/client-ses'; +import { Context } from 'aws-lambda'; + +import { EnvironmentVariablesService } from '../lib/environment-variables-service'; +import { CompactConfigurationClient } from '../lib/compact-configuration-client'; +import { EmailService } from '../lib/email-service'; +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: SESClient; +} + +export class Lambda implements LambdaInterface { + private readonly emailService: EmailService; + + constructor(props: LambdaProperties) { + const compactConfigurationClient = new CompactConfigurationClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + + this.emailService = new EmailService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, + }); + } + + /** + * 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', { event: event }); + + // 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 'transactionBatchSettlementFailure': + await this.emailService.sendTransactionBatchSettlementFailureEmail( + event.compact, + event.recipientType, + event.specificEmails + ); + 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/compact-connect/lambdas/nodejs/email-notification-service/handler.ts b/backend/compact-connect/lambdas/nodejs/email-notification-service/handler.ts new file mode 100644 index 000000000..7dd5d5ce3 --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/email-notification-service/handler.ts @@ -0,0 +1,11 @@ +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { SESClient } from '@aws-sdk/client-ses'; +import { Lambda } from './email-notification-service-lambda'; + + +const lambda = new Lambda({ + dynamoDBClient: new DynamoDBClient(), + sesClient: new SESClient(), +}); + +export const sendEmail = lambda.handler.bind(lambda); diff --git a/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/README.md b/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/README.md index ac8e0fab6..a32e0d687 100644 --- a/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/README.md +++ b/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/README.md @@ -3,47 +3,3 @@ 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. - -## Table of Contents -- **[Prerequisites](#prerequisites)** -- **[Installing dependencies](#installing-dependencies)** -- **[Bundling the runtime](#bundling-the-runtime)** -- **[Local development](#local-development)** -- **[Tests](#tests)** - ---- -## Prerequisites -* **[Node](https://github.com/creationix/nvm#installation) `22.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)_ - ---- -## Tests -This project uses `jest` and `aws-sdk-client-mock` for approachable unit testing. To run the included test: - -- `yarn test` - -This lambda module requires >90% code coverage and >90% code _branch_ coverage. Be sure that all contributions are -covered with tests accordingly. diff --git a/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/handler.ts b/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/handler.ts index a2204d0b5..127204d13 100644 --- a/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/handler.ts +++ b/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/handler.ts @@ -1,6 +1,6 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { SESClient } from '@aws-sdk/client-ses'; -import { Lambda } from '../lib/lambda'; +import { Lambda } from './lambda'; const lambda = new Lambda({ diff --git a/backend/compact-connect/lambdas/nodejs/lib/lambda.ts b/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/lambda.ts similarity index 84% rename from backend/compact-connect/lambdas/nodejs/lib/lambda.ts rename to backend/compact-connect/lambdas/nodejs/ingest-event-reporter/lambda.ts index cee91529c..52ebc0712 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/lambda.ts +++ b/backend/compact-connect/lambdas/nodejs/ingest-event-reporter/lambda.ts @@ -4,11 +4,12 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { SESClient } from '@aws-sdk/client-ses'; import { Context } from 'aws-lambda'; -import { EnvironmentVariablesService } from './environment-variables-service'; -import { JurisdictionClient } from './jurisdiction-client'; -import { IEventBridgeEvent } from './models/event-bridge-event-detail'; -import { ReportEmailer } from './report-emailer'; -import { EventClient } from './event-client'; +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 { EmailService } from '../lib/email-service'; +import { EventClient } from '../lib/event-client'; const environmentVariables = new EnvironmentVariablesService(); const logger = new Logger({ logLevel: environmentVariables.getLogLevel() }); @@ -25,20 +26,27 @@ interface LambdaProperties { export class Lambda implements LambdaInterface { private readonly jurisdictionClient: JurisdictionClient; private readonly eventClient: EventClient; - private readonly reportEmailer: ReportEmailer; + private readonly emailService: EmailService; constructor(props: LambdaProperties) { this.jurisdictionClient = new JurisdictionClient({ logger: logger, dynamoDBClient: props.dynamoDBClient, }); + + const compactConfigurationClient = new CompactConfigurationClient({ + logger: logger, + dynamoDBClient: props.dynamoDBClient, + }); + this.eventClient = new EventClient({ logger: logger, dynamoDBClient: props.dynamoDBClient, }); - this.reportEmailer = new ReportEmailer({ + this.emailService = new EmailService({ logger: logger, sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, }); } @@ -61,7 +69,7 @@ export class Lambda implements LambdaInterface { // If there were any issues, send a report email summarizing them if (ingestEvents.ingestFailures.length || ingestEvents.validationErrors.length) { - const messageId = await this.reportEmailer.sendReportEmail( + const messageId = await this.emailService.sendReportEmail( ingestEvents, compact, jurisdictionConfig.jurisdictionName, @@ -97,11 +105,11 @@ export class Lambda implements LambdaInterface { ); // verify that the jurisdiction uploaded licenses within the last week without any errors - if (!weeklyIngestEvents.ingestFailures.length - && !weeklyIngestEvents.validationErrors.length + if (!weeklyIngestEvents.ingestFailures.length + && !weeklyIngestEvents.validationErrors.length && weeklyIngestEvents.ingestSuccesses.length ) { - const messageId = await this.reportEmailer.sendAllsWellEmail( + const messageId = await this.emailService.sendAllsWellEmail( compact, jurisdictionConfig.jurisdictionName, jurisdictionConfig.jurisdictionOperationsTeamEmails @@ -117,7 +125,7 @@ export class Lambda implements LambdaInterface { ); } else if(!weeklyIngestEvents.ingestSuccesses.length) { - const messageId = await this.reportEmailer.sendNoLicenseUpdatesEmail( + const messageId = await this.emailService.sendNoLicenseUpdatesEmail( compact, jurisdictionConfig.jurisdictionName, jurisdictionConfig.jurisdictionOperationsTeamEmails diff --git a/backend/compact-connect/lambdas/nodejs/lib/compact-configuration-client.ts b/backend/compact-connect/lambdas/nodejs/lib/compact-configuration-client.ts new file mode 100644 index 000000000..586e2b083 --- /dev/null +++ b/backend/compact-connect/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/compact-connect/lambdas/nodejs/lib/report-emailer.ts b/backend/compact-connect/lambdas/nodejs/lib/email-service.ts similarity index 77% rename from backend/compact-connect/lambdas/nodejs/lib/report-emailer.ts rename to backend/compact-connect/lambdas/nodejs/lib/email-service.ts index b0109ad82..c8f7890fc 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/report-emailer.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/email-service.ts @@ -3,8 +3,11 @@ import * as crypto from 'crypto'; import { Logger } from '@aws-lambda-powertools/logger'; import { SendEmailCommand, SESClient } from '@aws-sdk/client-ses'; import { renderToStaticMarkup, TReaderDocument } from '@usewaypoint/email-builder'; +import { CompactConfigurationClient } from './compact-configuration-client'; import { EnvironmentVariablesService } from './environment-variables-service'; import { IIngestFailureEventRecord, IValidationErrorEventRecord } from './models'; +import { RecipientType } from './models/email-notification-service-event'; + const environmentVariableService = new EnvironmentVariablesService(); @@ -13,9 +16,10 @@ interface IIngestEvents { validationErrors: IValidationErrorEventRecord[]; } -interface ReportEmailerProperties { +interface EmailServiceProperties { logger: Logger; sesClient: SESClient; + compactConfigurationClient: CompactConfigurationClient; } const getEmailImageBaseUrl = () => { @@ -27,9 +31,10 @@ const getEmailImageBaseUrl = () => { * Integrates with AWS SES to send emails and with EmailBuilderJS to render JS object templates into HTML * content that is expected to be consistently rendered across common email clients. */ -export class ReportEmailer { +export class EmailService { private readonly logger: Logger; private readonly sesClient: SESClient; + private readonly compactConfigurationClient: CompactConfigurationClient; private readonly emailTemplate: TReaderDocument = { 'root': { 'type': 'EmailLayout', @@ -43,9 +48,10 @@ export class ReportEmailer { } }; - public constructor(props: ReportEmailerProperties) { + public constructor(props: EmailServiceProperties) { this.logger = props.logger; this.sesClient = props.sesClient; + this.compactConfigurationClient = props.compactConfigurationClient; } private async sendEmail({ htmlContent, subject, recipients, errorMessage }: @@ -86,11 +92,11 @@ export class ReportEmailer { // Generate the HTML report const htmlContent = this.generateReport(events, compact, jurisdiction); - return this.sendEmail({ - htmlContent, - subject: `License Data Error Summary: ${compact} / ${jurisdiction}`, - recipients, - errorMessage: 'Error sending report email' + return this.sendEmail({ + htmlContent, + subject: `License Data Error Summary: ${compact} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending report email' }); } @@ -100,18 +106,18 @@ export class ReportEmailer { // Generate the HTML report const report = JSON.parse(JSON.stringify(this.emailTemplate)); - this.insertHeader(report, compact, jurisdiction, 'License Data Summary'); + this.insertHeaderWithJurisdiction(report, compact, jurisdiction, 'License Data Summary'); this.insertNoErrorImage(report); this.insertSubHeading(report, 'There have been no license data errors this week!'); this.insertFooter(report); const htmlContent = renderToStaticMarkup(report, { rootBlockId: 'root' }); - return this.sendEmail({ - htmlContent, - subject: `License Data Summary: ${compact} / ${jurisdiction}`, - recipients, - errorMessage: 'Error sending alls well email' + return this.sendEmail({ + htmlContent, + subject: `License Data Summary: ${compact} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending alls well email' }); } @@ -121,25 +127,25 @@ export class ReportEmailer { // Generate the HTML report const report = JSON.parse(JSON.stringify(this.emailTemplate)); - this.insertHeader(report, compact, jurisdiction, 'License Data Summary'); + this.insertHeaderWithJurisdiction(report, compact, 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 = renderToStaticMarkup(report, { rootBlockId: 'root' }); - return this.sendEmail({ - htmlContent, - subject: `No License Updates for Last 7 Days: ${compact} / ${jurisdiction}`, - recipients, - errorMessage: 'Error sending no license updates email' + return this.sendEmail({ + htmlContent, + subject: `No License Updates for Last 7 Days: ${compact} / ${jurisdiction}`, + recipients, + errorMessage: 'Error sending no license updates email' }); } public generateReport(events: IIngestEvents, compact: string, jurisdiction: string): string { const report = JSON.parse(JSON.stringify(this.emailTemplate)); - this.insertHeader( + this.insertHeaderWithJurisdiction( report, compact, jurisdiction, @@ -179,6 +185,51 @@ export class ReportEmailer { return validationErrors; } + private async getRecipients(compact: string, + recipientType: RecipientType, + specificEmails?: string[] + ): Promise { + if (recipientType === 'SPECIFIC') { + if (specificEmails) return specificEmails; + + throw new Error(`SPECIFIC recipientType requested but no specific email addresses provided`); + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + + switch (recipientType) { + case 'COMPACT_OPERATIONS_TEAM': + return compactConfig.compactOperationsTeamEmails; + default: + throw new Error(`Unsupported recipient type for compact configuration: ${recipientType}`); + } + } + + public async sendTransactionBatchSettlementFailureEmail(compact: string, + recipientType: RecipientType, + specificEmails?: string[] + ): Promise { + const recipients = await this.getRecipients(compact, recipientType, specificEmails); + + if (recipients.length === 0) { + throw new Error(`No recipients found for compact ${compact} with recipient type ${recipientType}`); + } + + const report = JSON.parse(JSON.stringify(this.emailTemplate)); + const subject = `Transactions Failed to Settle for ${compact.toUpperCase()} Payment Processor`; + const bodyText = 'A transaction settlement error was detected within the payment processing account for the compact. ' + + 'Please reach out to your payment processing representative to determine the cause. ' + + 'Transactions made in the account will not be able to be settled until the issue is addressed.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText); + this.insertFooter(report); + + const htmlContent = renderToStaticMarkup(report, { rootBlockId: 'root' }); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send transaction batch settlement failure email' }); + } + private insertIngestFailure(report: TReaderDocument, ingestFailure: IIngestFailureEventRecord) { const blockAId = `block-${crypto.randomUUID()}`; @@ -482,7 +533,11 @@ export class ReportEmailer { report['root']['data']['childrenIds'].push(blockDivId); } - private insertHeader(report: TReaderDocument, compact: string, jurisdiction: string, heading: string) { + private insertHeaderWithJurisdiction(report: TReaderDocument, + compact: string, + jurisdiction: string, + heading: string) { + const blockLogoId = 'block-logo'; const blockHeaderId = 'block-header'; const blockJurisdictionId = 'block-jurisdiction'; @@ -667,4 +722,92 @@ export class ReportEmailer { report['root']['data']['childrenIds'].push(blockId); } + + /** + * Adds a standard header block with Compact Connect logo to the report. + * + * @param report The report object to insert the block into. + * @param heading The text to insert into the block. + */ + private insertHeader(report: TReaderDocument, heading: string) { + 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': `${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['root']['data']['childrenIds'].push(blockLogoId); + report['root']['data']['childrenIds'].push(blockHeaderId); + } + + /** + * Inserts a body text block into the report. + * + * @param report The report object to insert the block into. + * @param bodyText The text to insert into the block. + */ + private insertBody(report: TReaderDocument, bodyText: string) { + const blockId = `block-body`; + + report[blockId] = { + 'type': 'Text', + 'data': { + 'style': { + 'fontSize': 16, + 'fontWeight': 'normal', + 'padding': { + 'top': 24, + 'bottom': 24, + 'right': 40, + 'left': 40 + } + }, + 'props': { + 'text': bodyText + } + } + }; + + report['root']['data']['childrenIds'].push(blockId); + } } diff --git a/backend/compact-connect/lambdas/nodejs/lib/environment-variables-service.ts b/backend/compact-connect/lambdas/nodejs/lib/environment-variables-service.ts index 3a50fd644..0dd1f58eb 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/environment-variables-service.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/environment-variables-service.ts @@ -19,7 +19,7 @@ export class EnvironmentVariablesService { return this.getEnvVar(this.uiBasePathUrlVariable); } - public getCompactconfigurationTableName() { + public getCompactConfigurationTableName() { return this.getEnvVar(this.compactConfigurationTableNameVariable); } diff --git a/backend/compact-connect/lambdas/nodejs/lib/jurisdiction-client.ts b/backend/compact-connect/lambdas/nodejs/lib/jurisdiction-client.ts index 41cc06a88..34614cf2d 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/jurisdiction-client.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/jurisdiction-client.ts @@ -31,7 +31,7 @@ export class JurisdictionClient { compactAbbr: string ): Promise { const resp = await this.dynamoDBClient.send(new QueryCommand({ - TableName: environmentVariables.getCompactconfigurationTableName(), + TableName: environmentVariables.getCompactConfigurationTableName(), Select: 'ALL_ATTRIBUTES', KeyConditionExpression: 'pk = :pk and begins_with (sk, :sk)', ExpressionAttributeValues: { diff --git a/backend/compact-connect/lambdas/nodejs/lib/models/compact.ts b/backend/compact-connect/lambdas/nodejs/lib/models/compact.ts new file mode 100644 index 000000000..cddd44ec7 --- /dev/null +++ b/backend/compact-connect/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; + compactName: string; + compactOperationsTeamEmails: string[]; + compactSummaryReportNotificationEmails: string[]; + dateOfUpdate: string; + type: string; +} diff --git a/backend/compact-connect/lambdas/nodejs/lib/models/email-notification-service-event.ts b/backend/compact-connect/lambdas/nodejs/lib/models/email-notification-service-event.ts new file mode 100644 index 000000000..3352861b3 --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/lib/models/email-notification-service-event.ts @@ -0,0 +1,23 @@ +export type RecipientType = + | 'COMPACT_OPERATIONS_TEAM' + | 'COMPACT_ADVERSE_ACTIONS' + | 'COMPACT_SUMMARY_REPORT' + | 'JURISDICTION_OPERATIONS_TEAM' + | 'JURISDICTION_ADVERSE_ACTIONS' + | 'JURISDICTION_SUMMARY_REPORT' + | '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/compact-connect/lambdas/nodejs/lib/models/event-records.ts b/backend/compact-connect/lambdas/nodejs/lib/models/event-records.ts index 960761c92..3e15b58fb 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/models/event-records.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/models/event-records.ts @@ -34,11 +34,11 @@ export interface IIngestSuccessEventRecord { sk: string, eventType: string, eventTime: string; - compact: string; - jurisdiction: string; - licenseType: string; - status: 'active' | 'inactive'; + compact: string; + jurisdiction: string; + licenseType: string; + status: 'active' | 'inactive'; dateOfIssuance: string; dateOfRenewal: string; - dateOfExpiration: string; + dateOfExpiration: string; } diff --git a/backend/compact-connect/lambdas/nodejs/tests/compact-configuration-client.test.ts b/backend/compact-connect/lambdas/nodejs/tests/compact-configuration-client.test.ts new file mode 100644 index 000000000..171545efe --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/tests/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: 'aslp#CONFIGURATION' }, + 'sk': { S: 'aslp#CONFIGURATION' }, + 'compactAdverseActionsNotificationEmails': { L: [{ S: 'adverse@example.com' }]}, + 'compactCommissionFee': { + M: { + 'feeAmount': { N: '3.5' }, + 'feeType': { S: 'FLAT_RATE' } + } + }, + 'compactName': { S: 'aslp' }, + 'compactOperationsTeamEmails': { L: [{ S: 'operations@example.com' }]}, + 'compactSummaryReportNotificationEmails': { L: [{ S: 'summary@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('aslp'); + + expect(mockDynamoDBClient).toHaveReceivedCommandWith( + GetItemCommand, + { + TableName: 'compact-table', + Key: { + 'pk': { S: 'aslp#CONFIGURATION' }, + 'sk': { S: 'aslp#CONFIGURATION' } + } + } + ); + + expect(config).toEqual({ + pk: 'aslp#CONFIGURATION', + sk: 'aslp#CONFIGURATION', + compactAdverseActionsNotificationEmails: ['adverse@example.com'], + compactCommissionFee: { + feeAmount: 3.5, + feeType: 'FLAT_RATE' + }, + compactName: 'aslp', + compactOperationsTeamEmails: ['operations@example.com'], + compactSummaryReportNotificationEmails: ['summary@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/compact-connect/lambdas/nodejs/tests/email-notification-service-lambda.test.ts b/backend/compact-connect/lambdas/nodejs/tests/email-notification-service-lambda.test.ts new file mode 100644 index 000000000..bcc5061a4 --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/tests/email-notification-service-lambda.test.ts @@ -0,0 +1,141 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'; +import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'; +import { Lambda } from '../email-notification-service/email-notification-service-lambda'; +import { EmailNotificationEvent } from '../lib/models/email-notification-service-event'; + +const SAMPLE_EVENT: EmailNotificationEvent = { + template: 'transactionBatchSettlementFailure', + recipientType: 'COMPACT_OPERATIONS_TEAM', + compact: 'aslp', + templateVariables: {} +}; + +const SAMPLE_COMPACT_CONFIGURATION = { + 'pk': { S: 'aslp#CONFIGURATION' }, + 'sk': { S: 'aslp#CONFIGURATION' }, + 'compactAdverseActionsNotificationEmails': { L: [{ S: 'adverse@example.com' }]}, + 'compactCommissionFee': { + M: { + 'feeAmount': { N: '3.5' }, + 'feeType': { S: 'FLAT_RATE' } + } + }, + 'compactName': { S: 'aslp' }, + 'compactOperationsTeamEmails': { L: [{ S: 'operations@example.com' }]}, + 'compactSummaryReportNotificationEmails': { L: [{ S: 'summary@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; + +const asSESClient = (mock: ReturnType) => + mock as unknown as SESClient; + +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(SESClient); + + // 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 + mockDynamoDBClient.on(GetItemCommand).resolves({ + Item: SAMPLE_COMPACT_CONFIGURATION + }); + + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'message-id-123' + }); + + 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 successfully send transaction batch settlement failure email', async () => { + const response = await lambda.handler(SAMPLE_EVENT, {} as any); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + // Verify DynamoDB was queried for compact configuration + expect(mockDynamoDBClient).toHaveReceivedCommandWith(GetItemCommand, { + TableName: 'compact-table', + Key: { + 'pk': { S: 'aslp#CONFIGURATION' }, + 'sk': { S: 'aslp#CONFIGURATION' } + } + }); + + // Verify email was sent with correct parameters + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Message: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('A transaction settlement error was detected') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Transactions Failed to Settle for ASLP Payment Processor' + } + }, + Source: 'Compact Connect ' + }); + }); + + 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(); + }); +}); diff --git a/backend/compact-connect/lambdas/nodejs/tests/email-service.test.ts b/backend/compact-connect/lambdas/nodejs/tests/email-service.test.ts new file mode 100644 index 000000000..e02bbf15b --- /dev/null +++ b/backend/compact-connect/lambdas/nodejs/tests/email-service.test.ts @@ -0,0 +1,300 @@ +import { mockClient } from 'aws-sdk-client-mock'; +import 'aws-sdk-client-mock-jest'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { SendEmailCommand, SESClient } from '@aws-sdk/client-ses'; +import { IValidationErrorEventRecord } from '../lib/models'; +import { EmailService } from '../lib/email-service'; +import { CompactConfigurationClient } from '../lib/compact-configuration-client'; +import { + SAMPLE_SORTABLE_VALIDATION_ERROR_RECORDS, + SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD, + SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD +} from './sample-records'; + +const SAMPLE_COMPACT_CONFIG = { + pk: 'aslp#CONFIGURATION', + sk: 'aslp#CONFIGURATION', + compactAdverseActionsNotificationEmails: ['adverse@example.com'], + compactCommissionFee: { + feeAmount: 3.5, + feeType: 'FLAT_RATE' + }, + compactName: 'aslp', + compactOperationsTeamEmails: ['operations@example.com'], + compactSummaryReportNotificationEmails: ['summary@example.com'], + dateOfUpdate: '2024-12-10T19:27:28+00:00', + type: 'compact' +}; + +/* + * Double casting to allow us to pass a mock in for the real thing + */ +const asSESClient = (mock: ReturnType) => + mock as unknown as SESClient; + +describe('Email Service', () => { + let mockSESClient: ReturnType; + let mockCompactConfigurationClient: jest.Mocked; + let emailService: EmailService; + + beforeAll(async () => { + process.env.DEBUG = 'true'; + process.env.FROM_ADDRESS = 'noreply@example.org'; + process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockSESClient = mockClient(SESClient); + mockCompactConfigurationClient = { + getCompactConfiguration: jest.fn(), + } as unknown as jest.Mocked; + + mockSESClient.on(SendEmailCommand).resolves({ + MessageId: 'message-id-123' + }); + + emailService = new EmailService({ + logger: new Logger(), + sesClient: asSESClient(mockSESClient), + compactConfigurationClient: mockCompactConfigurationClient + }); + }); + + it('should render an html document', async () => { + const template = emailService.generateReport( + { + ingestFailures: [ SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD ], + validationErrors: [ SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD ] + }, + 'aslp', + '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 ] + }, + 'aslp', + 'ohio', + [ + 'operations@example.com' + ] + ); + + expect(messageId).toEqual('message-id-123'); + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Message: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Data Error Summary: aslp / ohio' + } + }, + Source: 'Compact Connect ' + } + ); + }); + + 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( + 'aslp', + 'ohio', + [ 'operations@example.com' ] + ); + + expect(messageId).toEqual('message-id-123'); + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Message: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'License Data Summary: aslp / ohio' + } + }, + Source: 'Compact Connect ' + } + ); + }); + + it('should send a "no license updates" email with expected image url', async () => { + const messageId = await emailService.sendNoLicenseUpdatesEmail( + 'aslp', + 'ohio', + [ 'operations@example.com' ] + ); + + expect(messageId).toEqual('message-id-123'); + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Message: { + 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: aslp / ohio' + } + }, + Source: 'Compact Connect ' + } + ); + }); + + describe('Transaction Batch Settlement Failure', () => { + it('should send email using compact operations team emails', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await emailService.sendTransactionBatchSettlementFailureEmail( + 'aslp', + 'COMPACT_OPERATIONS_TEAM' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Message: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Transactions Failed to Settle for ASLP Payment Processor' + } + }, + Source: 'Compact Connect ' + } + ); + }); + + it('should send email using specific emails', async () => { + await emailService.sendTransactionBatchSettlementFailureEmail( + 'aslp', + 'SPECIFIC', + ['specific@example.com'] + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['specific@example.com'] + }, + Message: expect.any(Object), + Source: 'Compact Connect ' + } + ); + }); + + it('should throw error when no recipients found', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue({ + ...SAMPLE_COMPACT_CONFIG, + compactOperationsTeamEmails: [] + }); + + await expect(emailService.sendTransactionBatchSettlementFailureEmail( + 'aslp', + 'COMPACT_OPERATIONS_TEAM' + )).rejects.toThrow('No recipients found for compact aslp with recipient type COMPACT_OPERATIONS_TEAM'); + }); + + it('should throw error for specific recipient type without emails', async () => { + await expect(emailService.sendTransactionBatchSettlementFailureEmail( + 'aslp', + 'SPECIFIC' + )).rejects.toThrow('SPECIFIC recipientType requested but no specific email addresses provided'); + }); + + it('should throw error for unsupported recipient type', async () => { + await expect(emailService.sendTransactionBatchSettlementFailureEmail( + 'aslp', + 'JURISDICTION_OPERATIONS_TEAM' + )).rejects.toThrow('Unsupported recipient type for compact configuration: JURISDICTION_OPERATIONS_TEAM'); + }); + + it('should include logo in email', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await emailService.sendTransactionBatchSettlementFailureEmail( + 'aslp', + 'COMPACT_OPERATIONS_TEAM' + ); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['operations@example.com'] + }, + Message: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('src=\"https://app.test.compactconnect.org/img/email/compact-connect-logo-final.png\"') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Transactions Failed to Settle for ASLP Payment Processor' + } + }, + Source: 'Compact Connect ' + } + ); + }); + }); +}); diff --git a/backend/compact-connect/lambdas/nodejs/tests/lambda.test.ts b/backend/compact-connect/lambdas/nodejs/tests/lambda.test.ts index 3cb802097..1353b3cdc 100644 --- a/backend/compact-connect/lambdas/nodejs/tests/lambda.test.ts +++ b/backend/compact-connect/lambdas/nodejs/tests/lambda.test.ts @@ -4,8 +4,8 @@ import { Context, EventBridgeEvent } from 'aws-lambda'; import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb'; import { SendEmailCommand, SESClient } from '@aws-sdk/client-ses'; -import { Lambda } from '../lib/lambda'; -import { ReportEmailer } from '../lib/report-emailer'; +import { Lambda } from '../ingest-event-reporter/lambda'; +import { EmailService } from '../lib/email-service'; import { IEventBridgeEvent } from '../lib/models/event-bridge-event-detail'; import { SAMPLE_INGEST_FAILURE_ERROR_RECORD, @@ -49,7 +49,7 @@ const asSESClient = (mock: ReturnType) => mock as unknown as SESClient; -jest.mock('../lib/report-emailer'); +jest.mock('../lib/email-service'); const mockSendReportEmail = jest.fn( (events, recipients: string[]) => Promise.resolve('message-id-123') @@ -62,7 +62,7 @@ const mockSendNoLicenseUpdatesEmail = jest.fn( (recipients: string[]) => Promise.resolve('message-id-no-license-updates') ); -(ReportEmailer as jest.Mock) = jest.fn().mockImplementation(() => ({ +(EmailService as jest.Mock) = jest.fn().mockImplementation(() => ({ sendReportEmail: mockSendReportEmail, sendAllsWellEmail: mockSendAllsWellEmail, sendNoLicenseUpdatesEmail: mockSendNoLicenseUpdatesEmail @@ -71,7 +71,7 @@ const mockSendNoLicenseUpdatesEmail = jest.fn( describe('Nightly runs', () => { let mockSESClient: ReturnType; - let mockReportEmailer: jest.Mocked; + let mockEmailService: jest.Mocked; let lambda: Lambda; beforeAll(async () => { diff --git a/backend/compact-connect/lambdas/nodejs/tests/report-emailer.test.ts b/backend/compact-connect/lambdas/nodejs/tests/report-emailer.test.ts deleted file mode 100644 index 89a9bc6ea..000000000 --- a/backend/compact-connect/lambdas/nodejs/tests/report-emailer.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { mockClient } from 'aws-sdk-client-mock'; -import 'aws-sdk-client-mock-jest'; -import { Logger } from '@aws-lambda-powertools/logger'; -import { SendEmailCommand, SESClient } from '@aws-sdk/client-ses'; -import { IValidationErrorEventRecord } from '../lib/models'; -import { ReportEmailer } from '../lib/report-emailer'; -import { - SAMPLE_SORTABLE_VALIDATION_ERROR_RECORDS, - SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD, - SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD -} from './sample-records'; - -/* - * Double casting to allow us to pass a mock in for the real thing - */ -const asSESClient = (mock: ReturnType) => - mock as unknown as SESClient; - - -describe('Report emailer', () => { - let mockSESClient: ReturnType; - - beforeAll(async () => { - process.env.DEBUG = 'true'; - process.env.FROM_ADDRESS = 'noreply@example.org'; - process.env.UI_BASE_PATH_URL = 'https://app.test.compactconnect.org'; - - mockSESClient = mockClient(SESClient); - - mockSESClient.on(SendEmailCommand).resolves({ - MessageId: 'message-id-123' - }); - - }); - - it('should render an html document', async () => { - const logger = new Logger(); - const reportEmailer = new ReportEmailer({ - logger: logger, - sesClient: asSESClient(mockSESClient) - }); - const template = reportEmailer.generateReport( - { - ingestFailures: [ SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD ], - validationErrors: [ SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD ] - }, - 'aslp', - '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 logger = new Logger(); - const sesClient = new SESClient(); - const reportEmailer = new ReportEmailer({ - logger: logger, - sesClient: sesClient - }); - const messageId = await reportEmailer.sendReportEmail( - { - ingestFailures: [ SAMPLE_UNMARSHALLED_INGEST_FAILURE_ERROR_RECORD ], - validationErrors: [ SAMPLE_UNMARSHALLED_VALIDATION_ERROR_RECORD ] - }, - 'aslp', - 'ohio', - [ - 'operations@example.com' - ] - ); - - expect(messageId).toEqual('message-id-123'); - expect(mockSESClient).toHaveReceivedCommandWith( - SendEmailCommand, - { - Destination: { - ToAddresses: ['operations@example.com'] - }, - Message: { - Body: { - Html: { - Charset: 'UTF-8', - Data: expect.stringContaining('') - } - }, - Subject: { - Charset: 'UTF-8', - Data: 'License Data Error Summary: aslp / ohio' - } - }, - Source: 'Compact Connect ' - } - ); - }); - - it('should sort validation errors by record number then time', async () => { - const logger = new Logger(); - const sesClient = new SESClient(); - - class TestableReportEmailer extends ReportEmailer { - public testSortValidationErrors(validationErrors: IValidationErrorEventRecord[]) { - return this.sortValidationErrors(validationErrors); - } - } - - const reportEmailer = new TestableReportEmailer({ - logger: logger, - sesClient: sesClient - }); - - const sorted = reportEmailer.testSortValidationErrors( - 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 logger = new Logger(); - const sesClient = new SESClient(); - const reportEmailer = new ReportEmailer({ - logger: logger, - sesClient: sesClient - }); - const messageId = await reportEmailer.sendAllsWellEmail( - 'aslp', - 'ohio', - [ 'operations@example.com' ] - ); - - expect(messageId).toEqual('message-id-123'); - expect(mockSESClient).toHaveReceivedCommandWith( - SendEmailCommand, - { - Destination: { - ToAddresses: ['operations@example.com'] - }, - Message: { - Body: { - Html: { - Charset: 'UTF-8', - Data: expect.stringContaining('') - } - }, - Subject: { - Charset: 'UTF-8', - Data: 'License Data Summary: aslp / ohio' - } - }, - Source: 'Compact Connect ' - } - ); - }); - - it('should send a "no license updates" email with expected image url', async () => { - const logger = new Logger(); - const sesClient = new SESClient(); - const reportEmailer = new ReportEmailer({ - logger: logger, - sesClient: sesClient - }); - const messageId = await reportEmailer.sendNoLicenseUpdatesEmail( - 'aslp', - 'ohio', - [ 'operations@example.com' ] - ); - - expect(messageId).toEqual('message-id-123'); - expect(mockSESClient).toHaveReceivedCommandWith( - SendEmailCommand, - { - Destination: { - ToAddresses: ['operations@example.com'] - }, - Message: { - 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: aslp / ohio' - } - }, - Source: 'Compact Connect ' - } - ); - }); -}); diff --git a/backend/compact-connect/lambdas/python/attestations/.coveragerc b/backend/compact-connect/lambdas/python/attestations/.coveragerc new file mode 100644 index 000000000..99c409d65 --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/.coveragerc @@ -0,0 +1,10 @@ +[run] +data_file = ../../../.coverage + +omit = + */cdk.out/* + */smoke-test/* + */tests/* + +[report] +skip_empty = true diff --git a/backend/compact-connect/lambdas/python/delete-objects/tests/__init__.py b/backend/compact-connect/lambdas/python/attestations/handlers/__init__.py similarity index 100% rename from backend/compact-connect/lambdas/python/delete-objects/tests/__init__.py rename to backend/compact-connect/lambdas/python/attestations/handlers/__init__.py diff --git a/backend/compact-connect/lambdas/python/attestations/handlers/attestations.py b/backend/compact-connect/lambdas/python/attestations/handlers/attestations.py new file mode 100644 index 000000000..6567655b2 --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/handlers/attestations.py @@ -0,0 +1,42 @@ +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.exceptions import CCInvalidRequestException +from cc_common.utils import api_handler + + +@api_handler +def attestations(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """Handle attestation requests.""" + # handle GET method + if event['httpMethod'] == 'GET': + return _get_attestation(event, context) + + raise CCInvalidRequestException('Invalid HTTP method') + + +def _get_attestation(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument + """ + Endpoint to get the latest version of an attestation by type. + + :param event: API Gateway event + :param context: Lambda context + :return: The latest version of the attestation record + """ + compact = event['pathParameters']['compact'] + attestation_id = event['pathParameters']['attestationId'] + # 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 {} + locale = query_string_params.get('locale', 'en') + + logger.info( + 'Getting attestation', + compact=compact, + attestation_id=attestation_id, + locale=locale, + ) + + return config.compact_configuration_client.get_attestation( + compact=compact, + attestation_id=attestation_id, + locale=locale, + ) diff --git a/backend/compact-connect/lambdas/python/attestations/requirements-dev.in b/backend/compact-connect/lambdas/python/attestations/requirements-dev.in new file mode 100644 index 000000000..5a61b7b0d --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/requirements-dev.in @@ -0,0 +1 @@ +moto[dynamodb, s3]>=5.0.12, <6 diff --git a/backend/compact-connect/lambdas/python/attestations/requirements-dev.txt b/backend/compact-connect/lambdas/python/attestations/requirements-dev.txt new file mode 100644 index 000000000..a878347c1 --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/requirements-dev.txt @@ -0,0 +1,70 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --no-emit-index-url compact-connect/lambdas/python/attestations/requirements-dev.in +# +boto3==1.35.92 + # via moto +botocore==1.35.92 + # via + # boto3 + # moto + # s3transfer +certifi==2024.12.14 + # via requests +cffi==1.17.1 + # via cryptography +charset-normalizer==3.4.1 + # via requests +cryptography==44.0.0 + # via moto +docker==7.1.0 + # via moto +idna==3.10 + # via requests +jinja2==3.1.5 + # via moto +jmespath==1.0.1 + # via + # boto3 + # botocore +markupsafe==3.0.2 + # via + # jinja2 + # werkzeug +moto[dynamodb,s3]==5.0.26 + # via -r compact-connect/lambdas/python/attestations/requirements-dev.in +py-partiql-parser==0.6.1 + # via moto +pycparser==2.22 + # via cffi +python-dateutil==2.9.0.post0 + # via + # botocore + # moto +pyyaml==6.0.2 + # via + # moto + # responses +requests==2.32.3 + # via + # docker + # moto + # responses +responses==0.25.3 + # via moto +s3transfer==0.10.4 + # via boto3 +six==1.17.0 + # via python-dateutil +urllib3==2.3.0 + # via + # botocore + # docker + # requests + # responses +werkzeug==3.1.3 + # via moto +xmltodict==0.14.2 + # via moto diff --git a/backend/compact-connect/lambdas/python/attestations/requirements.in b/backend/compact-connect/lambdas/python/attestations/requirements.in new file mode 100644 index 000000000..68b7c56e7 --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/requirements.in @@ -0,0 +1 @@ +# common requirements are managed in the common requirements.in file diff --git a/backend/compact-connect/lambdas/python/attestations/requirements.txt b/backend/compact-connect/lambdas/python/attestations/requirements.txt new file mode 100644 index 000000000..13a990a83 --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/requirements.txt @@ -0,0 +1,6 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --no-emit-index-url compact-connect/lambdas/python/attestations/requirements.in +# diff --git a/backend/compact-connect/lambdas/python/attestations/tests/__init__.py b/backend/compact-connect/lambdas/python/attestations/tests/__init__.py new file mode 100644 index 000000000..fdcc24c2a --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/tests/__init__.py @@ -0,0 +1,27 @@ +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', + 'AWS_DEFAULT_REGION': 'us-east-1', + 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'COMPACTS': '["aslp", "octp", "coun"]', + 'JURISDICTIONS': '["ne", "oh", "ky"]', + }, + ) + # 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/compact-connect/lambdas/python/attestations/tests/function/__init__.py b/backend/compact-connect/lambdas/python/attestations/tests/function/__init__.py new file mode 100644 index 000000000..b224762c4 --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/tests/function/__init__.py @@ -0,0 +1,54 @@ +import json +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): + self.create_compact_configuration_table() + + def create_compact_configuration_table(self): + from cc_common.data_model.schema.attestation import AttestationRecordSchema + + 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', + ) + + with open('../common/tests/resources/dynamo/attestation.json') as f: + json_data = json.load(f) + # adding four versions of the same attestation to test getting the latest version + for i in range(1, 5): + json_data['version'] = str(i) + # strip off the pk and sk, then using schema to add them + json_data.pop('pk') + json_data.pop('sk') + serialized_data = AttestationRecordSchema().dump(json_data) + self._compact_configuration_table.put_item(Item=serialized_data) + + def delete_resources(self): + self._compact_configuration_table.delete() diff --git a/backend/compact-connect/lambdas/python/attestations/tests/function/test_attestations.py b/backend/compact-connect/lambdas/python/attestations/tests/function/test_attestations.py new file mode 100644 index 000000000..63fb7cfc3 --- /dev/null +++ b/backend/compact-connect/lambdas/python/attestations/tests/function/test_attestations.py @@ -0,0 +1,70 @@ +import json + +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +class TestGetAttestation(TstFunction): + """Test suite for attestation endpoints.""" + + def _generate_test_event(self, method: str, attestation_type: str) -> dict: + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + event['httpMethod'] = method + event['pathParameters'] = { + 'compact': 'aslp', + 'attestationId': attestation_type, + } + + return event + + def test_get_latest_attestation(self): + """Test getting the latest version of an attestation.""" + from handlers.attestations import attestations + + event = self._generate_test_event('GET', 'jurisprudence-confirmation') + + response = attestations(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + response_body = json.loads(response['body']) + + # The TstFunction class sets up 4 versions of this attestation, we expect the endpoint to return version 4 + # as it's the latest + self.assertEqual( + { + 'attestationId': 'jurisprudence-confirmation', + 'compact': 'aslp', + 'dateCreated': '2024-06-06T23:59:59+00:00', + # this field is dynamic, so setting it to match the actual response + 'dateOfUpdate': response_body['dateOfUpdate'], + 'description': 'For displaying the jurisprudence confirmation', + 'displayName': 'Jurisprudence Confirmation', + 'required': True, + 'text': 'You attest that you have read and understand the jurisprudence ' + 'requirements for all states you are purchasing privileges for.', + 'type': 'attestation', + 'version': '4', + 'locale': 'en', + }, + response_body, + ) + + def test_get_nonexistent_attestation(self): + """Test getting an attestation that doesn't exist.""" + from handlers.attestations import attestations + + event = self._generate_test_event('GET', 'nonexistent-type') + + response = attestations(event, self.mock_context) + self.assertEqual(404, response['statusCode']) + + def test_invalid_http_method(self): + """Test that non-GET methods are rejected.""" + from handlers.attestations import attestations + + event = self._generate_test_event('POST', 'jurisprudence-confirmation') + + response = attestations(event, self.mock_context) + self.assertEqual(400, response['statusCode']) diff --git a/backend/compact-connect/lambdas/python/common/.coveragerc b/backend/compact-connect/lambdas/python/common/.coveragerc index 99c409d65..193e8ec8a 100644 --- a/backend/compact-connect/lambdas/python/common/.coveragerc +++ b/backend/compact-connect/lambdas/python/common/.coveragerc @@ -1,5 +1,5 @@ [run] -data_file = ../../../.coverage +data_file = ../../../../.coverage omit = */cdk.out/* diff --git a/backend/compact-connect/lambdas/python/common/cc_common/config.py b/backend/compact-connect/lambdas/python/common/cc_common/config.py index 59bce70ee..45abbed82 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/config.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/config.py @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,8 @@ 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 @@ -35,10 +38,16 @@ def dynamodb_client(self): @cached_property def data_client(self): - from cc_common.data_model.client import DataClient + 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 user_client(self): from cc_common.data_model.user_client import UserClient @@ -65,6 +74,10 @@ def event_bus_name(self): 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'] @@ -92,6 +105,10 @@ def license_types_for_compact(self, compact): 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'] @@ -100,6 +117,10 @@ def fam_giv_mid_index_name(self): def date_of_update_index_name(self): return os.environ['PROV_DATE_OF_UPDATE_INDEX_NAME'] + @property + def ssn_inverted_index_name(self): + return os.environ['SSN_INVERTED_INDEX_NAME'] + @property def bulk_bucket_name(self): return os.environ['BULK_BUCKET_NAME'] @@ -158,5 +179,19 @@ def current_standard_datetime(self): """ return datetime.now(tz=UTC).replace(microsecond=0) + @cached_property + def transaction_client(self): + from cc_common.data_model.transaction_client import TransactionClient + + return TransactionClient(self) + + @property + def transaction_history_table_name(self): + return os.environ['TRANSACTION_HISTORY_TABLE_NAME'] + + @property + def transaction_history_table(self): + return boto3.resource('dynamodb').Table(self.transaction_history_table_name) + config = _Config() diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py new file mode 100644 index 000000000..602546f70 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py @@ -0,0 +1,68 @@ +from boto3.dynamodb.conditions import Key + +from cc_common.config import _Config, logger +from cc_common.data_model.schema.attestation import AttestationRecordSchema +from cc_common.exceptions import CCNotFoundException + + +class CompactConfigurationClient: + """Client interface for compact configuration dynamodb queries""" + + def __init__(self, config: _Config): + self.config = config + self.attestation_schema = AttestationRecordSchema() + + def get_attestation(self, *, compact: str, attestation_id: str, locale: str = 'en') -> dict: + """ + Get the latest version of an attestation. + + :param compact: The compact name + :param attestation_id: The attestation id used to query. + :param locale: The language code for the attestation text (defaults to 'en') + :return: The attestation record + :raises CCNotFoundException: If no attestation is found + """ + logger.info('Getting attestation', compact=compact, attestation_type=attestation_id, locale=locale) + + pk = f'COMPACT#{compact}#ATTESTATIONS' + sk_prefix = f'COMPACT#{compact}#LOCALE#{locale}#ATTESTATION#{attestation_id}#VERSION#' + response = self.config.compact_configuration_table.query( + KeyConditionExpression=Key('pk').eq(pk) & Key('sk').begins_with(sk_prefix), + ScanIndexForward=False, # Sort in descending order + Limit=1, # We only want the latest version + ) + + items = response.get('Items', []) + if not items: + raise CCNotFoundException(f'No attestation found for type "{attestation_id}" in locale "{locale}"') + + # Load and return the latest version through the schema + return self.attestation_schema.load(items[0]) + + def get_attestations_by_locale(self, *, compact: str, locale: str = 'en') -> dict[str, dict]: + """ + Get all attestations for a compact and locale, keyed by attestation ID. + Returns only the latest version of each attestation. + + :param compact: The compact name + :param locale: The language code for the attestation text (defaults to 'en') + :return: Dictionary of attestation records keyed by attestation ID + """ + logger.info('Getting all attestations', compact=compact, locale=locale) + + pk = f'COMPACT#{compact}#ATTESTATIONS' + sk_prefix = f'COMPACT#{compact}#LOCALE#{locale}#ATTESTATION#' + response = self.config.compact_configuration_table.query( + KeyConditionExpression=Key('pk').eq(pk) & Key('sk').begins_with(sk_prefix), + ScanIndexForward=False, # Sort in descending order to get latest versions first + ) + + # Group by attestation ID and take the first (latest) version + attestations_by_id = {} + for item in response.get('Items', []): + attestation = self.attestation_schema.load(item) + attestation_id = attestation['attestationId'] + if attestation_id not in attestations_by_id: + attestations_by_id[attestation_id] = attestation + + return attestations_by_id diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py similarity index 55% rename from backend/compact-connect/lambdas/python/common/cc_common/data_model/client.py rename to backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index bb4bb80fa..20792eb32 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -2,19 +2,21 @@ from urllib.parse import quote from uuid import uuid4 +from aws_lambda_powertools.metrics import MetricUnit from boto3.dynamodb.conditions import Attr, Key -from boto3.dynamodb.types import TypeDeserializer +from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from botocore.exceptions import ClientError -from cc_common.config import _Config, config, logger +from cc_common.config import _Config, config, logger, metrics from cc_common.data_model.query_paginator import paginated_query from cc_common.data_model.schema import PrivilegeRecordSchema from cc_common.data_model.schema.base_record import SSNIndexRecordSchema from cc_common.data_model.schema.military_affiliation import ( - MilitaryAffiliationRecordSchema, MilitaryAffiliationStatus, MilitaryAffiliationType, ) +from cc_common.data_model.schema.military_affiliation.record import MilitaryAffiliationRecordSchema +from cc_common.data_model.schema.privilege.record import PrivilegeUpdateRecordSchema from cc_common.exceptions import CCAwsServiceException, CCNotFoundException @@ -29,7 +31,7 @@ def get_provider_id(self, *, compact: str, ssn: str) -> str: """Get all records associated with a given SSN.""" logger.info('Getting provider id by ssn') try: - resp = self.config.provider_table.get_item( + resp = self.config.ssn_table.get_item( Key={'pk': f'{compact}#SSN#{ssn}', 'sk': f'{compact}#SSN#{ssn}'}, ConsistentRead=True, )['Item'] @@ -44,7 +46,7 @@ def get_or_create_provider_id(self, *, compact: str, ssn: str) -> str: # 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.provider_table.put_item( + self.config.ssn_table.put_item( Item={ 'pk': f'{compact}#SSN#{ssn}', 'sk': f'{compact}#SSN#{ssn}', @@ -61,6 +63,8 @@ def get_or_create_provider_id(self, *, compact: str, ssn: str) -> str: # 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 @paginated_query @@ -169,34 +173,36 @@ def get_privilege_purchase_options(self, *, compact: str, dynamo_pagination: dic def _generate_privilege_record( self, - compact_name: str, + compact: str, provider_id: str, jurisdiction_postal_abbreviation: str, license_expiration_date: date, compact_transaction_id: str, + attestations: list[dict], original_issuance_date: datetime | None = None, ): current_datetime = config.current_standard_datetime - privilege_object = { + return { 'providerId': provider_id, - 'compact': compact_name, + 'compact': compact, 'jurisdiction': jurisdiction_postal_abbreviation.lower(), 'dateOfIssuance': original_issuance_date if original_issuance_date else current_datetime, 'dateOfRenewal': current_datetime, 'dateOfExpiration': license_expiration_date, 'compactTransactionId': compact_transaction_id, + 'attestations': attestations, } - schema = PrivilegeRecordSchema() - return schema.dump(privilege_object) def create_provider_privileges( self, - compact_name: str, + compact: str, provider_id: str, jurisdiction_postal_abbreviations: list[str], license_expiration_date: date, compact_transaction_id: str, + provider_record: dict, existing_privileges: list[dict], + attestations: list[dict], ): """ Create privilege records for a provider in the database. @@ -205,70 +211,239 @@ def create_provider_privileges( the entire transaction will be rolled back. As this is usually performed after a provider has purchased one or more privileges, it is important that all records are created successfully. - :param compact_name: The compact name + :param compact: The compact name :param provider_id: The provider id :param jurisdiction_postal_abbreviations: The list of jurisdiction postal codes :param license_expiration_date: The license expiration date :param compact_transaction_id: The compact transaction id + :param provider_record: The original provider record :param existing_privileges: The list of existing privileges for this user. Used to track the original issuance date of the privilege. + :param attestations: List of attestations that were accepted when purchasing the privileges """ logger.info( 'Creating provider privileges', provider_id=provider_id, + compact=compact, privlige_jurisdictions=jurisdiction_postal_abbreviations, compact_transaction_id=compact_transaction_id, ) try: - # the batch writer handles retries and sending the requests in batches - with self.config.provider_table.batch_writer() as batch: - for postal_abbreviation in jurisdiction_postal_abbreviations: - # get the original privilege issuance date from an existing privilege record if it exists - original_privilege_issuance_date = next( - ( - record['dateOfIssuance'] - for record in existing_privileges - if record['jurisdiction'].lower() == postal_abbreviation.lower() - ), - None, + # We'll collect all the record changes into a transaction to protect data consistency + transactions = [] + processed_transactions = [] + privilege_update_records = [] + + for postal_abbreviation in jurisdiction_postal_abbreviations: + # get the original privilege issuance date from an existing privilege record if it exists + original_privilege = next( + ( + record + for record in existing_privileges + if record['jurisdiction'].lower() == postal_abbreviation.lower() + ), + None, + ) + original_issuance_date = original_privilege['dateOfIssuance'] if original_privilege else None + + privilege_record = self._generate_privilege_record( + compact=compact, + provider_id=provider_id, + jurisdiction_postal_abbreviation=postal_abbreviation, + license_expiration_date=license_expiration_date, + compact_transaction_id=compact_transaction_id, + original_issuance_date=original_issuance_date, + attestations=attestations, + ) + + # Create privilege update record if this is updating an existing privilege + if original_privilege: + update_record = { + 'type': 'privilegeUpdate', + 'updateType': 'renewal', + 'providerId': provider_id, + 'compact': compact, + 'jurisdiction': postal_abbreviation.lower(), + 'previous': original_privilege, + 'updatedValues': { + 'dateOfRenewal': privilege_record['dateOfRenewal'], + 'dateOfExpiration': privilege_record['dateOfExpiration'], + 'compactTransactionId': compact_transaction_id, + }, + } + privilege_update_records.append(update_record) + transactions.append( + { + 'Put': { + 'TableName': self.config.provider_table_name, + 'Item': TypeSerializer().serialize(PrivilegeUpdateRecordSchema().dump(update_record))[ + 'M' + ], + } + } ) - privilege_record = self._generate_privilege_record( - compact_name=compact_name, + transactions.append( + { + 'Put': { + 'TableName': self.config.provider_table_name, + 'Item': TypeSerializer().serialize(PrivilegeRecordSchema().dump(privilege_record))['M'], + } + } + ) + + # We save this update till last so that it is least likely to be changed in the event of a failure in one of + # the other transactions. + transactions.append( + { + 'Update': { + 'TableName': self.config.provider_table_name, + 'Key': { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + }, + 'UpdateExpression': 'ADD #privilegeJurisdictions :newJurisdictions', + 'ExpressionAttributeNames': {'#privilegeJurisdictions': 'privilegeJurisdictions'}, + 'ExpressionAttributeValues': {':newJurisdictions': {'SS': jurisdiction_postal_abbreviations}}, + } + } + ) + + # Unfortunately, we can't guarantee that the number of transactions is below the 100 action limit + # for extremely large purchases. To handle those large purchases, we will have to break our transactions + # up and handle a multi-transaction roll-back on failure. + # We'll collect data for sizes, just so we can keep an eye on them and understand user behavior + metrics.add_metric( + name='privilege-purchase-transaction-size', unit=MetricUnit.Count, value=len(transactions) + ) + metrics.add_metric( + name='privileges-purchased', unit=MetricUnit.Count, value=len(jurisdiction_postal_abbreviations) + ) + # 100 is the maximum transaction size + batch_size = 100 + # Iterate over the transactions until they are empty + while transaction_batch := transactions[:batch_size]: + self.config.dynamodb_client.transact_write_items(TransactItems=transaction_batch) + processed_transactions.extend(transaction_batch) + transactions = transactions[batch_size:] + if transactions: + logger.info( + 'Breaking privilege updates into multiple transactions', + compact=compact, provider_id=provider_id, - jurisdiction_postal_abbreviation=postal_abbreviation, - license_expiration_date=license_expiration_date, + privlige_jurisdictions=jurisdiction_postal_abbreviations, compact_transaction_id=compact_transaction_id, - original_issuance_date=original_privilege_issuance_date, ) - batch.put_item(Item=privilege_record) - - # finally we need to update the provider record to include the new privilege jurisdictions - # batch writer can't perform updates, so we'll use a transact_write_items call - self.config.provider_table.update_item( - Key={'pk': f'{compact_name}#PROVIDER#{provider_id}', 'sk': f'{compact_name}#PROVIDER'}, - UpdateExpression='ADD #privilegeJurisdictions :newJurisdictions', - ExpressionAttributeNames={'#privilegeJurisdictions': 'privilegeJurisdictions'}, - ExpressionAttributeValues={':newJurisdictions': set(jurisdiction_postal_abbreviations)}, - ) + except ClientError as e: message = 'Unable to create all provider privileges. Rolling back transaction.' logger.info(message, error=str(e)) - # we must rollback and delete the privilege records that were created - with self.config.provider_table.batch_writer() as delete_batch: - for postal_abbreviation in jurisdiction_postal_abbreviations: - privilege_record = self._generate_privilege_record( - compact_name=compact_name, - provider_id=provider_id, - jurisdiction_postal_abbreviation=postal_abbreviation, - license_expiration_date=license_expiration_date, - compact_transaction_id=compact_transaction_id, - ) - # this transaction is idempotent, so we can safely delete the records even if they weren't created - delete_batch.delete_item(Key={'pk': privilege_record['pk'], 'sk': privilege_record['sk']}) + self._rollback_privilege_transactions( + processed_transactions=processed_transactions, + provider_record=provider_record, + existing_privileges=existing_privileges, + ) raise CCAwsServiceException(message) from e + def _rollback_privilege_transactions( + self, + processed_transactions: list[dict], + provider_record: dict, + existing_privileges: list[dict], + ): + """Roll back successful privilege transactions after a failure.""" + rollback_transactions = [] + + compact = provider_record['compact'] + provider_id = provider_record['providerId'] + + # Create a lookup of existing privileges by jurisdiction + existing_privileges_by_jurisdiction = { + privilege['jurisdiction']: privilege for privilege in existing_privileges + } + + # Delete all privilege update records and handle privilege records appropriately + privilege_record_schema = PrivilegeRecordSchema() + for transaction in processed_transactions: + if transaction.get('Put'): + item = TypeDeserializer().deserialize({'M': transaction['Put']['Item']}) + if item.get('type') == 'privilegeUpdate': + # Always delete update records as they are always new + rollback_transactions.append( + { + 'Delete': { + 'TableName': self.config.provider_table_name, + 'Key': { + 'pk': {'S': item['pk']}, + 'sk': {'S': item['sk']}, + }, + } + } + ) + elif item.get('type') == 'privilege': + # For privilege records, check if it was an update or new creation + original_privilege = existing_privileges_by_jurisdiction.get(item['jurisdiction']) + if original_privilege: + logger.info( + 'Restoring original privilege record', + provider_id=provider_id, + compact=compact, + jurisdiction=item['jurisdiction'], + ) + # If it was an update, restore the original record + rollback_transactions.append( + { + 'Put': { + 'TableName': self.config.provider_table_name, + 'Item': TypeSerializer().serialize( + privilege_record_schema.dump(original_privilege) + )['M'], + } + } + ) + else: + # If it was a new creation, delete it + logger.info( + 'Deleting new privilege record', + provider_id=provider_id, + compact=compact, + jurisdiction=item['jurisdiction'], + ) + rollback_transactions.append( + { + 'Delete': { + 'TableName': self.config.provider_table_name, + 'Key': { + 'pk': {'S': item['pk']}, + 'sk': {'S': item['sk']}, + }, + } + } + ) + + # Restore the original provider record + rollback_transactions.append( + { + 'Put': { + 'TableName': self.config.provider_table_name, + 'Item': TypeSerializer().serialize(provider_record)['M'], + } + } + ) + + # Execute rollback in batches of 100 + batch_size = 100 + while rollback_batch := rollback_transactions[:batch_size]: + try: + logger.info('Submitting rollback transaction', provider_id=provider_id, compact=compact) + self.config.dynamodb_client.transact_write_items(TransactItems=rollback_batch) + rollback_transactions = rollback_transactions[batch_size:] + except ClientError as e: + logger.error('Failed to roll back privilege transactions', error=str(e)) + raise CCAwsServiceException('Failed to roll back privilege transactions') from e + logger.info('Privilege rollback complete', provider_id=provider_id, compact=compact) + def _get_military_affiliation_records_by_status( self, compact: str, provider_id: str, status: MilitaryAffiliationStatus ): diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/__init__.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/__init__.py index 1ef0e00b7..7612ecff1 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/__init__.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/__init__.py @@ -1,8 +1,8 @@ # ruff: noqa: F401 # We import all the record types with the package to ensure they are all registered -from .compact import CompactRecordSchema -from .jurisdiction import JurisdictionRecordSchema -from .license import LicenseRecordSchema -from .privilege import PrivilegeRecordSchema -from .provider import ProviderRecordSchema +from .compact.record import CompactRecordSchema +from .jurisdiction.record import JurisdictionRecordSchema +from .license.record import LicenseRecordSchema +from .privilege.record import PrivilegeRecordSchema +from .provider.record import ProviderRecordSchema from .user import UserRecordSchema diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/attestation.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/attestation.py new file mode 100644 index 000000000..0c3785cfd --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/attestation.py @@ -0,0 +1,42 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +# We diverge from PEP8 variable naming in schema because they map to our API JSON Schema in which, +# by convention, we use camelCase. +from marshmallow import pre_dump +from marshmallow.fields import Boolean, String +from marshmallow.validate import OneOf, Regexp + +from cc_common.data_model.schema.base_record import BaseRecordSchema + +ATTESTATION_TYPE = 'attestation' +SUPPORTED_LOCALES = ['en'] + + +@BaseRecordSchema.register_schema(ATTESTATION_TYPE) +class AttestationRecordSchema(BaseRecordSchema): + """Schema for attestation records""" + + _record_type = ATTESTATION_TYPE + + # Provided fields + attestationId = String(required=True, allow_none=False) + compact = String(required=True, allow_none=False) + # verify that version is a string of digits + # we store the version as a string, rather than an integer, to avoid + # type casting between DynamoDB's Decimal and Python's int types + version = String(required=True, allow_none=False, validate=Regexp(r'^\d+$')) + dateCreated = String(required=True, allow_none=False) + text = String(required=True, allow_none=False) + required = Boolean(required=True, allow_none=False) + displayName = String(required=True, allow_none=False) + description = String(required=True, allow_none=False) + locale = String(required=True, allow_none=False, validate=OneOf(SUPPORTED_LOCALES)) + + @pre_dump + def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument + """Generate the pk and sk fields for the attestation record""" + in_data['pk'] = f'COMPACT#{in_data["compact"]}#ATTESTATIONS' + in_data['sk'] = ( + f'COMPACT#{in_data["compact"]}#LOCALE#{in_data["locale"]}#ATTESTATION#' + f'{in_data["attestationId"]}#VERSION#{in_data["version"]}' + ) + return in_data diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/base_record.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/base_record.py index 554e80936..fd8055e68 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/base_record.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/base_record.py @@ -5,11 +5,11 @@ from datetime import date, datetime from marshmallow import EXCLUDE, RAISE, Schema, post_load, pre_dump, pre_load -from marshmallow.fields import UUID, DateTime, List, String -from marshmallow.validate import OneOf, Regexp +from marshmallow.fields import UUID, DateTime, String +from marshmallow.validate import OneOf from cc_common.config import config -from cc_common.data_model.schema.common import ensure_value_is_datetime +from cc_common.data_model.schema.fields import SocialSecurityNumber from cc_common.exceptions import CCInternalException @@ -27,25 +27,13 @@ class Meta: unknown = EXCLUDE -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 BaseRecordSchema(StrictSchema, ABC): - """Abstract base class, common to all records in the license data table""" + """ + Abstract base class, common to all records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ _record_type = None _registered_schema = {} @@ -58,13 +46,6 @@ class BaseRecordSchema(StrictSchema, ABC): # Provided fields type = String(required=True, allow_none=False) - @pre_load - def ensure_date_of_update_is_datetime(self, in_data, **kwargs): - # for backwards compatibility with the old data model, which was using a Date value - in_data['dateOfUpdate'] = ensure_value_is_datetime(in_data['dateOfUpdate']) - - return in_data - @post_load def drop_base_gen_fields(self, in_data, **kwargs): # noqa: ARG001 unused-argument """Drop the db-specific pk and sk fields before returning loaded data""" @@ -104,8 +85,13 @@ def get_schema_by_type(cls, record_type: str) -> Schema: class CalculatedStatusRecordSchema(BaseRecordSchema): - """Schema for records whose active/inactive status is determined at load time. This - includes licenses, privileges and provider records.""" + """ + Schema for records whose active/inactive status is determined at load time. This + includes licenses, privileges and provider records. + + Serialization direction: + DB -> load() -> Python + """ # This field is the actual status referenced by the system, which is determined by the expiration date # in addition to the jurisdictionStatus. This should never be written to the DB. It is calculated @@ -136,16 +122,14 @@ def _calculate_status(self, in_data, **kwargs): return in_data -class ITUTE164PhoneNumber(String): - """Phone number format consistent with ITU-T E.164: - https://www.itu.int/rec/T-REC-E.164-201011-I/en +class SSNIndexRecordSchema(StrictSchema): """ + Schema for records that translate between SSN and provider_id - def __init__(self, *args, **kwargs): - super().__init__(*args, validate=Regexp(r'^\+[0-9]{8,15}$'), **kwargs) - + Serialization direction: + DB -> load() -> Python + """ -class SSNIndexRecordSchema(StrictSchema): pk = String(required=True, allow_none=False) sk = String(required=True, allow_none=False) ssn = SocialSecurityNumber(required=True, allow_none=False) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/common.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/common.py index 1010e9cac..49c82e536 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/common.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/common.py @@ -1,11 +1,14 @@ +# ruff: noqa: N815 invalid-name +import json from datetime import UTC, datetime -from enum import Enum +from enum import StrEnum +from hashlib import md5 from marshmallow import Schema from marshmallow.fields import Dict, String, Url -class CCEnum(Enum): +class CCEnum(StrEnum): """ Base class for Compact Connect enums @@ -57,3 +60,46 @@ def ensure_value_is_datetime(value: str): # Not a date string, return the original return value + + +class UpdateCategory(CCEnum): + RENEWAL = 'renewal' + DEACTIVATION = 'deactivation' + OTHER = 'other' + + +class Status(CCEnum): + ACTIVE = 'active' + INACTIVE = 'inactive' + + +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() diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact.py deleted file mode 100644 index 716cde943..000000000 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact.py +++ /dev/null @@ -1,102 +0,0 @@ -# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs -from collections import UserDict - -from marshmallow import Schema, pre_dump -from marshmallow.fields import Decimal, 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, ForgivingSchema -from cc_common.data_model.schema.common import CCEnum - -COMPACT_TYPE = 'compact' - - -class CompactFeeType(CCEnum): - FLAT_RATE = 'FLAT_RATE' - - -class CompactCommissionFeeSchema(Schema): - feeType = String(required=True, allow_none=False, validate=OneOf([e.value for e in CompactFeeType])) - feeAmount = Decimal(required=True, allow_none=False) - - -@BaseRecordSchema.register_schema(COMPACT_TYPE) -class CompactRecordSchema(BaseRecordSchema): - """Schema for the root compact configuration records""" - - _record_type = COMPACT_TYPE - - # Provided fields - compactName = String(required=True, allow_none=False, validate=OneOf(config.compacts)) - compactCommissionFee = Nested(CompactCommissionFeeSchema(), 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, - ) - compactSummaryReportNotificationEmails = List( - String(required=True, allow_none=False), - 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 - # the pk and sk are the same for the root compact record - in_data['pk'] = f'{in_data['compactName']}#CONFIGURATION' - in_data['sk'] = f'{in_data['compactName']}#CONFIGURATION' - return in_data - - -class CompactOptionsApiResponseSchema(ForgivingSchema): - """Used to enforce which fields are returned in compact objects for the GET /purchase/privileges/options endpoint""" - - compactName = String(required=True, allow_none=False, validate=OneOf(config.compacts)) - compactCommissionFee = Nested(CompactCommissionFeeSchema(), required=True, allow_none=False) - type = String(required=True, allow_none=False, validate=OneOf([COMPACT_TYPE])) - - -class CompactCommissionFee(UserDict): - """ - Compact commission fee data model. Used to access variables without needing to know the underlying key structure. - """ - - @property - def fee_type(self) -> CompactFeeType: - return CompactFeeType.from_str(self['feeType']) - - @property - def fee_amount(self) -> float: - return float(self['feeAmount']) - - -class Compact(UserDict): - """ - Compact configuration data model. Used to access variables without needing to know the underlying key structure. - """ - - @property - def compact_name(self) -> str: - return self['compactName'] - - @property - def compact_commission_fee(self) -> CompactCommissionFee: - return CompactCommissionFee(self['compactCommissionFee']) - - @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 compact_summary_report_notification_emails(self) -> list[str] | None: - return self.get('compactSummaryReportNotificationEmails') diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py new file mode 100644 index 000000000..cb748bb1b --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/__init__.py @@ -0,0 +1,59 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from collections import UserDict + +from marshmallow import Schema +from marshmallow.fields import Decimal, String +from marshmallow.validate import OneOf + +from cc_common.data_model.schema.common import CCEnum + +COMPACT_TYPE = 'compact' + + +class CompactFeeType(CCEnum): + FLAT_RATE = 'FLAT_RATE' + + +class CompactCommissionFeeSchema(Schema): + feeType = String(required=True, allow_none=False, validate=OneOf([e.value for e in CompactFeeType])) + feeAmount = Decimal(required=True, allow_none=False) + + +class CompactCommissionFee(UserDict): + """ + Compact commission fee data model. Used to access variables without needing to know the underlying key structure. + """ + + @property + def fee_type(self) -> CompactFeeType: + return CompactFeeType.from_str(self['feeType']) + + @property + def fee_amount(self) -> float: + return float(self['feeAmount']) + + +class Compact(UserDict): + """ + Compact configuration data model. Used to access variables without needing to know the underlying key structure. + """ + + @property + def compact_name(self) -> str: + return self['compactName'] + + @property + def compact_commission_fee(self) -> CompactCommissionFee: + return CompactCommissionFee(self['compactCommissionFee']) + + @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 compact_summary_report_notification_emails(self) -> list[str] | None: + return self.get('compactSummaryReportNotificationEmails') diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/api.py new file mode 100644 index 000000000..8ab3a084e --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/api.py @@ -0,0 +1,15 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow.fields import Nested, String +from marshmallow.validate import OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.compact import COMPACT_TYPE, CompactCommissionFeeSchema + + +class CompactOptionsResponseSchema(ForgivingSchema): + """Used to enforce which fields are returned in compact objects for the GET /purchase/privileges/options endpoint""" + + compactName = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + compactCommissionFee = Nested(CompactCommissionFeeSchema(), required=True, allow_none=False) + type = String(required=True, allow_none=False, validate=OneOf([COMPACT_TYPE])) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/record.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/record.py new file mode 100644 index 000000000..f264ebbcb --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/compact/record.py @@ -0,0 +1,41 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow import pre_dump +from marshmallow.fields import 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 import COMPACT_TYPE, CompactCommissionFeeSchema + + +@BaseRecordSchema.register_schema(COMPACT_TYPE) +class CompactRecordSchema(BaseRecordSchema): + """Schema for the root compact configuration records""" + + _record_type = COMPACT_TYPE + + # Provided fields + compactName = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + compactCommissionFee = Nested(CompactCommissionFeeSchema(), 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, + ) + compactSummaryReportNotificationEmails = List( + String(required=True, allow_none=False), + 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 + # the pk and sk are the same for the root compact record + in_data['pk'] = f'{in_data['compactName']}#CONFIGURATION' + in_data['sk'] = f'{in_data['compactName']}#CONFIGURATION' + return in_data diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/fields.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/fields.py new file mode 100644 index 000000000..9c1a23aa1 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/fields.py @@ -0,0 +1,56 @@ +from marshmallow.fields import List, String +from marshmallow.validate import OneOf, Regexp + +from cc_common.config import config +from cc_common.data_model.schema.common import Status, UpdateCategory + + +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 Status]), **kwargs) + + +class UpdateType(String): + def __init__(self, *args, **kwargs): + super().__init__(*args, validate=OneOf([entry.value for entry in UpdateCategory]), **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) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction.py deleted file mode 100644 index 56c5ffda4..000000000 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction.py +++ /dev/null @@ -1,156 +0,0 @@ -# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs -from collections import UserDict - -from marshmallow import Schema, pre_dump -from marshmallow.fields import Boolean, Decimal, Email, 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, ForgivingSchema -from cc_common.data_model.schema.common import CCEnum - -JURISDICTION_TYPE = 'jurisdiction' - - -class JurisdictionMilitaryDiscountType(CCEnum): - FLAT_RATE = 'FLAT_RATE' - - -class JurisdictionMilitaryDiscountSchema(Schema): - active = Boolean(required=True, allow_none=False) - discountType = String( - required=True, allow_none=False, validate=OneOf([e.value for e in JurisdictionMilitaryDiscountType]) - ) - discountAmount = Decimal(required=True, allow_none=False) - - -class JurisdictionJurisprudenceRequirementsSchema(Schema): - required = Boolean(required=True, allow_none=False) - - -@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)) - jurisdictionFee = Decimal(required=True, allow_none=False) - militaryDiscount = Nested(JurisdictionMilitaryDiscountSchema(), required=False, allow_none=False) - jurisdictionOperationsTeamEmails = List( - Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) - ) - jurisdictionAdverseActionsNotificationEmails = List( - String(required=True, allow_none=False), - required=True, - allow_none=False, - ) - jurisdictionSummaryReportNotificationEmails = List( - String(required=True, allow_none=False), - required=True, - allow_none=False, - ) - jurisprudenceRequirements = Nested(JurisdictionJurisprudenceRequirementsSchema(), 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 - - -class JurisdictionOptionsApiResponseSchema(ForgivingSchema): - """ - Used to enforce which fields are returned in jurisdiction objects for the - GET /purchase/privileges/options 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)) - jurisdictionFee = Decimal(required=True, allow_none=False) - militaryDiscount = Nested(JurisdictionMilitaryDiscountSchema(), required=False, allow_none=False) - jurisprudenceRequirements = Nested(JurisdictionJurisprudenceRequirementsSchema(), required=True, allow_none=False) - type = String(required=True, allow_none=False, validate=OneOf([JURISDICTION_TYPE])) - - -class JurisdictionMilitaryDiscount(UserDict): - """ - Jurisdiction military discount data model. Used to access variables without needing to know - the underlying key structure. - """ - - @property - def active(self) -> bool: - return self['active'] - - @property - def discount_type(self) -> 'JurisdictionMilitaryDiscountType': - return JurisdictionMilitaryDiscountType.from_str(self['discountType']) - - @property - def discount_amount(self) -> float: - return float(self['discountAmount']) - - -class JurisdictionJurisprudenceRequirements(UserDict): - """ - Jurisdiction jurisprudence requirements data model. Used to access variables without needing to know - the underlying key structure. - """ - - @property - def required(self) -> bool: - return self['required'] - - -class Jurisdiction(UserDict): - """ - Jurisdiction configuration data model. Used to access variables without needing to know - the underlying key structure. - """ - - @property - def jurisdiction_name(self) -> str: - return self['jurisdictionName'] - - @property - def postal_abbreviation(self) -> str: - return self['postalAbbreviation'] - - @property - def compact(self) -> str: - return self['compact'] - - @property - def jurisdiction_fee(self) -> float: - return float(self['jurisdictionFee']) - - @property - def military_discount(self) -> JurisdictionMilitaryDiscount | None: - if 'militaryDiscount' in self.data: - return JurisdictionMilitaryDiscount(self.data['militaryDiscount']) - return None - - @property - def jurisprudence_requirements(self) -> JurisdictionJurisprudenceRequirements: - return JurisdictionJurisprudenceRequirements(self.data['jurisprudenceRequirements']) - - @property - def jurisdiction_operations_team_emails(self) -> list[str] | None: - return self.get('jurisdictionOperationsTeamEmails') - - @property - def jurisdiction_adverse_actions_notification_emails(self) -> list[str] | None: - return self.get('jurisdictionAdverseActionsNotificationEmails') - - @property - def jurisdiction_summary_report_notification_emails(self) -> list[str] | None: - return self.get('jurisdictionSummaryReportNotificationEmails') diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py new file mode 100644 index 000000000..a2b106f1b --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/__init__.py @@ -0,0 +1,85 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from collections import UserDict + +from cc_common.data_model.schema.common import CCEnum + +JURISDICTION_TYPE = 'jurisdiction' + + +class JurisdictionMilitaryDiscountType(CCEnum): + FLAT_RATE = 'FLAT_RATE' + + +class JurisdictionMilitaryDiscount(UserDict): + """ + Jurisdiction military discount data model. Used to access variables without needing to know + the underlying key structure. + """ + + @property + def active(self) -> bool: + return self['active'] + + @property + def discount_type(self) -> 'JurisdictionMilitaryDiscountType': + return JurisdictionMilitaryDiscountType.from_str(self['discountType']) + + @property + def discount_amount(self) -> float: + return float(self['discountAmount']) + + +class JurisdictionJurisprudenceRequirements(UserDict): + """ + Jurisdiction jurisprudence requirements data model. Used to access variables without needing to know + the underlying key structure. + """ + + @property + def required(self) -> bool: + return self['required'] + + +class Jurisdiction(UserDict): + """ + Jurisdiction configuration data model. Used to access variables without needing to know + the underlying key structure. + """ + + @property + def jurisdiction_name(self) -> str: + return self['jurisdictionName'] + + @property + def postal_abbreviation(self) -> str: + return self['postalAbbreviation'] + + @property + def compact(self) -> str: + return self['compact'] + + @property + def jurisdiction_fee(self) -> float: + return float(self['jurisdictionFee']) + + @property + def military_discount(self) -> JurisdictionMilitaryDiscount | None: + if 'militaryDiscount' in self.data: + return JurisdictionMilitaryDiscount(self.data['militaryDiscount']) + return None + + @property + def jurisprudence_requirements(self) -> JurisdictionJurisprudenceRequirements: + return JurisdictionJurisprudenceRequirements(self.data['jurisprudenceRequirements']) + + @property + def jurisdiction_operations_team_emails(self) -> list[str] | None: + return self.get('jurisdictionOperationsTeamEmails') + + @property + def jurisdiction_adverse_actions_notification_emails(self) -> list[str] | None: + return self.get('jurisdictionAdverseActionsNotificationEmails') + + @property + def jurisdiction_summary_report_notification_emails(self) -> list[str] | None: + return self.get('jurisdictionSummaryReportNotificationEmails') diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py new file mode 100644 index 000000000..eb583e6c4 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/api.py @@ -0,0 +1,37 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow import Schema +from marshmallow.fields import Boolean, Decimal, Nested, String +from marshmallow.validate import OneOf + +from cc_common.config import config +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.jurisdiction import JURISDICTION_TYPE, JurisdictionMilitaryDiscountType + + +class JurisdictionMilitaryDiscountResponseSchema(Schema): + active = Boolean(required=True, allow_none=False) + discountType = String( + required=True, allow_none=False, validate=OneOf([e.value for e in JurisdictionMilitaryDiscountType]) + ) + discountAmount = Decimal(required=True, allow_none=False) + + +class JurisdictionJurisprudenceRequirementsResponseSchema(Schema): + required = Boolean(required=True, allow_none=False) + + +class JurisdictionOptionsResponseSchema(ForgivingSchema): + """ + Used to enforce which fields are returned in jurisdiction objects for the + GET /purchase/privileges/options endpoint + """ + + type = String(required=True, allow_none=False, validate=OneOf([JURISDICTION_TYPE])) + 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)) + jurisdictionFee = Decimal(required=True, allow_none=False) + militaryDiscount = Nested(JurisdictionMilitaryDiscountResponseSchema(), required=False, allow_none=False) + jurisprudenceRequirements = Nested( + JurisdictionJurisprudenceRequirementsResponseSchema(), required=True, allow_none=False + ) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py new file mode 100644 index 000000000..b5a8e360b --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/jurisdiction/record.py @@ -0,0 +1,60 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-kwargs +from marshmallow import Schema, pre_dump +from marshmallow.fields import Boolean, Decimal, Email, 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.jurisdiction import JURISDICTION_TYPE, JurisdictionMilitaryDiscountType + + +class JurisdictionMilitaryDiscountRecordSchema(Schema): + active = Boolean(required=True, allow_none=False) + discountType = String( + required=True, allow_none=False, validate=OneOf([e.value for e in JurisdictionMilitaryDiscountType]) + ) + discountAmount = Decimal(required=True, allow_none=False) + + +class JurisdictionJurisprudenceRequirementsRecordSchema(Schema): + required = Boolean(required=True, allow_none=False) + + +@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)) + jurisdictionFee = Decimal(required=True, allow_none=False) + militaryDiscount = Nested(JurisdictionMilitaryDiscountRecordSchema(), required=False, allow_none=False) + jurisdictionOperationsTeamEmails = List( + Email(required=True, allow_none=False), required=True, allow_none=False, validate=Length(min=1) + ) + jurisdictionAdverseActionsNotificationEmails = List( + String(required=True, allow_none=False), + required=True, + allow_none=False, + ) + jurisdictionSummaryReportNotificationEmails = List( + String(required=True, allow_none=False), + required=True, + allow_none=False, + ) + jurisprudenceRequirements = Nested( + JurisdictionJurisprudenceRequirementsRecordSchema(), 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/compact-connect/lambdas/python/common/cc_common/data_model/schema/license.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license.py deleted file mode 100644 index 08f492f41..000000000 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license.py +++ /dev/null @@ -1,133 +0,0 @@ -# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument - -from marshmallow import ValidationError, pre_dump, pre_load, validates_schema -from marshmallow.fields import UUID, Boolean, Date, DateTime, Email, String -from marshmallow.validate import Length, OneOf, Regexp - -from cc_common.config import config -from cc_common.data_model.schema.base_record import ( - BaseRecordSchema, - CalculatedStatusRecordSchema, - ForgivingSchema, - ITUTE164PhoneNumber, - SocialSecurityNumber, -) - - -class LicensePublicSchema(ForgivingSchema): - """Schema for license data that can be shared with the public""" - - birthMonthDay = String(required=False, allow_none=False, validate=Regexp('^[0-1]{1}[0-9]{1}-[0-3]{1}[0-9]{1}')) - compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) - jurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) - licenseType = String(required=True, allow_none=False) - status = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) - 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 = Date(required=True, allow_none=False) - dateOfRenewal = Date(required=True, allow_none=False) - dateOfExpiration = Date(required=True, allow_none=False) - - -class LicenseCommonSchema(ForgivingSchema): - """ - 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. - """ - - compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) - jurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) - 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=True, 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)) - militaryWaiver = Boolean(required=False, allow_none=False) - 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): # 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)}.']}) - - -class LicensePostSchema(LicenseCommonSchema): - """Schema for license data as posted by a board""" - - ssn = SocialSecurityNumber(required=True, allow_none=False) - npi = String(required=False, allow_none=False, validate=Regexp('^[0-9]{10}$')) - # This status field is required when posting a license record. It will be transformed into the - # jurisdictionStatus field when the record is ingested. - status = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) - - -class SanitizedLicenseIngestDataEventSchema(ForgivingSchema): - """Schema which removes all pii from the license ingest event for storing in the database""" - - compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) - jurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) - licenseType = String(required=True, allow_none=False) - status = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) - dateOfIssuance = Date(required=True, allow_none=False) - dateOfRenewal = Date(required=True, allow_none=False) - dateOfExpiration = Date(required=True, allow_none=False) - eventTime = DateTime(required=True, allow_none=False) - - -class LicenseIngestSchema(LicenseCommonSchema): - """Schema for converting the external license data to the internal format""" - - ssn = SocialSecurityNumber(required=True, allow_none=False) - npi = String(required=False, allow_none=False, validate=Regexp('^[0-9]{10}$')) - # When a license record is first uploaded into the system, we store the value of - # 'status' under this field for backwards compatibility with the external contract. - # this is used to calculate the actual 'status' used by the system in addition - # to the expiration date of the license. - jurisdictionStatus = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) - - @pre_load - def pre_load_initialization(self, in_data, **kwargs): # noqa: ARG001 unused-argument - return self._set_jurisdiction_status(in_data) - - def _set_jurisdiction_status(self, in_data, **kwargs): - """ - When a license record is first uploaded into the system, the 'jurisdictionStatus' value is captured - from the 'status' field for backwards compatibility with the existing contract. - This maps the income 'status' value to the internal 'jurisdictionStatus' field. - """ - in_data['jurisdictionStatus'] = in_data.pop('status') - return in_data - - -@BaseRecordSchema.register_schema('license') -class LicenseRecordSchema(CalculatedStatusRecordSchema, LicenseCommonSchema): - """Schema for license records in the license data table""" - - _record_type = 'license' - - ssn = SocialSecurityNumber(required=True, allow_none=False) - npi = String(required=False, allow_none=False, validate=Regexp('^[0-9]{10}$')) - # Provided fields - providerId = UUID(required=True, allow_none=False) - jurisdictionStatus = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) - - @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#license/{in_data['jurisdiction']}#{in_data['dateOfRenewal']}' - return in_data diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/__init__.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/__init__.py new file mode 100644 index 000000000..f2f07dfea --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/__init__.py @@ -0,0 +1,53 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument + +from marshmallow import ValidationError, validates_schema +from marshmallow.fields import Boolean, Date, Email, String +from marshmallow.validate import Length + +from cc_common.config import config +from cc_common.data_model.schema.base_record import ( + ForgivingSchema, +) +from cc_common.data_model.schema.fields import ( + Compact, + ITUTE164PhoneNumber, + Jurisdiction, +) + + +class LicenseCommonSchema(ForgivingSchema): + """ + 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=True, 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)) + militaryWaiver = Boolean(required=False, allow_none=False) + 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): # 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)}.']}) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/api.py new file mode 100644 index 000000000..f7a7cccfb --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/api.py @@ -0,0 +1,147 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +""" +Schema for API objects. +""" + +from marshmallow import ValidationError, 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.base_record import ForgivingSchema +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + ITUTE164PhoneNumber, + Jurisdiction, + NationalProviderIdentifier, + SocialSecurityNumber, + UpdateType, +) + + +class LicensePostRequestSchema(ForgivingSchema): + """ + Schema for license data as posted by a board staff-user + + Serialization direction: + API -> load() -> Python + """ + + ssn = SocialSecurityNumber(required=True, allow_none=False) + npi = NationalProviderIdentifier(required=False, allow_none=False) + # This status field is required when posting a license record. It will be transformed into the + # jurisdictionStatus field when the record is ingested. + status = ActiveInactive(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=True, 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)) + militaryWaiver = Boolean(required=False, allow_none=False) + 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): # 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)}.']}) + + +class LicenseUpdatePreviousGeneralResponseSchema(ForgivingSchema): + """ + A snapshot of a previous state of a license object + + Serialization direction: + Python -> load() -> API + """ + + npi = NationalProviderIdentifier(required=False, 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)) + dateOfUpdate = Raw(required=True, allow_none=False) + dateOfIssuance = Raw(required=True, allow_none=False) + dateOfRenewal = Raw(required=True, 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)) + militaryWaiver = Boolean(required=False, allow_none=False) + emailAddress = Email(required=False, allow_none=False, validate=Length(1, 100)) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + jurisdictionStatus = ActiveInactive(required=True, allow_none=False) + + +class LicenseUpdateGeneralResponseSchema(ForgivingSchema): + """ + Schema for license update history entries in the license object + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False) + updateType = UpdateType(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) + dateOfUpdate = Raw(required=True, allow_none=False) + previous = Nested(LicenseUpdatePreviousGeneralResponseSchema(), 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(LicenseUpdatePreviousGeneralResponseSchema(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) + + +class LicenseGeneralResponseSchema(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) + jurisdictionStatus = ActiveInactive(required=True, allow_none=False) + npi = NationalProviderIdentifier(required=False, 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)) + dateOfIssuance = Raw(required=True, allow_none=False) + dateOfRenewal = Raw(required=True, 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, validate=Length(1, 100)) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + status = ActiveInactive(required=True, allow_none=False) + militaryWaiver = Boolean(required=False, allow_none=False) + history = List(Nested(LicenseUpdateGeneralResponseSchema, required=False, allow_none=False)) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/ingest.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/ingest.py new file mode 100644 index 000000000..e7553173e --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/ingest.py @@ -0,0 +1,61 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow import pre_load +from marshmallow.fields import Date, DateTime, String + +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + Jurisdiction, + NationalProviderIdentifier, + SocialSecurityNumber, +) +from cc_common.data_model.schema.license import LicenseCommonSchema + + +class LicenseIngestSchema(LicenseCommonSchema): + """ + Schema for converting the external license data to the internal format + + Serialization direction: + SQS -> load() -> Python + """ + + ssn = SocialSecurityNumber(required=True, allow_none=False) + npi = NationalProviderIdentifier(required=False, allow_none=False) + # When a license record is first uploaded into the system, we store the value of + # 'status' under this field for backwards compatibility with the external contract. + # this is used to calculate the actual 'status' used by the system in addition + # to the expiration date of the license. + jurisdictionStatus = ActiveInactive(required=True, allow_none=False) + + @pre_load + def pre_load_initialization(self, in_data, **kwargs): # noqa: ARG001 unused-argument + return self._set_jurisdiction_status(in_data) + + def _set_jurisdiction_status(self, in_data, **kwargs): + """ + When a license record is first uploaded into the system, the 'jurisdictionStatus' value is captured + from the 'status' field for backwards compatibility with the existing contract. + This maps the income 'status' value to the internal 'jurisdictionStatus' field. + """ + in_data['jurisdictionStatus'] = in_data.pop('status') + return in_data + + +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) + status = ActiveInactive(required=True, allow_none=False) + dateOfIssuance = Date(required=True, allow_none=False) + dateOfRenewal = Date(required=True, allow_none=False) + dateOfExpiration = Date(required=True, allow_none=False) + eventTime = DateTime(required=True, allow_none=False) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/record.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/record.py new file mode 100644 index 000000000..beaefe0cb --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/license/record.py @@ -0,0 +1,129 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow import ValidationError, post_dump, pre_dump, validates_schema +from marshmallow.fields import UUID, Boolean, Date, DateTime, 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, + CalculatedStatusRecordSchema, + StrictSchema, +) +from cc_common.data_model.schema.common import ChangeHashMixin +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + ITUTE164PhoneNumber, + Jurisdiction, + NationalProviderIdentifier, + SocialSecurityNumber, + UpdateType, +) +from cc_common.data_model.schema.license import LicenseCommonSchema + + +@BaseRecordSchema.register_schema('license') +class LicenseRecordSchema(CalculatedStatusRecordSchema, LicenseCommonSchema): + """ + Schema for license records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'license' + + ssn = SocialSecurityNumber(required=True, allow_none=False) + npi = NationalProviderIdentifier(required=False, allow_none=False) + # Provided fields + providerId = UUID(required=True, allow_none=False) + jurisdictionStatus = ActiveInactive(required=True, allow_none=False) + + @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#license/{in_data['jurisdiction']}#' + return in_data + + +class LicenseUpdateRecordPreviousSchema(StrictSchema): + """ + A snapshot of a previous state of a license record + + Serialization direction: + DB -> load() -> Python + """ + + ssn = SocialSecurityNumber(required=True, allow_none=False) + npi = NationalProviderIdentifier(required=False, 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)) + dateOfUpdate = DateTime(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=True, 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)) + militaryWaiver = Boolean(required=False, allow_none=False) + emailAddress = Email(required=False, allow_none=False, validate=Length(1, 100)) + phoneNumber = ITUTE164PhoneNumber(required=False, allow_none=False) + jurisdictionStatus = ActiveInactive(required=True, 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) + previous = Nested(LicenseUpdateRecordPreviousSchema, 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) + # 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 + """ + 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 a POSIX timestamp (seconds) and a hash of the changes + # to the record. We'll use the current time and the hash of the updatedValues + # field for this. + change_hash = self.hash_changes(in_data) + in_data['sk'] = ( + f'{in_data['compact']}#PROVIDER#license/{in_data['jurisdiction']}#UPDATE#{int(config.current_standard_datetime.timestamp())}/{change_hash}' + ) + 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['previous']['licenseType'] not in license_types: + raise ValidationError({'previous.licenseType': [f'Must be one of: {', '.join(license_types)}.']}) + # We have to check for existence here to allow for the updatedValues partial case + if data['updatedValues'].get('licenseType') and data['updatedValues']['licenseType'] not in license_types: + raise ValidationError({'updatedValues.licenseType': [f'Must be one of: {', '.join(license_types)}.']}) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/__init__.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/__init__.py new file mode 100644 index 000000000..06f90a041 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/__init__.py @@ -0,0 +1,20 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument + +from cc_common.data_model.schema.common import CCEnum + + +class MilitaryAffiliationStatus(CCEnum): + INITIALIZING = 'initializing' + ACTIVE = 'active' + INACTIVE = 'inactive' + + +class MilitaryAffiliationType(CCEnum): + MILITARY_MEMBER = 'militaryMember' + MILITARY_MEMBER_SPOUSE = 'militaryMemberSpouse' + + +SUPPORTED_MILITARY_AFFILIATION_FILE_EXTENSIONS = ('pdf', 'jpg', 'jpeg', 'png', 'docx') +MILITARY_AFFILIATIONS_DOCUMENT_TYPE_KEY_NAME = 'military-affiliations' + +MILITARY_AFFILIATION_RECORD_TYPE = 'militaryAffiliation' diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/api.py new file mode 100644 index 000000000..1d1bf43e7 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/api.py @@ -0,0 +1,43 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow.fields import List, Nested, Raw, String +from marshmallow.validate import 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 S3PresignedPostSchema +from cc_common.data_model.schema.military_affiliation import MilitaryAffiliationStatus, MilitaryAffiliationType + + +class MilitaryAffiliationGeneralResponseSchema(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) + dateOfUpdate = Raw(required=True, allow_none=False) + providerId = Raw(required=True, allow_none=False) + compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) + fileNames = List(String(required=True, allow_none=False), required=True, allow_none=False) + affiliationType = String( + required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationType]) + ) + dateOfUpload = Raw(required=True, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationStatus])) + + +class PostMilitaryAffiliationResponseSchema(ForgivingSchema): + """Schema for POST requests to create a new military affiliation record""" + + fileNames = List(String(required=True, allow_none=False), required=True, allow_none=False) + dateOfUpload = Raw(required=True, allow_none=False) + dateOfUpdate = Raw(required=True, allow_none=False) + status = String(required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationStatus])) + affiliationType = String( + required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationType]) + ) + documentUploadFields = List( + Nested(S3PresignedPostSchema(), required=True, allow_none=False), required=True, allow_none=False + ) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/record.py similarity index 54% rename from backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation.py rename to backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/record.py index acd5a18b3..6276b287d 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/military_affiliation/record.py @@ -1,29 +1,15 @@ # ruff: noqa: N801, N815, ARG002 invalid-name unused-argument - from marshmallow import pre_dump -from marshmallow.fields import UUID, DateTime, List, Nested, String +from marshmallow.fields import UUID, DateTime, List, String from marshmallow.validate import Length, OneOf 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 CCEnum, S3PresignedPostSchema - - -class MilitaryAffiliationStatus(CCEnum): - INITIALIZING = 'initializing' - ACTIVE = 'active' - INACTIVE = 'inactive' - - -class MilitaryAffiliationType(CCEnum): - MILITARY_MEMBER = 'militaryMember' - MILITARY_MEMBER_SPOUSE = 'militaryMemberSpouse' - - -SUPPORTED_MILITARY_AFFILIATION_FILE_EXTENSIONS = ('pdf', 'jpg', 'jpeg', 'png', 'docx') -MILITARY_AFFILIATIONS_DOCUMENT_TYPE_KEY_NAME = 'military-affiliations' - -MILITARY_AFFILIATION_RECORD_TYPE = 'militaryAffiliation' +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.military_affiliation import ( + MILITARY_AFFILIATION_RECORD_TYPE, + MilitaryAffiliationStatus, + MilitaryAffiliationType, +) @BaseRecordSchema.register_schema(MILITARY_AFFILIATION_RECORD_TYPE) @@ -53,18 +39,3 @@ def generate_pk_sk(self, in_data, **kwargs): # noqa: ARG001 unused-argument upload_date = in_data['dateOfUpload'].date().isoformat() in_data['sk'] = f'{in_data['compact']}#PROVIDER#military-affiliation#{upload_date}' return in_data - - -class PostMilitaryAffiliationResponseSchema(ForgivingSchema): - """Schema for POST requests to create a new military affiliation record""" - - fileNames = List(String(required=True, allow_none=False), required=True, allow_none=False) - dateOfUpload = DateTime(required=True, allow_none=False) - dateOfUpdate = DateTime(required=True, allow_none=False) - status = String(required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationStatus])) - affiliationType = String( - required=True, allow_none=False, validate=OneOf([e.value for e in MilitaryAffiliationType]) - ) - documentUploadFields = List( - Nested(S3PresignedPostSchema(), required=True, allow_none=False), required=True, allow_none=False - ) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege.py deleted file mode 100644 index 6b54ecfb4..000000000 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege.py +++ /dev/null @@ -1,52 +0,0 @@ -# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument - -from marshmallow import pre_dump, pre_load -from marshmallow.fields import UUID, Date, DateTime, String -from marshmallow.validate import Length, OneOf - -from cc_common.config import config -from cc_common.data_model.schema.base_record import BaseRecordSchema, CalculatedStatusRecordSchema -from cc_common.data_model.schema.common import ensure_value_is_datetime - - -@BaseRecordSchema.register_schema('privilege') -class PrivilegeRecordSchema(CalculatedStatusRecordSchema): - """Schema for privilege records in the license data table""" - - _record_type = 'privilege' - - # Provided fields - providerId = UUID(required=True, allow_none=False) - compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) - jurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) - dateOfIssuance = DateTime(required=True, allow_none=False) - dateOfRenewal = DateTime(required=True, allow_none=False) - # this is determined by the license expiration date, which is a date field, so this is also a date field - dateOfExpiration = Date(required=True, allow_none=False) - # the id of the transaction that was made when the user purchased the privilege - compactTransactionId = String(required=False, 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']}#PROVIDER#{in_data['providerId']}' - in_data['sk'] = ( - f'{in_data['compact']}#PROVIDER#privilege/{in_data['jurisdiction']}#{in_data['dateOfRenewal'].date().isoformat()}' # noqa: E501 - ) - return in_data - - @pre_load - def pre_load_initialization(self, in_data, **kwargs): # noqa: ARG001 unused-argument - return self._enforce_datetimes(in_data) - - def _enforce_datetimes(self, in_data, **kwargs): - # for backwards compatibility with the old data model - # we convert any records that are using a Date value - # for dateOfRenewal and dateOfIssuance to DateTime values - in_data['dateOfRenewal'] = ensure_value_is_datetime(in_data.get('dateOfRenewal', in_data['dateOfIssuance'])) - in_data['dateOfIssuance'] = ensure_value_is_datetime(in_data['dateOfIssuance']) - - return in_data diff --git a/backend/compact-connect/lambdas/python/license-data/data_model/__init__.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/__init__.py similarity index 100% rename from backend/compact-connect/lambdas/python/license-data/data_model/__init__.py rename to backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/__init__.py diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/api.py new file mode 100644 index 000000000..635b8f273 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/api.py @@ -0,0 +1,79 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow import Schema +from marshmallow.fields import List, Nested, Raw, String + +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.fields import ActiveInactive, Compact, Jurisdiction, UpdateType + + +class PrivilegeUpdatePreviousGeneralResponseSchema(ForgivingSchema): + """ + A snapshot of a previous state of a privilege object + + Serialization direction: + Python -> load() -> API + """ + + dateOfUpdate = Raw(required=True, allow_none=False) + dateOfIssuance = Raw(required=True, allow_none=False) + dateOfRenewal = Raw(required=True, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + compactTransactionId = String(required=False, allow_none=False) + + +class PrivilegeUpdateGeneralResponseSchema(ForgivingSchema): + """ + Schema for privilege update history entries in the privilege object + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False) + updateType = UpdateType(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) + dateOfUpdate = Raw(required=True, allow_none=False) + previous = Nested(PrivilegeUpdatePreviousGeneralResponseSchema(), 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(PrivilegeUpdatePreviousGeneralResponseSchema(partial=True), required=True, allow_none=False) + + +class AttestationVersionResponseSchema(Schema): + """ + This schema is intended to be used by any api response in the system which needs to track which attestations have + been accepted by a user (i.e. when purchasing privileges). + + This schema is intended to be used as a nested field in other schemas. + + Serialization direction: + Python -> load() -> API + """ + + attestationId = String(required=True, allow_none=False) + version = String(required=True, allow_none=False) + + +class PrivilegeGeneralResponseSchema(ForgivingSchema): + """ + A snapshot of a previous state of a privilege object + + Serialization direction: + Python -> load() -> API + """ + + type = String(required=True, allow_none=False) + dateOfUpdate = Raw(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) + dateOfIssuance = Raw(required=True, allow_none=False) + dateOfRenewal = Raw(required=True, allow_none=False) + dateOfExpiration = Raw(required=True, allow_none=False) + # the id of the transaction that was made when the user purchased the privilege + compactTransactionId = String(required=False, allow_none=False) + status = ActiveInactive(required=True, allow_none=False) + history = List(Nested(PrivilegeUpdateGeneralResponseSchema, required=False, allow_none=False)) + # list of attestations that were accepted when purchasing this privilege + attestations = List(Nested(AttestationVersionResponseSchema()), required=False, allow_none=False) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/record.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/record.py new file mode 100644 index 000000000..0165b7a29 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/privilege/record.py @@ -0,0 +1,115 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow import Schema, post_dump, pre_dump, pre_load +from marshmallow.fields import UUID, Date, DateTime, List, Nested, String + +from cc_common.config import config +from cc_common.data_model.schema.base_record import BaseRecordSchema, CalculatedStatusRecordSchema, ForgivingSchema +from cc_common.data_model.schema.common import ChangeHashMixin, ensure_value_is_datetime +from cc_common.data_model.schema.fields import Compact, Jurisdiction, UpdateType + + +class AttestationVersionRecordSchema(Schema): + """ + This schema is intended to be used by any record in the system which needs to track which attestations have been + accepted by a user (i.e. when purchasing privileges). + + This schema is intended to be used as a nested field in other schemas. + + Serialization direction: + DB -> load() -> Python + """ + + attestationId = String(required=True, allow_none=False) + version = String(required=True, allow_none=False) + + +@BaseRecordSchema.register_schema('privilege') +class PrivilegeRecordSchema(CalculatedStatusRecordSchema): + """ + Schema for privilege records in the license data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'privilege' + + # Provided fields + providerId = UUID(required=True, allow_none=False) + compact = Compact(required=True, allow_none=False) + jurisdiction = Jurisdiction(required=True, allow_none=False) + dateOfIssuance = DateTime(required=True, allow_none=False) + dateOfRenewal = DateTime(required=True, allow_none=False) + # this is determined by the license expiration date, which is a date field, so this is also a date field + dateOfExpiration = Date(required=True, allow_none=False) + # the id of the transaction that was made when the user purchased the privilege + compactTransactionId = String(required=False, allow_none=False) + # list of attestations that were accepted when purchasing this privilege + attestations = List(Nested(AttestationVersionRecordSchema()), required=False, allow_none=False) + + @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#privilege/{in_data['jurisdiction']}#' + return in_data + + @pre_load + def pre_load_initialization(self, in_data, **kwargs): # noqa: ARG001 unused-argument + return self._enforce_datetimes(in_data) + + def _enforce_datetimes(self, in_data, **kwargs): + # for backwards compatibility with the old data model + # we convert any records that are using a Date value + # for dateOfRenewal and dateOfIssuance to DateTime values + in_data['dateOfRenewal'] = ensure_value_is_datetime(in_data.get('dateOfRenewal', in_data['dateOfIssuance'])) + in_data['dateOfIssuance'] = ensure_value_is_datetime(in_data['dateOfIssuance']) + + return in_data + + +class PrivilegeUpdatePreviousRecordSchema(ForgivingSchema): + """ + A snapshot of a previous state of a privilege record + + Serialization direction: + DB -> load() -> Python + """ + + dateOfIssuance = DateTime(required=True, allow_none=False) + dateOfRenewal = DateTime(required=True, allow_none=False) + dateOfExpiration = Date(required=True, allow_none=False) + dateOfUpdate = DateTime(required=True, allow_none=False) + compactTransactionId = String(required=False, allow_none=False) + attestations = List(Nested(AttestationVersionRecordSchema()), required=False, allow_none=False) + + +@BaseRecordSchema.register_schema('privilegeUpdate') +class PrivilegeUpdateRecordSchema(BaseRecordSchema, ChangeHashMixin): + """ + Schema for privilege update history records in the provider data table + + Serialization direction: + DB -> load() -> Python + """ + + _record_type = 'privilegeUpdate' + + 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) + previous = Nested(PrivilegeUpdatePreviousRecordSchema, 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(PrivilegeUpdatePreviousRecordSchema(partial=True), required=True, 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 a POSIX timestamp (seconds) and a hash of the changes + # to the record. We'll use the current time and the hash of the updatedValues + # field for this. + change_hash = self.hash_changes(in_data) + in_data['sk'] = ( + f'{in_data['compact']}#PROVIDER#privilege/{in_data['jurisdiction']}#UPDATE#{int(config.current_standard_datetime.timestamp())}/{change_hash}' + ) + return in_data diff --git a/backend/compact-connect/lambdas/python/license-data/handlers/__init__.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/__init__.py similarity index 100% rename from backend/compact-connect/lambdas/python/license-data/handlers/__init__.py rename to backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/__init__.py diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/api.py new file mode 100644 index 000000000..8cee2ccb5 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/api.py @@ -0,0 +1,68 @@ +# ruff: noqa: N801, N815, ARG002 invalid-name unused-argument +from marshmallow.fields import Boolean, Email, List, Nested, Raw, String +from marshmallow.validate import Length, Regexp + +from cc_common.data_model.schema.base_record import ForgivingSchema +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, + ITUTE164PhoneNumber, + Jurisdiction, + NationalProviderIdentifier, + Set, +) +from cc_common.data_model.schema.license.api import LicenseGeneralResponseSchema +from cc_common.data_model.schema.military_affiliation.api import MilitaryAffiliationGeneralResponseSchema +from cc_common.data_model.schema.privilege.api import PrivilegeGeneralResponseSchema + + +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) + npi = NationalProviderIdentifier(required=False, allow_none=False) + licenseType = String(required=True, allow_none=False) + jurisdictionStatus = ActiveInactive(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) + 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) + status = ActiveInactive(required=True, allow_none=False) + militaryWaiver = Boolean(required=False, allow_none=False) + + privilegeJurisdictions = Set(String, required=False, allow_none=False, load_default=set()) + providerFamGivMid = String(required=False, allow_none=False, validate=Length(2, 400)) + providerDateOfUpdate = Raw(required=False, allow_none=False) + birthMonthDay = String(required=False, 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)) + militaryAffiliations = List(Nested(MilitaryAffiliationGeneralResponseSchema(), required=False, allow_none=False)) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/record.py similarity index 84% rename from backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider.py rename to backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/record.py index 8307a10e0..d6b9df137 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/provider/record.py @@ -3,32 +3,35 @@ from marshmallow import ValidationError, post_load, pre_dump, pre_load, validates_schema from marshmallow.fields import UUID, Boolean, Date, DateTime, Email, String -from marshmallow.validate import Length, OneOf, Regexp +from marshmallow.validate import Length, Regexp from cc_common.config import config -from cc_common.data_model.schema.base_record import ( - BaseRecordSchema, - CalculatedStatusRecordSchema, - ForgivingSchema, +from cc_common.data_model.schema.base_record import BaseRecordSchema, CalculatedStatusRecordSchema, ForgivingSchema +from cc_common.data_model.schema.common import ensure_value_is_datetime +from cc_common.data_model.schema.fields import ( + ActiveInactive, + Compact, ITUTE164PhoneNumber, + Jurisdiction, + NationalProviderIdentifier, Set, SocialSecurityNumber, ) -from cc_common.data_model.schema.common import ensure_value_is_datetime -class ProviderPublicSchema(ForgivingSchema): - """Schema for license data that can be shared with the public""" +class ProviderPrivateSchema(ForgivingSchema): + """Schema for provider data that can be shared with the staff users with the appropriate permissions, as well as + the provider themselves""" # Provided fields providerId = UUID(required=True, allow_none=False) - compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) - licenseJurisdiction = String(required=True, allow_none=False, validate=OneOf(config.jurisdictions)) + compact = Compact(required=True, allow_none=False) + licenseJurisdiction = Jurisdiction(required=True, allow_none=False) ssn = SocialSecurityNumber(required=True, allow_none=False) - npi = String(required=False, allow_none=False, validate=Regexp('^[0-9]{10}$')) + npi = NationalProviderIdentifier(required=False, allow_none=False) licenseType = String(required=True, allow_none=False) - jurisdictionStatus = String(required=True, allow_none=False, validate=OneOf(['active', 'inactive'])) + jurisdictionStatus = ActiveInactive(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)) @@ -57,7 +60,7 @@ def validate_license_type(self, data, **kwargs): # noqa: ARG001 unused-argument @BaseRecordSchema.register_schema('provider') -class ProviderRecordSchema(CalculatedStatusRecordSchema, ProviderPublicSchema): +class ProviderRecordSchema(CalculatedStatusRecordSchema, ProviderPrivateSchema): """Schema for license records in the license data table""" _record_type = 'provider' diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/user.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/user.py index f542e4e51..79e9ab27c 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/user.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/user.py @@ -5,7 +5,8 @@ from marshmallow.validate import Length, OneOf from cc_common.config import config -from cc_common.data_model.schema.base_record import BaseRecordSchema, Set +from cc_common.data_model.schema.base_record import BaseRecordSchema +from cc_common.data_model.schema.fields import Set class CompactPermissionsRecordSchema(Schema): diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py new file mode 100644 index 000000000..8581aa610 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/transaction_client.py @@ -0,0 +1,41 @@ +from datetime import datetime + +from cc_common.config import _Config + +AUTHORIZE_DOT_NET_CLIENT_TYPE = 'authorize.net' + + +class TransactionClient: + """Client interface for transaction history data dynamodb queries""" + + def __init__(self, config: _Config): + self.config = config + + def store_transactions(self, compact: str, transactions: list[dict]) -> None: + """ + Store transaction records in DynamoDB. + + :param compact: The compact name + :param transactions: List of transaction records to store + """ + with self.config.transaction_history_table.batch_writer() as batch: + for transaction in transactions: + # Convert UTC timestamp to epoch for sorting + transaction_processor = transaction['transactionProcessor'] + if transaction_processor == AUTHORIZE_DOT_NET_CLIENT_TYPE: + settlement_time = datetime.fromisoformat(transaction['batch']['settlementTimeUTC']) + epoch_timestamp = int(settlement_time.timestamp()) + month_key = settlement_time.strftime('%Y-%m') + + # Create the composite keys + pk = f'COMPACT#{compact}#TRANSACTIONS#MONTH#{month_key}' + sk = ( + f'COMPACT#{compact}#TIME#{epoch_timestamp}#BATCH#{transaction["batch"]["batchId"]}' + f'#TX#{transaction["transactionId"]}' + ) + + # Store the full transaction record along with the keys + item = {'pk': pk, 'sk': sk, **transaction} + batch.put_item(Item=item) + else: + raise ValueError(f'Unsupported transaction processor: {transaction_processor}') diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py index ca1264bdf..3e59e83aa 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/user_client.py @@ -96,10 +96,10 @@ def update_user_permissions( ```json { "permissions": { - "actions": { "admin" } + "actions": { "admin", "readPrivate" }, "jurisdictions": { "oh": { - "actions": { "admin", "write" } + "actions": { "admin", "write", "readPrivate" } } } } diff --git a/backend/compact-connect/lambdas/python/common/cc_common/exceptions.py b/backend/compact-connect/lambdas/python/common/cc_common/exceptions.py index c93b60548..a75245404 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/exceptions.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/exceptions.py @@ -34,3 +34,7 @@ class CCAwsServiceException(CCBaseException): class CCConflictException(CCBaseException): """Client error in the request, corresponds to a 409 response""" + + +class TransactionBatchSettlementFailureException(CCBaseException): + """Raised when a transaction batch has a settlement error.""" diff --git a/backend/compact-connect/lambdas/python/common/cc_common/utils.py b/backend/compact-connect/lambdas/python/common/cc_common/utils.py index be1f19fec..83494f628 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/utils.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/utils.py @@ -10,7 +10,8 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from botocore.exceptions import ClientError -from cc_common.config import logger +from cc_common.config import logger, metrics +from cc_common.data_model.schema.provider.api import ProviderGeneralResponseSchema from cc_common.exceptions import ( CCAccessDeniedException, CCInvalidRequestException, @@ -51,6 +52,7 @@ def api_handler(fn: Callable): """ @wraps(fn) + @metrics.log_metrics @logger.inject_lambda_context def caught_handler(event, context: LambdaContext): # We have to jump through extra hoops to handle the case where APIGW sets headers to null @@ -61,6 +63,7 @@ def caught_handler(event, context: LambdaContext): 'Incoming request', method=event['httpMethod'], path=event['requestContext']['resourcePath'], + identity={'user': event['requestContext'].get('authorizer', {}).get('claims', {}).get('sub')}, query_params=event['queryStringParameters'], username=event['requestContext'].get('authorizer', {}).get('claims', {}).get('cognito:username'), context=context, @@ -119,7 +122,7 @@ def caught_handler(event, context: LambdaContext): class authorize_compact: # noqa: N801 invalid-name - """Authorize endpoint by matching path parameter compact to the expected scope, (i.e. aslp/read)""" + """Authorize endpoint by matching path parameter compact to the expected scope, (i.e. aslp/readGeneral)""" def __init__(self, action: str): super().__init__() @@ -164,8 +167,9 @@ def _authorize_compact_with_scope(event: dict, resource_parameter: str, scope_pa For each of these actions, specific rules apply to the scope required to perform the action, which are as follows: - Read - granted at compact level, allows read access to all jurisdictions within the compact. - i.e. aslp/read would allow read access to all jurisdictions within the aslp compact. + ReadGeneral - granted at compact level, allows read access to all generally available (not private) jurisdiction + data within the compact. + i.e. aslp/readGeneral would allow read access to all generally available jurisdiction data within the aslp compact. Write - granted at jurisdiction level, allows write access to a specific jurisdiction within the compact. i.e. aslp/oh.write would allow write access to the ohio jurisdiction within the aslp compact. @@ -260,6 +264,7 @@ def sqs_handler(fn: Callable): """ @wraps(fn) + @metrics.log_metrics @logger.inject_lambda_context def process_messages(event, context: LambdaContext): # noqa: ARG001 unused-argument records = event['Records'] @@ -308,6 +313,12 @@ def get_allowed_jurisdictions(*, compact: str, scopes: set[str]) -> list[str] | 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(' ')) @@ -344,6 +355,13 @@ def collect_and_authorize_changes(*, path_compact: str, scopes: set, compact_cha for action, value in compact_changes.get('actions', {}).items(): if action == 'admin' and f'{path_compact}/{path_compact}.admin' not in scopes: raise CCAccessDeniedException('Only compact admins can affect compact-level admin permissions') + if action == 'readPrivate' and f'{path_compact}/{path_compact}.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 == '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) @@ -359,6 +377,11 @@ def collect_and_authorize_changes(*, path_compact: str, scopes: set, compact_cha ) for action, value in jurisdiction_changes.get('actions', {}).items(): + # dropping the read action as this is now implicitly granted to all users + if action == '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: @@ -377,3 +400,59 @@ def get_sub_from_user_attributes(attributes: list): if attribute['Name'] == 'sub': return attribute['Value'] raise ValueError('Failed to find user sub!') + + +def _user_has_private_read_access_for_provider(compact: str, provider_information: dict, scopes: set[str]) -> bool: + if f'{compact}/{compact}.readPrivate' in scopes: + logger.debug( + 'User has readPrivate 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'{compact}/{jurisdiction}.readPrivate' in scopes: + logger.debug( + 'User has readPrivate permission at jurisdiction level', + compact=compact, + provider_id=provider_information['providerId'], + jurisdiction=jurisdiction, + ) + return True + + logger.debug( + 'Caller does not have readPrivate 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 user's scopes from the request. + :return: The provider record, sanitized based on the user's scopes. + """ + if _user_has_private_read_access_for_provider(compact=compact, provider_information=provider, scopes=scopes): + # return full object since caller has 'readPrivate' access for provider + return 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) diff --git a/backend/compact-connect/lambdas/python/common/requirements-dev.txt b/backend/compact-connect/lambdas/python/common/requirements-dev.txt index 4cbcd70e7..0322fa337 100644 --- a/backend/compact-connect/lambdas/python/common/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/common/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/common/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.92 # via moto -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto @@ -25,7 +25,7 @@ faker==28.4.1 # via -r compact-connect/lambdas/python/common/requirements-dev.in idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -35,9 +35,9 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.26 # via -r compact-connect/lambdas/python/common/requirements-dev.in -py-partiql-parser==0.5.6 +py-partiql-parser==0.6.1 # via moto pycparser==2.22 # via cffi @@ -59,9 +59,9 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/common/requirements.txt b/backend/compact-connect/lambdas/python/common/requirements.txt index e263dd643..a98abe9c5 100644 --- a/backend/compact-connect/lambdas/python/common/requirements.txt +++ b/backend/compact-connect/lambdas/python/common/requirements.txt @@ -6,9 +6,9 @@ # aws-lambda-powertools==2.43.1 # via -r compact-connect/lambdas/python/common/requirements.in -boto3==1.35.67 +boto3==1.35.92 # via -r compact-connect/lambdas/python/common/requirements.in -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # s3transfer @@ -17,7 +17,7 @@ jmespath==1.0.1 # aws-lambda-powertools # boto3 # botocore -marshmallow==3.23.1 +marshmallow==3.23.3 # via -r compact-connect/lambdas/python/common/requirements.in packaging==24.2 # via marshmallow @@ -25,9 +25,9 @@ python-dateutil==2.9.0.post0 # via botocore s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil typing-extensions==4.12.2 # via aws-lambda-powertools -urllib3==2.2.3 +urllib3==2.3.0 # via botocore diff --git a/backend/compact-connect/lambdas/python/common/tests/__init__.py b/backend/compact-connect/lambdas/python/common/tests/__init__.py index 3ace1318b..3ffe73870 100644 --- a/backend/compact-connect/lambdas/python/common/tests/__init__.py +++ b/backend/compact-connect/lambdas/python/common/tests/__init__.py @@ -22,7 +22,7 @@ def setUpClass(cls): 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', 'FAM_GIV_INDEX_NAME': 'famGiv', 'USER_POOL_ID': 'us-east-1-12345', - 'USERS_TABLE_NAME': 'provider-table', + 'USERS_TABLE_NAME': 'users-table', 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', 'COMPACTS': '["aslp", "octp", "coun"]', 'JURISDICTIONS': '["ne", "oh", "ky"]', diff --git a/backend/compact-connect/lambdas/python/common/tests/function/__init__.py b/backend/compact-connect/lambdas/python/common/tests/function/__init__.py new file mode 100644 index 000000000..55f1e9cb9 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/function/__init__.py @@ -0,0 +1,266 @@ +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 + + cc_common.config.config = cc_common.config._Config() # noqa: SLF001 protected-access + self.config = cc_common.config.config + + def build_resources(self): + self.create_compact_configuration_table() + self.create_provider_table() + self.create_users_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) + + # 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): + 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_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_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 delete_resources(self): + self._compact_configuration_table.delete() + self._provider_table.delete() + self._users_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) + + # 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): + """Use the canned test resources to load a basic provider to the DB""" + test_resources = glob('../common/tests/resources/dynamo/provider.json') + + def privilege_jurisdictions_to_set(obj: dict): + if obj.get('type') == 'provider' and 'privilegeJurisdictions' in obj: + obj['privilegeJurisdictions'] = set(obj['privilegeJurisdictions']) + return obj + + for resource in test_resources: + with open(resource) as f: + record = json.load(f, object_hook=privilege_jurisdictions_to_set, parse_float=Decimal) + + logger.debug('Loading resource, %s: %s', resource, str(record)) + self._provider_table.put_item(Item=record) + + 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_military_affiliation_record_data(self, status: str = 'active'): + """Use the canned test resources to load a basic provider to the DB""" + with open('../common/tests/resources/dynamo/military-affiliation.json') as f: + record = json.load(f, parse_float=Decimal) + record['status'] = status + + 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.user 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, + '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.user 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._users_table.put_item( + Item=schema.dump( + { + 'userId': sub, + 'compact': compact, + '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']) + + @staticmethod + def _create_write_permissions(jurisdiction: str): + return {'actions': {'read'}, 'jurisdictions': {jurisdiction: {'write'}}} diff --git a/backend/compact-connect/lambdas/python/common/tests/function/test_data_client.py b/backend/compact-connect/lambdas/python/common/tests/function/test_data_client.py new file mode 100644 index 000000000..86e1493c9 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/function/test_data_client.py @@ -0,0 +1,322 @@ +from datetime import UTC, date, datetime +from unittest.mock import patch +from uuid import uuid4 + +from boto3.dynamodb.conditions import Key +from moto import mock_aws + +from tests.function import TstFunction + + +@mock_aws +class TestDataClient(TstFunction): + sample_privilege_attestations = [{'attestationId': 'jurisprudence-confirmation', 'version': '1'}] + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) + def test_data_client_created_privilege_record(self): + from cc_common.data_model.data_client import DataClient + + test_data_client = DataClient(self.config) + + test_data_client.create_provider_privileges( + compact='aslp', + provider_id='test_provider_id', + jurisdiction_postal_abbreviations=['ca'], + license_expiration_date=date.fromisoformat('2024-10-31'), + provider_record={}, + existing_privileges=[], + compact_transaction_id='test_transaction_id', + attestations=self.sample_privilege_attestations, + ) + + # Verify that the privilege record was created + new_privilege = self._provider_table.get_item( + Key={'pk': 'aslp#PROVIDER#test_provider_id', 'sk': 'aslp#PROVIDER#privilege/ca#'} + )['Item'] + self.assertEqual( + { + 'pk': 'aslp#PROVIDER#test_provider_id', + 'sk': 'aslp#PROVIDER#privilege/ca#', + 'type': 'privilege', + 'providerId': 'test_provider_id', + 'compact': 'aslp', + 'jurisdiction': 'ca', + 'dateOfIssuance': '2024-11-08T23:59:59+00:00', + 'dateOfRenewal': '2024-11-08T23:59:59+00:00', + 'dateOfExpiration': '2024-10-31', + 'dateOfUpdate': '2024-11-08T23:59:59+00:00', + 'compactTransactionId': 'test_transaction_id', + 'attestations': self.sample_privilege_attestations, + }, + new_privilege, + ) + + # Verify that the provider record was updated + updated_provider = self._provider_table.get_item( + Key={'pk': 'aslp#PROVIDER#test_provider_id', 'sk': 'aslp#PROVIDER'} + )['Item'] + self.assertEqual({'ca'}, updated_provider['privilegeJurisdictions']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) + def test_data_client_updates_privilege_records(self): + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.privilege.record import PrivilegeRecordSchema + + # Create the first privilege + provider_uuid = str(uuid4()) + original_privilege = { + 'pk': f'aslp#PROVIDER#{provider_uuid}', + 'sk': 'aslp#PROVIDER#privilege/ky#', + 'type': 'privilege', + 'providerId': provider_uuid, + 'compact': 'aslp', + 'jurisdiction': 'ky', + 'dateOfIssuance': '2023-11-08T23:59:59+00:00', + 'dateOfRenewal': '2023-11-08T23:59:59+00:00', + 'dateOfExpiration': '2024-10-31', + 'dateOfUpdate': '2023-11-08T23:59:59+00:00', + 'compactTransactionId': '1234567890', + 'attestations': self.sample_privilege_attestations, + } + self._provider_table.put_item(Item=original_privilege) + + test_data_client = DataClient(self.config) + + # Now, renew the privilege + test_data_client.create_provider_privileges( + compact='aslp', + provider_id='test_provider_id', + jurisdiction_postal_abbreviations=['ky'], + license_expiration_date=date.fromisoformat('2025-10-31'), + provider_record={ + 'pk': 'aslp#PROVIDER#test_provider_id', + 'sk': 'aslp#PROVIDER', + }, + existing_privileges=[PrivilegeRecordSchema().load(original_privilege)], + compact_transaction_id='test_transaction_id', + attestations=self.sample_privilege_attestations, + ) + + # Verify that the privilege record was created + new_privilege = self._provider_table.query( + KeyConditionExpression=Key('pk').eq('aslp#PROVIDER#test_provider_id') + & Key('sk').begins_with('aslp#PROVIDER#privilege/ky#'), + )['Items'] + self.maxDiff = None + self.assertEqual( + [ + # Primary record + { + 'pk': 'aslp#PROVIDER#test_provider_id', + 'sk': 'aslp#PROVIDER#privilege/ky#', + 'type': 'privilege', + 'providerId': 'test_provider_id', + 'compact': 'aslp', + 'jurisdiction': 'ky', + # Should be updated dates for renewal, expiration, update + 'dateOfIssuance': '2023-11-08T23:59:59+00:00', + 'dateOfRenewal': '2024-11-08T23:59:59+00:00', + 'dateOfExpiration': '2025-10-31', + 'dateOfUpdate': '2024-11-08T23:59:59+00:00', + 'compactTransactionId': 'test_transaction_id', + 'attestations': self.sample_privilege_attestations, + }, + # A new history record + { + 'pk': 'aslp#PROVIDER#test_provider_id', + 'sk': 'aslp#PROVIDER#privilege/ky#UPDATE#1731110399/5a9ac1180424bee1436ed2be1c6884b4', + 'type': 'privilegeUpdate', + 'updateType': 'renewal', + 'providerId': 'test_provider_id', + 'compact': 'aslp', + 'jurisdiction': 'ky', + 'dateOfUpdate': '2024-11-08T23:59:59+00:00', + 'previous': { + 'dateOfIssuance': '2023-11-08T23:59:59+00:00', + 'dateOfRenewal': '2023-11-08T23:59:59+00:00', + 'dateOfExpiration': '2024-10-31', + 'dateOfUpdate': '2023-11-08T23:59:59+00:00', + 'compactTransactionId': '1234567890', + 'attestations': self.sample_privilege_attestations, + }, + 'updatedValues': { + 'dateOfRenewal': '2024-11-08T23:59:59+00:00', + 'dateOfExpiration': '2025-10-31', + 'compactTransactionId': 'test_transaction_id', + }, + }, + ], + new_privilege, + ) + + # The renewal should still ensure that 'ky' is listed in provider privilegeJurisdictions + provider = self._provider_table.get_item( + Key={'pk': 'aslp#PROVIDER#test_provider_id', 'sk': 'aslp#PROVIDER'}, + )['Item'] + self.assertEqual({'ky'}, provider['privilegeJurisdictions']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) + def test_data_client_handles_large_privilege_purchase(self): + """Test that we can process privilege purchases with more than 100 transaction items.""" + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.privilege.record import PrivilegeRecordSchema + + test_data_client = DataClient(self.config) + provider_uuid = str(uuid4()) + + # Generate 51 jurisdictions (will create 102 records - 51 privileges and 51 updates) + jurisdictions = [f'j{i}' for i in range(51)] + original_privileges = [] + + # Create original privileges that will be updated + privilege_record_schema = PrivilegeRecordSchema() + for jurisdiction in jurisdictions: + # We'll use the schema to dump the privilege record to the table, but won't ever load again + # because we're using invalid jurisdiction abbreviations for testing convenience + original_privilege = { + 'type': 'privilege', + 'providerId': provider_uuid, + 'compact': 'aslp', + 'jurisdiction': jurisdiction, + 'dateOfIssuance': datetime(2023, 11, 8, 23, 59, 59, tzinfo=UTC), + 'dateOfRenewal': datetime(2023, 11, 8, 23, 59, 59, tzinfo=UTC), + 'dateOfExpiration': date(2024, 10, 31), + 'dateOfUpdate': datetime(2023, 11, 8, 23, 59, 59, tzinfo=UTC), + 'compactTransactionId': '1234567890', + } + self._provider_table.put_item(Item=privilege_record_schema.dump(original_privilege)) + original_privileges.append(original_privilege) + + # Now update all privileges + test_data_client.create_provider_privileges( + compact='aslp', + provider_id=provider_uuid, + jurisdiction_postal_abbreviations=jurisdictions, + license_expiration_date=date.fromisoformat('2025-10-31'), + provider_record={ + 'pk': f'aslp#PROVIDER#{provider_uuid}', + 'sk': 'aslp#PROVIDER', + 'privilegeJurisdictions': set(jurisdictions), + }, + existing_privileges=original_privileges, + compact_transaction_id='test_transaction_id', + attestations=self.sample_privilege_attestations, + ) + + # Verify that all privileges were updated + for jurisdiction in jurisdictions: + privilege_records = self._provider_table.query( + KeyConditionExpression=Key('pk').eq(f'aslp#PROVIDER#{provider_uuid}') + & Key('sk').begins_with(f'aslp#PROVIDER#privilege/{jurisdiction}#'), + )['Items'] + + self.assertEqual(2, len(privilege_records)) # One privilege record and one update record + + # Find the main privilege record + privilege_record = next(r for r in privilege_records if r['type'] == 'privilege') + self.assertEqual('2025-10-31', privilege_record['dateOfExpiration']) + self.assertEqual('test_transaction_id', privilege_record['compactTransactionId']) + + # Find the update record + update_record = next(r for r in privilege_records if r['type'] == 'privilegeUpdate') + self.assertEqual('renewal', update_record['updateType']) + self.assertEqual('2024-10-31', update_record['previous']['dateOfExpiration']) + self.assertEqual('2025-10-31', update_record['updatedValues']['dateOfExpiration']) + + # Verify the provider record was updated correctly + provider = self._provider_table.get_item( + Key={'pk': f'aslp#PROVIDER#{provider_uuid}', 'sk': 'aslp#PROVIDER'}, + )['Item'] + self.assertEqual(set(jurisdictions), provider['privilegeJurisdictions']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) + def test_data_client_rolls_back_failed_large_privilege_purchase(self): + """Test that we properly roll back when a large privilege purchase fails.""" + from botocore.exceptions import ClientError + from cc_common.data_model.data_client import DataClient + from cc_common.data_model.schema.privilege.record import PrivilegeRecordSchema + from cc_common.exceptions import CCAwsServiceException + + test_data_client = DataClient(self.config) + provider_uuid = str(uuid4()) + + # Generate 51 jurisdictions (will create 102 records - 51 privileges and 51 updates) + jurisdictions = [f'j{i}' for i in range(51)] + original_privileges = [] + + privilege_record_schema = PrivilegeRecordSchema() + for jurisdiction in jurisdictions: + # We'll use the schema to dump the privilege record to the table, but won't ever load again + # because we're using invalid jurisdiction abbreviations for testing convenience + original_privilege = { + 'type': 'privilege', + 'providerId': provider_uuid, + 'compact': 'aslp', + 'jurisdiction': jurisdiction, + 'dateOfIssuance': datetime(2023, 11, 8, 23, 59, 59, tzinfo=UTC), + 'dateOfRenewal': datetime(2023, 11, 8, 23, 59, 59, tzinfo=UTC), + 'dateOfExpiration': date(2024, 10, 31), + 'dateOfUpdate': datetime(2023, 11, 8, 23, 59, 59, tzinfo=UTC), + 'compactTransactionId': '1234567890', + } + dumped_privilege = privilege_record_schema.dump(original_privilege) + self._provider_table.put_item(Item=dumped_privilege) + original_privileges.append(original_privilege) + + # Store original provider record + original_provider = { + 'pk': f'aslp#PROVIDER#{provider_uuid}', + 'sk': 'aslp#PROVIDER', + 'providerId': provider_uuid, + 'compact': 'aslp', + 'privilegeJurisdictions': set(jurisdictions), + } + self._provider_table.put_item(Item=original_provider) + + # Mock DynamoDB to fail after first batch + original_transact_write_items = self.config.dynamodb_client.transact_write_items + call_count = 0 + + def mock_transact_write_items(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 2: # Fail on second batch + raise ClientError( + {'Error': {'Code': 'TransactionCanceledException', 'Message': 'Test error'}}, + 'TransactWriteItems', + ) + return original_transact_write_items(**kwargs) + + self.config.dynamodb_client.transact_write_items = mock_transact_write_items + + # Attempt to update all privileges (should fail) + with self.assertRaises(CCAwsServiceException): + test_data_client.create_provider_privileges( + compact='aslp', + provider_id=provider_uuid, + jurisdiction_postal_abbreviations=jurisdictions, + license_expiration_date=date.fromisoformat('2025-10-31'), + provider_record=original_provider, + existing_privileges=original_privileges, + compact_transaction_id='test_transaction_id', + attestations=self.sample_privilege_attestations, + ) + + # Verify that all privileges were restored to their original state + for jurisdiction in jurisdictions: + privilege_records = self._provider_table.query( + KeyConditionExpression=Key('pk').eq(f'aslp#PROVIDER#{provider_uuid}') + & Key('sk').begins_with(f'aslp#PROVIDER#privilege/{jurisdiction}#'), + )['Items'] + + self.assertEqual(1, len(privilege_records)) # Only the original privilege record should exist + privilege_record = privilege_records[0] + + self.assertEqual('2024-10-31', privilege_record['dateOfExpiration']) + self.assertEqual('1234567890', privilege_record['compactTransactionId']) + + # Verify the provider record was restored to its original state + provider = self._provider_table.get_item( + Key={'pk': f'aslp#PROVIDER#{provider_uuid}', 'sk': 'aslp#PROVIDER'}, + )['Item'] + self.assertEqual(set(jurisdictions), provider['privilegeJurisdictions']) diff --git a/backend/compact-connect/lambdas/python/license-data/tests/function/test_data_model/__init__.py b/backend/compact-connect/lambdas/python/common/tests/function/test_data_model/__init__.py similarity index 100% rename from backend/compact-connect/lambdas/python/license-data/tests/function/test_data_model/__init__.py rename to backend/compact-connect/lambdas/python/common/tests/function/test_data_model/__init__.py diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/test_client.py b/backend/compact-connect/lambdas/python/common/tests/function/test_data_model/test_user_client.py similarity index 97% rename from backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/test_client.py rename to backend/compact-connect/lambdas/python/common/tests/function/test_data_model/test_user_client.py index 0a78933be..2a438be62 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/test_client.py +++ b/backend/compact-connect/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -142,7 +142,7 @@ def test_update_user_permissions_jurisdiction_actions(self): self.assertEqual(user_id, resp['userId']) self.assertEqual( - {'actions': {'read'}, 'jurisdictions': {'oh': {'admin'}, 'ky': {'write'}}}, + {'actions': {'readPrivate'}, 'jurisdictions': {'oh': {'admin'}, 'ky': {'write'}}}, resp['permissions'], ) # Just checking that we're getting the whole object, not just changes @@ -164,7 +164,7 @@ def test_update_user_permissions_board_to_compact_admin(self): ) self.assertEqual(user_id, resp['userId']) - self.assertEqual({'actions': {'read', 'admin'}, 'jurisdictions': {}}, resp['permissions']) + 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'} - resp.keys()) @@ -177,7 +177,7 @@ def test_update_user_permissions_compact_to_board_admin(self): user_id = UUID(user_data['userId']) # Convert our canned user into a compact admin user_data['permissions'] = {'actions': {'read', 'admin'}, 'jurisdictions': {}} - self._table.put_item(Item=user_data) + self._users_table.put_item(Item=user_data) from cc_common.data_model.user_client import UserClient @@ -204,7 +204,7 @@ def test_update_user_permissions_no_change(self): user_id = UUID(user_data['userId']) # Convert our canned user into a compact admin user_data['permissions'] = {'actions': {'read', 'admin'}, 'jurisdictions': {}} - self._table.put_item(Item=user_data) + self._users_table.put_item(Item=user_data) from cc_common.data_model.user_client import UserClient diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/api/license-post.json b/backend/compact-connect/lambdas/python/common/tests/resources/api/license-post.json index 6974e665b..1f475a1fa 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/api/license-post.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/api/license-post.json @@ -11,10 +11,10 @@ "homeAddressCity": "Columbus", "homeAddressState": "oh", "homeAddressPostalCode": "43004", - "dateOfIssuance": "2024-06-06", - "dateOfBirth": "2024-06-06", - "dateOfExpiration": "2050-06-06", - "dateOfRenewal": "2024-06-06", + "dateOfIssuance": "2010-06-06", + "dateOfRenewal": "2020-04-04", + "dateOfExpiration": "2025-04-04", + "dateOfBirth": "1985-06-06", "emailAddress": "björk@example.com", "phoneNumber": "+13213214321", "militaryWaiver": false diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-detail-response.json b/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-detail-response.json index 65fdda256..ee7c9d770 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-detail-response.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-detail-response.json @@ -20,9 +20,9 @@ "militaryWaiver": false, "emailAddress": "björk@example.com", "phoneNumber": "+13213214321", - "dateOfBirth": "2024-06-06", + "dateOfBirth": "1985-06-06", "dateOfUpdate": "2024-07-08T23:59:59+00:00", - "dateOfExpiration": "2050-06-06", + "dateOfExpiration": "2025-04-04", "birthMonthDay": "06-06", "licenses": [ { @@ -38,11 +38,11 @@ "givenName": "Björk", "middleName": "Gunnar", "familyName": "Guðmundsdóttir", - "dateOfIssuance": "2024-06-06", - "dateOfRenewal": "2024-06-06", - "dateOfExpiration": "2050-06-06", - "dateOfBirth": "2024-06-06", - "dateOfUpdate": "2024-07-08T23:59:59+00:00", + "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", @@ -50,7 +50,43 @@ "homeAddressPostalCode": "43004", "emailAddress": "björk@example.com", "phoneNumber": "+13213214321", - "militaryWaiver": false + "militaryWaiver": false, + "history": [ + { + "type": "licenseUpdate", + "updateType": "renewal", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "aslp", + "jurisdiction": "oh", + "dateOfUpdate": "2020-04-07T12:59:59+00:00", + "previous": { + "ssn": "123-12-1234", + "npi": "0608337260", + "licenseType": "speech-language pathologist", + "jurisdictionStatus": "active", + "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", + "militaryWaiver": false + }, + "updatedValues": { + "dateOfRenewal": "2020-04-04", + "dateOfExpiration": "2025-04-04" + } + } + ] } ], "privileges": [ @@ -60,11 +96,39 @@ "compact": "aslp", "jurisdiction": "ne", "status": "active", - "dateOfIssuance": "2024-06-06T23:59:59+00:00", - "dateOfRenewal": "2024-11-08T23:59:59+00:00", - "dateOfUpdate": "2024-07-01T23:59:59+00:00", - "dateOfExpiration": "2050-06-06", - "compactTransactionId": "1234567890" + "dateOfIssuance": "2016-05-05T12:59:59+00:00", + "dateOfRenewal": "2020-05-05T12:59:59+00:00", + "dateOfExpiration": "2025-04-04", + "dateOfUpdate": "2020-05-05T12:59:59+00:00", + "compactTransactionId": "1234567890", + "history": [ + { + "type": "privilegeUpdate", + "updateType": "renewal", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "aslp", + "jurisdiction": "ne", + "dateOfUpdate": "2020-05-05T12:59:59+00:00", + "previous": { + "dateOfIssuance": "2016-05-05T12:59:59+00:00", + "dateOfRenewal": "2016-05-05T12:59:59+00:00", + "dateOfExpiration": "2020-06-06", + "dateOfUpdate": "2016-05-05T12:59:59+00:00", + "compactTransactionId": "0123456789" + }, + "updatedValues": { + "dateOfRenewal": "2020-05-05T12:59:59+00:00", + "dateOfExpiration": "2025-04-04", + "compactTransactionId": "1234567890" + } + } + ], + "attestations": [ + { + "attestationId": "jurisprudence-confirmation", + "version": "1" + } + ] } ], "militaryAffiliations": [ @@ -72,7 +136,7 @@ "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", "compact": "aslp", "type": "militaryAffiliation", - "documentKeys": ["/provider//document-type/military-affiliations/2024-07-08/1234#military-waiver.pdf"], + "documentKeys": ["/provider/89a6377e-c3a5-40e5-bca5-317ec854c570/document-type/military-affiliations/2024-07-08/1234#military-waiver.pdf"], "affiliationType": "militaryMember", "fileNames": ["military-waiver.pdf"], "status": "active", diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json b/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json index 0b7b95072..fbc886fbd 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/api/provider-response.json @@ -1,7 +1,6 @@ { "type": "provider", "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", - "ssn": "123-12-1234", "npi": "0608337260", "givenName": "Björk", "middleName": "Gunnar", @@ -20,8 +19,7 @@ "militaryWaiver": false, "emailAddress": "björk@example.com", "phoneNumber": "+13213214321", - "dateOfBirth": "2024-06-06", "dateOfUpdate": "2024-07-08T23:59:59+00:00", - "dateOfExpiration": "2050-06-06", + "dateOfExpiration": "2025-04-04", "birthMonthDay": "06-06" } diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/attestation.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/attestation.json new file mode 100644 index 000000000..bfe467ae9 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/attestation.json @@ -0,0 +1,15 @@ +{ + "pk": "COMPACT#aslp#ATTESTATIONS", + "sk": "COMPACT#aslp#LOCALE#en#ATTESTATION#jurisprudence-confirmation#VERSION#1", + "type": "attestation", + "attestationId": "jurisprudence-confirmation", + "displayName": "Jurisprudence Confirmation", + "description": "For displaying the jurisprudence confirmation", + "compact": "aslp", + "version": "1", + "dateCreated": "2024-06-06T23:59:59+00:00", + "dateOfUpdate": "2024-06-06T23:59:59+00:00", + "text": "You attest that you have read and understand the jurisprudence requirements for all states you are purchasing privileges for.", + "required": true, + "locale": "en" +} diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/compact.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/compact.json index 7b3ffb587..4e3c280e0 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/compact.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/compact.json @@ -10,5 +10,5 @@ "compactOperationsTeamEmails": [""], "compactAdverseActionsNotificationEmails": [""], "compactSummaryReportNotificationEmails": [""], - "dateOfUpdate": "2024-10-04" + "dateOfUpdate": "2024-10-04T12:34:56+00:00" } diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/jurisdiction.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/jurisdiction.json index 3e2785a17..ccdf85fbf 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/jurisdiction.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/jurisdiction.json @@ -17,5 +17,5 @@ "jurisprudenceRequirements": { "required": true }, - "dateOfUpdate": "2024-10-04" + "dateOfUpdate": "2024-10-04T12:34:56+00:00" } diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/license-update.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/license-update.json new file mode 100644 index 000000000..353a28179 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/license-update.json @@ -0,0 +1,36 @@ +{ + "pk": "aslp#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", + "sk": "aslp#PROVIDER#license/oh#UPDATE#1586264399/10bd49b5c65f404b9b505884a61cd3c4", + "type": "licenseUpdate", + "updateType": "renewal", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "aslp", + "jurisdiction": "oh", + "dateOfUpdate": "2020-04-07T12:59:59+00:00", + "previous": { + "ssn": "123-12-1234", + "npi": "0608337260", + "licenseType": "speech-language pathologist", + "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", + "militaryWaiver": false, + "jurisdictionStatus": "active" + }, + "updatedValues": { + "dateOfRenewal": "2020-04-04", + "dateOfExpiration": "2025-04-04" + } +} diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/license.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/license.json index dabaab924..0a849d57e 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/license.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/license.json @@ -1,6 +1,6 @@ { "pk": "aslp#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", - "sk": "aslp#PROVIDER#license/oh#2024-06-06", + "sk": "aslp#PROVIDER#license/oh#", "type": "license", "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", "compact": "aslp", @@ -11,11 +11,11 @@ "givenName": "Björk", "middleName": "Gunnar", "familyName": "Guðmundsdóttir", - "dateOfIssuance": "2024-06-06", - "dateOfRenewal": "2024-06-06", - "dateOfExpiration": "2050-06-06", - "dateOfBirth": "2024-06-06", - "dateOfUpdate": "2024-07-08", + "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", diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/military-affiliation.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/military-affiliation.json index b10d83f63..f2aa2314b 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/military-affiliation.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/military-affiliation.json @@ -4,7 +4,7 @@ "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", "compact": "aslp", "type": "militaryAffiliation", - "documentKeys": ["/provider//document-type/military-affiliations/2024-07-08/1234#military-waiver.pdf"], + "documentKeys": ["/provider/89a6377e-c3a5-40e5-bca5-317ec854c570/document-type/military-affiliations/2024-07-08/1234#military-waiver.pdf"], "affiliationType": "militaryMember", "fileNames": ["military-waiver.pdf"], "status": "active", diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/privilege-update.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/privilege-update.json new file mode 100644 index 000000000..88c3a0d77 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/privilege-update.json @@ -0,0 +1,22 @@ +{ + "pk": "aslp#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", + "sk": "aslp#PROVIDER#privilege/ne#UPDATE#1735232821/1a812bc8f", + "type": "privilegeUpdate", + "updateType": "renewal", + "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", + "compact": "aslp", + "jurisdiction": "ne", + "dateOfUpdate": "2020-05-05T12:59:59+00:00", + "previous": { + "dateOfIssuance": "2016-05-05T12:59:59+00:00", + "dateOfRenewal": "2016-05-05T12:59:59+00:00", + "dateOfExpiration": "2020-06-06", + "dateOfUpdate": "2016-05-05T12:59:59+00:00", + "compactTransactionId": "0123456789" + }, + "updatedValues": { + "dateOfRenewal": "2020-05-05T12:59:59+00:00", + "dateOfExpiration": "2025-04-04", + "compactTransactionId": "1234567890" + } +} diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/privilege.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/privilege.json index 1ac8ad154..f4a7f8542 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/privilege.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/privilege.json @@ -1,13 +1,19 @@ { "pk": "aslp#PROVIDER#89a6377e-c3a5-40e5-bca5-317ec854c570", - "sk": "aslp#PROVIDER#privilege/ne#2024-11-08", + "sk": "aslp#PROVIDER#privilege/ne#", "type": "privilege", "providerId": "89a6377e-c3a5-40e5-bca5-317ec854c570", "compact": "aslp", "jurisdiction": "ne", - "dateOfIssuance": "2024-06-06T23:59:59+00:00", - "dateOfRenewal": "2024-11-08T23:59:59+00:00", - "dateOfUpdate": "2024-07-01", - "dateOfExpiration": "2050-06-06", - "compactTransactionId": "1234567890" + "dateOfIssuance": "2016-05-05T12:59:59+00:00", + "dateOfRenewal": "2020-05-05T12:59:59+00:00", + "dateOfExpiration": "2025-04-04", + "dateOfUpdate": "2020-05-05T12:59:59+00:00", + "compactTransactionId": "1234567890", + "attestations": [ + { + "attestationId": "jurisprudence-confirmation", + "version": "1" + } + ] } diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/provider.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/provider.json index 7075cd793..8cb590bff 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/provider.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/provider.json @@ -23,8 +23,8 @@ "emailAddress": "björk@example.com", "phoneNumber": "+13213214321", "militaryWaiver": false, - "dateOfBirth": "2024-06-06", - "dateOfUpdate": "2024-07-08", - "dateOfExpiration": "2050-06-06", + "dateOfExpiration": "2025-04-04", + "dateOfBirth": "1985-06-06", + "dateOfUpdate": "2024-07-08T23:59:59+00:00", "birthMonthDay": "06-06" } diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/resources/dynamo/user.json b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/user.json similarity index 92% rename from backend/compact-connect/lambdas/python/staff-users/tests/resources/dynamo/user.json rename to backend/compact-connect/lambdas/python/common/tests/resources/dynamo/user.json index ef6b9435c..ce626dc3d 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/resources/dynamo/user.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/dynamo/user.json @@ -12,7 +12,7 @@ "S": "aslp" }, "dateOfUpdate": { - "S": "2024-09-12" + "S": "2024-09-12T23:59:59+00:00" }, "type": { "S": "user" @@ -37,7 +37,7 @@ "M": { "actions": { "SS": [ - "read" + "readPrivate" ] }, "jurisdictions": { diff --git a/backend/compact-connect/lambdas/python/common/tests/resources/ingest/message.json b/backend/compact-connect/lambdas/python/common/tests/resources/ingest/message.json index 302aa3a9a..8bd7766a7 100644 --- a/backend/compact-connect/lambdas/python/common/tests/resources/ingest/message.json +++ b/backend/compact-connect/lambdas/python/common/tests/resources/ingest/message.json @@ -24,10 +24,10 @@ "emailAddress": "björk@example.com", "phoneNumber": "+13213214321", "militaryWaiver": false, - "dateOfIssuance": "2024-06-06", - "dateOfBirth": "2024-06-06", - "dateOfExpiration": "2050-06-06", - "dateOfRenewal": "2024-06-06", + "dateOfIssuance": "2010-06-06", + "dateOfBirth": "1985-06-06", + "dateOfExpiration": "2025-04-04", + "dateOfRenewal": "2020-04-04", "compact": "aslp", "jurisdiction": "oh" } diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py index da0d68269..6d0709c86 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_authorize_compact_jurisdiction.py @@ -83,14 +83,14 @@ class TestAuthorizeCompact(TstLambdas): def test_authorize_compact(self): from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') 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 aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = { 'compact': 'aslp', } @@ -101,13 +101,13 @@ def test_no_path_param(self): from cc_common.exceptions import CCInvalidRequestException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') 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 aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = {} with self.assertRaises(CCInvalidRequestException): @@ -117,7 +117,7 @@ def test_no_authorizer(self): from cc_common.exceptions import CCUnauthorizedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} @@ -133,7 +133,7 @@ def test_missing_scope(self): from cc_common.exceptions import CCAccessDeniedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_data_client.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_data_client.py index 2d4643dd2..fcb6f1d28 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_data_client.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_data_client.py @@ -1,47 +1,70 @@ -from datetime import date, datetime -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from botocore.exceptions import ClientError -from cc_common.exceptions import CCAwsServiceException +from cc_common.exceptions import CCNotFoundException from tests import TstLambdas class TestDataClient(TstLambdas): - @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) - def test_data_client_deletes_records_if_exception_during_create_privilege_records(self): - from cc_common.data_model import client + def setUp(self): + from cc_common.config import _Config + from cc_common.data_model.data_client import DataClient - mock_dynamo_db_table = MagicMock(name='provider-table') - mock_batch_writer = MagicMock(name='batch_writer') + 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 - mock_dynamo_db_table.batch_writer.return_value.__enter__.return_value = mock_batch_writer + self.mock_provider_table.batch_writer.return_value.__enter__.return_value = self.mock_batch_writer - # Set the side effect to raise ClientError on put_item - mock_batch_writer.put_item.side_effect = ClientError( - error_response={'Error': {'Code': 'InternalServerError', 'Message': 'DynamoDB Internal Server Error'}}, - operation_name='PutItem', - ) + 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_provider_id_success(self): + # Mock response from DynamoDB + self.mock_ssn_table.get_item.return_value = { + 'Item': {'pk': 'aslp#SSN#123456789', 'sk': 'aslp#SSN#123456789', 'providerId': 'test_provider_id'} + } - mock_config = MagicMock(spec=client._Config) # noqa: SLF001 protected-access - mock_config.provider_table = mock_dynamo_db_table - - test_data_client = client.DataClient(mock_config) - - with self.assertRaises(CCAwsServiceException): - test_data_client.create_provider_privileges( - compact_name='aslp', - provider_id='test_provider_id', - jurisdiction_postal_abbreviations=['CA'], - license_expiration_date=date.fromisoformat('2024-10-31'), - existing_privileges=[], - compact_transaction_id='test_transaction_id', - ) - - mock_batch_writer.delete_item.assert_called_with( - Key={ - 'pk': 'aslp#PROVIDER#test_provider_id', - 'sk': 'aslp#PROVIDER#privilege/ca#2024-11-08', - } + # Call the method + provider_id = self.client.get_provider_id(compact='aslp', ssn='123456789') + + # Verify the result + self.assertEqual(provider_id, 'test_provider_id') + self.mock_ssn_table.get_item.assert_called_once_with( + Key={'pk': 'aslp#SSN#123456789', 'sk': 'aslp#SSN#123456789'}, ConsistentRead=True ) + + def test_get_provider_id_not_found(self): + # Mock response from DynamoDB for non-existent item + self.mock_ssn_table.get_item.return_value = {} + + # Verify it raises CCNotFoundException + with self.assertRaises(CCNotFoundException): + self.client.get_provider_id(compact='aslp', ssn='123456789') + + 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='aslp', 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='aslp', provider_id='test_id', detail=True, consistent_read=False) diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_paginated.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_paginated.py index 86919b7d2..a8153f4e5 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_paginated.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_paginated.py @@ -14,7 +14,7 @@ def setUp(self): # noqa: N801 invalid-name def test_pagination_parameters(self): from cc_common.data_model.query_paginator import paginated_query - from cc_common.data_model.schema.provider import ProviderRecordSchema + from cc_common.data_model.schema import ProviderRecordSchema calls = [] @@ -63,7 +63,7 @@ def test_multiple_internal_pages(self): 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.provider import ProviderRecordSchema + from cc_common.data_model.schema import ProviderRecordSchema calls = [] @@ -169,7 +169,7 @@ def get_something(*args, **kwargs): def test_no_pagination_parameters(self): from cc_common.data_model.query_paginator import paginated_query - from cc_common.data_model.schema.provider import ProviderRecordSchema + from cc_common.data_model.schema import ProviderRecordSchema calls = [] diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_base_record.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_base_record.py index b32d9edb0..c0e8700ac 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_base_record.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_base_record.py @@ -5,10 +5,8 @@ class TestRegistration(TstLambdas): def test_license_privilege_lookup(self): + from cc_common.data_model.schema import LicenseRecordSchema, PrivilegeRecordSchema, ProviderRecordSchema from cc_common.data_model.schema.base_record import BaseRecordSchema - from cc_common.data_model.schema.license import LicenseRecordSchema - from cc_common.data_model.schema.privilege import PrivilegeRecordSchema - from cc_common.data_model.schema.provider import ProviderRecordSchema with open('tests/resources/dynamo/privilege.json') as f: privilege_data = json.load(f) diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py index e9d848dc4..c893624ca 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py @@ -1,5 +1,7 @@ import json -from uuid import UUID +from datetime import UTC, datetime +from unittest.mock import patch +from uuid import UUID, uuid4 from marshmallow import ValidationError @@ -8,31 +10,31 @@ class TestLicenseSchema(TstLambdas): def test_validate_post(self): - from cc_common.data_model.schema.license import LicensePostSchema + from cc_common.data_model.schema.license.api import LicensePostRequestSchema with open('tests/resources/api/license-post.json') as f: - LicensePostSchema().load({'compact': 'aslp', 'jurisdiction': 'oh', **json.load(f)}) + LicensePostRequestSchema().load({'compact': 'aslp', 'jurisdiction': 'oh', **json.load(f)}) def test_license_post_schema_maps_status_to_jurisdiction_status(self): - from cc_common.data_model.schema.license import LicenseIngestSchema + from cc_common.data_model.schema.license.ingest import LicenseIngestSchema with open('tests/resources/api/license-post.json') as f: result = LicenseIngestSchema().load({'compact': 'aslp', 'jurisdiction': 'oh', **json.load(f)}) self.assertEqual('active', result['jurisdictionStatus']) def test_invalid_post(self): - from cc_common.data_model.schema.license import LicensePostSchema + 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): - LicensePostSchema().load({'compact': 'aslp', 'jurisdiction': 'oh', **license_data}) + LicensePostRequestSchema().load({'compact': 'aslp', 'jurisdiction': 'oh', **license_data}) def test_serde_record(self): """Test round-trip serialization/deserialization of license records""" - from cc_common.data_model.schema.license import LicenseRecordSchema + from cc_common.data_model.schema import LicenseRecordSchema with open('tests/resources/dynamo/license.json') as f: expected_license = json.load(f) @@ -54,7 +56,7 @@ def test_serde_record(self): self.assertEqual(expected_license, license_data) def test_invalid_record(self): - from cc_common.data_model.schema.license import LicenseRecordSchema + from cc_common.data_model.schema import LicenseRecordSchema with open('tests/resources/dynamo/license.json') as f: license_data = json.load(f) @@ -67,7 +69,8 @@ def test_serialize(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.license import LicenseIngestSchema, LicenseRecordSchema + from cc_common.data_model.schema import LicenseRecordSchema + from cc_common.data_model.schema.license.ingest import LicenseIngestSchema with open('tests/resources/api/license-post.json') as f: license_data = LicenseIngestSchema().load({'compact': 'aslp', 'jurisdiction': 'oh', **json.load(f)}) @@ -88,7 +91,7 @@ def test_serialize(self): self.assertEqual(expected_license_record, license_record) def test_license_record_schema_sets_status_to_inactive_if_license_expired(self): - from cc_common.data_model.schema.license import LicenseRecordSchema + from cc_common.data_model.schema import LicenseRecordSchema with open('tests/resources/dynamo/license.json') as f: raw_license_data = json.load(f) @@ -100,7 +103,7 @@ def test_license_record_schema_sets_status_to_inactive_if_license_expired(self): self.assertEqual('inactive', license_data['status']) def test_license_record_schema_sets_status_to_inactive_if_jurisdiction_status_inactive(self): - from cc_common.data_model.schema.license import LicenseRecordSchema + from cc_common.data_model.schema import LicenseRecordSchema with open('tests/resources/dynamo/license.json') as f: raw_license_data = json.load(f) @@ -113,7 +116,7 @@ def test_license_record_schema_sets_status_to_inactive_if_jurisdiction_status_in self.assertEqual('inactive', license_data['status']) def test_license_record_schema_strips_status_during_serialization(self): - from cc_common.data_model.schema.license import LicenseRecordSchema + from cc_common.data_model.schema import LicenseRecordSchema with open('tests/resources/dynamo/license.json') as f: raw_license_data = json.load(f) @@ -122,3 +125,83 @@ def test_license_record_schema_strips_status_during_serialization(self): license_data = schema.dump(schema.load(raw_license_data)) self.assertNotIn('status', license_data) + + +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.maxDiff = None + 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': 'different', + 'jurisdiction': 'different', + # 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': 'different', + 'jurisdiction': 'different', + # 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))) diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_military_affiliation.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_military_affiliation.py index 2869a05aa..b36e136c4 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_military_affiliation.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_military_affiliation.py @@ -8,7 +8,7 @@ class TestMilitaryAffiliationRecordSchema(TstLambdas): def test_serde(self): """Test round-trip deserialization/serialization""" - from cc_common.data_model.schema.military_affiliation import MilitaryAffiliationRecordSchema + from cc_common.data_model.schema.military_affiliation.record import MilitaryAffiliationRecordSchema with open('tests/resources/dynamo/military-affiliation.json') as f: expected_military_affiliation = json.load(f) @@ -25,7 +25,7 @@ def test_serde(self): self.assertEqual(expected_military_affiliation, military_affiliation_data) def test_invalid(self): - from cc_common.data_model.schema.military_affiliation import MilitaryAffiliationRecordSchema + from cc_common.data_model.schema.military_affiliation.record import MilitaryAffiliationRecordSchema with open('tests/resources/dynamo/military-affiliation.json') as f: military_affiliation_data = json.load(f) diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_privilege.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_privilege.py index d2cce087a..c4e7d0f99 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_privilege.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_privilege.py @@ -8,7 +8,7 @@ class TestPrivilegeRecordSchema(TstLambdas): def test_serde(self): """Test round-trip deserialization/serialization""" - from cc_common.data_model.schema.privilege import PrivilegeRecordSchema + from cc_common.data_model.schema import PrivilegeRecordSchema with open('tests/resources/dynamo/privilege.json') as f: expected_privilege = json.load(f) @@ -29,7 +29,7 @@ def test_serde(self): self.assertEqual(privilege_data, expected_privilege) def test_invalid(self): - from cc_common.data_model.schema.privilege import PrivilegeRecordSchema + from cc_common.data_model.schema import PrivilegeRecordSchema with open('tests/resources/dynamo/privilege.json') as f: privilege_data = json.load(f) @@ -39,7 +39,7 @@ def test_invalid(self): PrivilegeRecordSchema().load(privilege_data) def test_status_is_set_to_inactive_when_past_expiration(self): - from cc_common.data_model.schema.privilege import PrivilegeRecordSchema + from cc_common.data_model.schema import PrivilegeRecordSchema with open('tests/resources/dynamo/privilege.json') as f: privilege_data = json.load(f) @@ -50,7 +50,7 @@ def test_status_is_set_to_inactive_when_past_expiration(self): self.assertEqual(result['status'], 'inactive') def test_status_is_set_to_active_when_not_past_expiration(self): - from cc_common.data_model.schema.privilege import PrivilegeRecordSchema + from cc_common.data_model.schema import PrivilegeRecordSchema with open('tests/resources/dynamo/privilege.json') as f: privilege_data = json.load(f) diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py index b2ce0ba62..eb5436781 100644 --- a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py @@ -8,7 +8,7 @@ class TestProviderRecordSchema(TstLambdas): def test_serde(self): """Test round-trip deserialization/serialization""" - from cc_common.data_model.schema.provider import ProviderRecordSchema + from cc_common.data_model.schema import ProviderRecordSchema with open('tests/resources/dynamo/provider.json') as f: expected_provider_record = json.load(f) @@ -33,7 +33,7 @@ def test_serde(self): self.assertEqual(expected_provider_record, license_record) def test_invalid(self): - from cc_common.data_model.schema.provider import ProviderRecordSchema + from cc_common.data_model.schema import ProviderRecordSchema with open('tests/resources/dynamo/provider.json') as f: license_data = json.load(f) @@ -44,7 +44,7 @@ def test_invalid(self): 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.provider import ProviderRecordSchema + from cc_common.data_model.schema import ProviderRecordSchema with open('tests/resources/dynamo/provider.json') as f: raw_provider_data = json.load(f) @@ -57,7 +57,7 @@ def test_provider_record_schema_sets_status_to_inactive_if_license_expired(self) def test_provider_record_schema_sets_status_to_inactive_if_jurisdiction_status_inactive(self): """Test round-trip serialization/deserialization of license records""" - from cc_common.data_model.schema.provider import ProviderRecordSchema + from cc_common.data_model.schema import ProviderRecordSchema with open('tests/resources/dynamo/provider.json') as f: raw_provider_data = json.load(f) diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_transaction_client.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_transaction_client.py new file mode 100644 index 000000000..01b57da52 --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_data_model/test_transaction_client.py @@ -0,0 +1,79 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from tests import TstLambdas + +TEST_SETTLEMENT_DATETIME = '2024-01-15T10:30:00+00:00' + + +class TestTransactionClient(TstLambdas): + def setUp(self): + from cc_common.data_model import transaction_client + + self.mock_dynamo_db_table = MagicMock(name='transaction-history-table') + self.mock_batch_writer = MagicMock(name='batch_writer') + self.mock_dynamo_db_table.batch_writer.return_value.__enter__.return_value = self.mock_batch_writer + + self.mock_config = MagicMock(spec=transaction_client._Config) # noqa: SLF001 protected-access + self.mock_config.transaction_history_table = self.mock_dynamo_db_table + + self.client = transaction_client.TransactionClient(self.mock_config) + + def test_store_transactions_authorize_net(self): + # Test data + test_transactions = [ + { + 'transactionProcessor': 'authorize.net', + 'transactionId': 'tx123', + 'batch': {'batchId': 'batch456', 'settlementTimeUTC': '2024-01-15T10:30:00+00:00'}, + } + ] + + # Call the method + self.client.store_transactions('aslp', test_transactions) + + # Verify the batch writer was called with correct data + expected_epoch = int(datetime.fromisoformat(TEST_SETTLEMENT_DATETIME).timestamp()) + expected_item = { + 'pk': 'COMPACT#aslp#TRANSACTIONS#MONTH#2024-01', + 'sk': f'COMPACT#aslp#TIME#{expected_epoch}#BATCH#batch456#TX#tx123', + 'transactionProcessor': 'authorize.net', + 'transactionId': 'tx123', + 'batch': {'batchId': 'batch456', 'settlementTimeUTC': TEST_SETTLEMENT_DATETIME}, + } + self.mock_batch_writer.put_item.assert_called_once_with(Item=expected_item) + + def test_store_transactions_unsupported_processor(self): + # Test data with unsupported processor + test_transactions = [ + { + 'transactionProcessor': 'unsupported', + 'transactionId': 'tx123', + 'batch': {'batchId': 'batch456', 'settlementTimeUTC': '2024-01-15T10:30:00+00:00'}, + } + ] + + # Verify it raises ValueError for unsupported processor + with self.assertRaises(ValueError): + self.client.store_transactions('aslp', test_transactions) + + def test_store_multiple_transactions(self): + # Test data with multiple transactions + test_transactions = [ + { + 'transactionProcessor': 'authorize.net', + 'transactionId': 'tx123', + 'batch': {'batchId': 'batch456', 'settlementTimeUTC': '2024-01-15T10:30:00+00:00'}, + }, + { + 'transactionProcessor': 'authorize.net', + 'transactionId': 'tx124', + 'batch': {'batchId': 'batch456', 'settlementTimeUTC': '2024-01-15T11:30:00+00:00'}, + }, + ] + + # Call the method + self.client.store_transactions('aslp', test_transactions) + + # Verify the batch writer was called twice + self.assertEqual(self.mock_batch_writer.put_item.call_count, 2) diff --git a/backend/compact-connect/lambdas/python/common/tests/unit/test_sanitize_provider_data.py b/backend/compact-connect/lambdas/python/common/tests/unit/test_sanitize_provider_data.py new file mode 100644 index 000000000..e810de8ff --- /dev/null +++ b/backend/compact-connect/lambdas/python/common/tests/unit/test_sanitize_provider_data.py @@ -0,0 +1,81 @@ +import json + +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='aslp', provider=test_provider, scopes=scopes) + + self.assertEqual(resp, expected_provider) + + def test_full_provider_record_returned_if_caller_has_compact_read_private_permissions(self): + self.when_expecting_full_provider_record_returned( + scopes={'openid', 'email', 'aslp/readGeneral', 'aslp/aslp.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', 'aslp/readGeneral', 'aslp/oh.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', 'aslp/readGeneral', 'aslp/ne.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['ssn'] + mock_dob = full_provider['dateOfBirth'] + mock_doc_keys = full_provider['militaryAffiliations'][0]['documentKeys'] + # simplest way to set up mock test user as returned from the db + loaded_provider = ProviderGeneralResponseSchema().load(full_provider) + loaded_provider['ssn'] = mock_ssn + loaded_provider['dateOfBirth'] = mock_dob + loaded_provider['militaryAffiliations'][0]['documentKeys'] = mock_doc_keys + loaded_provider['licenses'][0]['ssn'] = mock_ssn + loaded_provider['licenses'][0]['dateOfBirth'] = mock_dob + loaded_provider['licenses'][0]['history'][0]['previous']['ssn'] = mock_ssn + loaded_provider['licenses'][0]['history'][0]['previous']['dateOfBirth'] = mock_dob + + # test provider has a license in oh and privilege in ne + resp = sanitize_provider_data_based_on_caller_scopes(compact='aslp', provider=loaded_provider, scopes=scopes) + + # now create expected provider record with the ssn and dob removed + del expected_provider['ssn'] + del expected_provider['dateOfBirth'] + # we do not return the military affiliation document keys if the caller does not have read private scope + del expected_provider['militaryAffiliations'][0]['documentKeys'] + # also remove the ssn from the license record + del expected_provider['licenses'][0]['ssn'] + del expected_provider['licenses'][0]['dateOfBirth'] + del expected_provider['licenses'][0]['history'][0]['previous']['ssn'] + del expected_provider['licenses'][0]['history'][0]['previous']['dateOfBirth'] + # cast to set to match schema + expected_provider['privilegeJurisdictions'] = set(expected_provider['privilegeJurisdictions']) + + self.maxDiff = None + self.assertEqual(expected_provider, resp) + + def test_sanitized_provider_record_returned_if_caller_does_not_have_read_private_permissions_for_jurisdiction(self): + self.when_testing_general_provider_info_returned( + scopes={'openid', 'email', 'aslp/readGeneral', 'aslp/az.readPrivate'} + ) + + def test_sanitized_provider_record_returned_if_caller_does_not_have_any_read_private_permissions(self): + self.when_testing_general_provider_info_returned(scopes={'openid', 'email', 'aslp/readGeneral'}) diff --git a/backend/compact-connect/lambdas/python/custom-resources/.coveragerc b/backend/compact-connect/lambdas/python/custom-resources/.coveragerc index 99c409d65..193e8ec8a 100644 --- a/backend/compact-connect/lambdas/python/custom-resources/.coveragerc +++ b/backend/compact-connect/lambdas/python/custom-resources/.coveragerc @@ -1,5 +1,5 @@ [run] -data_file = ../../../.coverage +data_file = ../../../../.coverage omit = */cdk.out/* diff --git a/backend/compact-connect/lambdas/python/custom-resources/handlers/compact_config_uploader.py b/backend/compact-connect/lambdas/python/custom-resources/handlers/compact_config_uploader.py index eea4c3569..c432281e4 100755 --- a/backend/compact-connect/lambdas/python/custom-resources/handlers/compact_config_uploader.py +++ b/backend/compact-connect/lambdas/python/custom-resources/handlers/compact_config_uploader.py @@ -4,8 +4,10 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config, logger -from cc_common.data_model.schema.compact import CompactRecordSchema -from cc_common.data_model.schema.jurisdiction import JurisdictionRecordSchema +from cc_common.data_model.schema.attestation import AttestationRecordSchema +from cc_common.data_model.schema.compact.record import CompactRecordSchema +from cc_common.data_model.schema.jurisdiction.record import JurisdictionRecordSchema +from cc_common.exceptions import CCNotFoundException def on_event(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument @@ -40,6 +42,9 @@ def upload_configuration(properties: dict): compact_configuration = json.loads(properties['compact_configuration'], parse_float=Decimal) logger.info('Uploading compact configuration') + # upload attestations for each compact + _upload_attestation_configuration(compact_configuration) + # upload the root compact configuration _upload_compact_root_configuration(compact_configuration) @@ -49,6 +54,67 @@ def upload_configuration(properties: dict): logger.info('Configuration upload successful') +def _upload_attestation_configuration(compact_configuration: dict) -> None: + """Upload attestation configurations to the provider table. + :param compact_configuration: The compact configuration + """ + attestation_record_schema = AttestationRecordSchema() + for compact in compact_configuration['compacts']: + compact_name = compact['compactName'] + + logger.info('Loading attestations', compact=compact_name) + for attestation in compact['attestations']: + attestation['compact'] = compact_name + attestation['type'] = 'attestation' + # set the dateCreated to the current date + attestation['dateCreated'] = config.current_standard_datetime.isoformat() + + # Try to get the latest version of this attestation + try: + latest_attestation = config.compact_configuration_client.get_attestation( + compact=compact_name, + attestation_id=attestation['attestationId'], + locale=attestation['locale'], + ) + # Check if any content fields have changed + content_changed = ( + any( + # Compare stripped values to ignore leading and trailing whitespace changes + latest_attestation[field].strip() != attestation[field].strip() + for field in ['displayName', 'description', 'text'] + ) + or latest_attestation['required'] != attestation['required'] + ) + if content_changed: + # Increment version if content changed + attestation['version'] = str(int(latest_attestation['version']) + 1) + logger.info( + 'Content changed, incrementing version', + attestation_id=attestation['attestationId'], + new_version=attestation['version'], + ) + else: + # No changes, skip upload + logger.info( + 'No content changes detected, skipping upload', + attestation_id=attestation['attestationId'], + ) + continue + except CCNotFoundException: + # No existing attestation, use version 1 + attestation['version'] = '1' + logger.info( + 'No existing attestation found, inserting attestation using version 1', + attestation_id=attestation['attestationId'], + ) + + serialized_attestation = attestation_record_schema.dump(attestation) + # Force validation before uploading + attestation_record_schema.load(serialized_attestation) + + config.compact_configuration_table.put_item(Item=serialized_attestation) + + def _upload_compact_root_configuration(compact_configuration: dict) -> None: """Upload the root compact configuration to the provider table. :param compact_configuration: The compact configuration @@ -60,6 +126,8 @@ def _upload_compact_root_configuration(compact_configuration: dict) -> None: compact['type'] = 'compact' # remove the activeEnvironments field as it's an implementation detail compact.pop('activeEnvironments') + # remove attestations as they are handled separately + compact.pop('attestations', None) serialized_compact = schema.dump(compact) 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 c16ab1663..5adca2322 100644 --- a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt @@ -4,26 +4,26 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/custom-resources/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.92 # via moto -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -33,9 +33,9 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.26 # via -r compact-connect/lambdas/python/custom-resources/requirements-dev.in -py-partiql-parser==0.5.6 +py-partiql-parser==0.6.1 # via moto pycparser==2.22 # via cffi @@ -56,9 +56,9 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/custom-resources/tests/function/test_handlers/test_compact_configuration_uploader.py b/backend/compact-connect/lambdas/python/custom-resources/tests/function/test_handlers/test_compact_configuration_uploader.py index c315d7333..15b870d14 100644 --- a/backend/compact-connect/lambdas/python/custom-resources/tests/function/test_handlers/test_compact_configuration_uploader.py +++ b/backend/compact-connect/lambdas/python/custom-resources/tests/function/test_handlers/test_compact_configuration_uploader.py @@ -13,6 +13,18 @@ MOCK_CURRENT_TIMESTAMP = '2024-11-08T23:59:59+00:00' +def generate_mock_attestation(): + return { + 'attestationId': 'jurisprudence-confirmation', + 'displayName': 'Jurisprudence Confirmation', + 'description': 'For displaying the jurisprudence confirmation', + 'text': 'You attest that you have read and understand the jurisprudence requirements ' + 'for all states you are purchasing privileges for.', + 'required': True, + 'locale': 'en', + } + + def generate_single_root_compact_config(compact_name: str, active_environments: list): return { 'compactName': compact_name, @@ -21,6 +33,7 @@ def generate_single_root_compact_config(compact_name: str, active_environments: 'compactAdverseActionsNotificationEmails': [], 'compactSummaryReportNotificationEmails': [], 'activeEnvironments': active_environments, + 'attestations': [generate_mock_attestation()], } @@ -189,3 +202,164 @@ def test_compact_configuration_uploader_raises_exception_on_invalid_jurisdiction with self.assertRaises(ValidationError): on_event(event, self.mock_context) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_TIMESTAMP)) + def test_compact_configuration_uploader_uploads_attestations(self): + """Test that attestations are correctly uploaded to DynamoDB.""" + from handlers.compact_config_uploader import on_event + + event = { + 'RequestType': 'Create', + 'ResourceProperties': { + 'compact_configuration': json.dumps(generate_mock_compact_configuration()), + }, + } + + on_event(event, self.mock_context) + + # Query for all attestations in the aslp compact + attestation_response = self.config.compact_configuration_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq('COMPACT#aslp#ATTESTATIONS') + & Key('sk').begins_with('COMPACT#aslp#LOCALE#en#ATTESTATION#jurisprudence-confirmation'), + ) + + self.assertEqual(1, len(attestation_response['Items'])) + attestation = attestation_response['Items'][0] + + expected_attestation = { + 'pk': 'COMPACT#aslp#ATTESTATIONS', + 'sk': 'COMPACT#aslp#LOCALE#en#ATTESTATION#jurisprudence-confirmation#VERSION#1', + 'type': 'attestation', + 'attestationId': 'jurisprudence-confirmation', + 'displayName': 'Jurisprudence Confirmation', + 'description': 'For displaying the jurisprudence confirmation', + 'version': '1', + 'dateCreated': MOCK_CURRENT_TIMESTAMP, + 'dateOfUpdate': MOCK_CURRENT_TIMESTAMP, + 'text': 'You attest that you have read and understand the jurisprudence requirements for all ' + 'states you are purchasing privileges for.', + 'required': True, + 'locale': 'en', + 'compact': 'aslp', + } + + self.assertEqual(expected_attestation, attestation) + + def test_compact_configuration_uploader_raises_exception_on_invalid_attestation(self): + """Test that invalid attestation configurations raise validation errors.""" + from handlers.compact_config_uploader import on_event + + mock_configuration = generate_mock_compact_configuration() + # Make the attestation invalid by removing a required field + del mock_configuration['compacts'][0]['attestations'][0]['required'] + + event = { + 'RequestType': 'Create', + 'ResourceProperties': { + 'compact_configuration': json.dumps(mock_configuration), + }, + } + + with self.assertRaises(ValidationError): + on_event(event, self.mock_context) + + def _when_testing_attestation_field_updates(self, field_to_change, new_value): + from handlers.compact_config_uploader import on_event + + original_attestation = generate_mock_attestation() + # First upload - should create version 1 + event = { + 'RequestType': 'Create', + 'ResourceProperties': { + 'compact_configuration': json.dumps(generate_mock_compact_configuration()), + }, + } + on_event(event, self.mock_context) + + # Modify the attestation text and upload again + mock_configuration = generate_mock_compact_configuration() + mock_configuration['compacts'][0]['attestations'][0][field_to_change] = new_value + + event = { + 'RequestType': 'Update', + 'ResourceProperties': { + 'compact_configuration': json.dumps(mock_configuration), + }, + } + on_event(event, self.mock_context) + + # Query for all attestations in the aslp compact + attestation_response = self.config.compact_configuration_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq('COMPACT#aslp#ATTESTATIONS') + & Key('sk').begins_with('COMPACT#aslp#LOCALE#en#ATTESTATION#jurisprudence-confirmation'), + ) + + # Should have two versions + self.assertEqual(2, len(attestation_response['Items'])) + + # Sort by version to get latest + attestations = sorted(attestation_response['Items'], key=lambda x: int(x['version'])) + + # Check version 1 + self.assertEqual('1', attestations[0]['version']) + self.assertEqual(original_attestation[field_to_change], attestations[0][field_to_change]) + + # Check version 2 + self.assertEqual('2', attestations[1]['version']) + self.assertEqual(new_value, attestations[1][field_to_change]) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_TIMESTAMP)) + def test_compact_configuration_uploader_handles_attestation_versioning_when_updating_text(self): + """Test that attestation versioning works correctly when content changes.""" + self._when_testing_attestation_field_updates('text', 'New text') + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_TIMESTAMP)) + def test_compact_configuration_uploader_handles_attestation_versioning_when_updating_description(self): + """Test that attestation versioning works correctly when content changes.""" + self._when_testing_attestation_field_updates('description', 'New description') + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_TIMESTAMP)) + def test_compact_configuration_uploader_handles_attestation_versioning_when_updating_display_name(self): + """Test that attestation versioning works correctly when content changes.""" + self._when_testing_attestation_field_updates('displayName', 'New Display Name') + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_TIMESTAMP)) + def test_compact_configuration_uploader_handles_attestation_versioning_when_updating_required_field(self): + """Test that attestation versioning works correctly when content changes.""" + self._when_testing_attestation_field_updates('required', False) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(MOCK_CURRENT_TIMESTAMP)) + def test_compact_configuration_uploader_skips_upload_when_no_changes(self): + """Test that attestation is not uploaded when content hasn't changed.""" + from handlers.compact_config_uploader import on_event + + # First upload - should create version 1 + event = { + 'RequestType': 'Create', + 'ResourceProperties': { + 'compact_configuration': json.dumps(generate_mock_compact_configuration()), + }, + } + on_event(event, self.mock_context) + + # Upload again with no changes + event = { + 'RequestType': 'Update', + 'ResourceProperties': { + 'compact_configuration': json.dumps(generate_mock_compact_configuration()), + }, + } + on_event(event, self.mock_context) + + # Query for attestations + attestation_response = self.config.compact_configuration_table.query( + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('pk').eq('COMPACT#aslp#ATTESTATIONS') + & Key('sk').begins_with('COMPACT#aslp#LOCALE#en#ATTESTATION#jurisprudence-confirmation'), + ) + + # Should still only have one version + self.assertEqual(1, len(attestation_response['Items'])) + self.assertEqual('1', attestation_response['Items'][0]['version']) diff --git a/backend/compact-connect/lambdas/python/data-events/.coveragerc b/backend/compact-connect/lambdas/python/data-events/.coveragerc index 99c409d65..193e8ec8a 100644 --- a/backend/compact-connect/lambdas/python/data-events/.coveragerc +++ b/backend/compact-connect/lambdas/python/data-events/.coveragerc @@ -1,5 +1,5 @@ [run] -data_file = ../../../.coverage +data_file = ../../../../.coverage omit = */cdk.out/* diff --git a/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py b/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py index 86bacba82..6e7feaf14 100644 --- a/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py +++ b/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from cc_common.config import config, logger -from cc_common.data_model.schema.license import SanitizedLicenseIngestDataEventSchema +from cc_common.data_model.schema.license.ingest import SanitizedLicenseIngestDataEventSchema from cc_common.utils import sqs_handler diff --git a/backend/compact-connect/lambdas/python/data-events/requirements-dev.in b/backend/compact-connect/lambdas/python/data-events/requirements-dev.in index 17377230b..5a61b7b0d 100644 --- a/backend/compact-connect/lambdas/python/data-events/requirements-dev.in +++ b/backend/compact-connect/lambdas/python/data-events/requirements-dev.in @@ -1 +1 @@ -moto[s3]>=5.0.12, <6 +moto[dynamodb, s3]>=5.0.12, <6 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 30fea6d15..3ae3b58cb 100644 --- a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt @@ -4,24 +4,26 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/data-events/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.92 # via moto -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -cryptography==43.0.3 +cryptography==44.0.0 + # via moto +docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -31,9 +33,9 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[s3]==5.0.21 +moto[dynamodb,s3]==5.0.26 # via -r compact-connect/lambdas/python/data-events/requirements-dev.in -py-partiql-parser==0.5.6 +py-partiql-parser==0.6.1 # via moto pycparser==2.22 # via cffi @@ -47,17 +49,19 @@ pyyaml==6.0.2 # responses requests==2.32.3 # via + # docker # moto # responses responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore + # docker # requests # responses werkzeug==3.1.3 diff --git a/backend/compact-connect/lambdas/python/data-events/tests/function/test_handlers.py b/backend/compact-connect/lambdas/python/data-events/tests/function/test_handlers.py index 99c62dea1..fa11ddd1a 100644 --- a/backend/compact-connect/lambdas/python/data-events/tests/function/test_handlers.py +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/test_handlers.py @@ -69,9 +69,9 @@ def test_handle_data_event_sanitizes_license_ingest_events(self): 'licenseType': 'speech-language pathologist', 'jurisdiction': 'oh', 'status': 'active', - 'dateOfIssuance': '2024-06-06', - 'dateOfRenewal': '2024-06-06', - 'dateOfExpiration': '2050-06-06', + 'dateOfExpiration': '2025-04-04', + 'dateOfIssuance': '2010-06-06', + 'dateOfRenewal': '2020-04-04', }, saved_event, ) diff --git a/backend/compact-connect/lambdas/python/delete-objects/requirements-dev.in b/backend/compact-connect/lambdas/python/delete-objects/requirements-dev.in deleted file mode 100644 index 17377230b..000000000 --- a/backend/compact-connect/lambdas/python/delete-objects/requirements-dev.in +++ /dev/null @@ -1 +0,0 @@ -moto[s3]>=5.0.12, <6 diff --git a/backend/compact-connect/lambdas/python/delete-objects/requirements.in b/backend/compact-connect/lambdas/python/delete-objects/requirements.in deleted file mode 100644 index 75cf54179..000000000 --- a/backend/compact-connect/lambdas/python/delete-objects/requirements.in +++ /dev/null @@ -1,2 +0,0 @@ -boto3>=1.34, <2 -aws-lambda-powertools>=2.29.1, <3 diff --git a/backend/compact-connect/lambdas/python/license-data/tests/function/test_handlers/__init__.py b/backend/compact-connect/lambdas/python/license-data/tests/function/test_handlers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/compact-connect/lambdas/python/license-data/tests/unit/__init__.py b/backend/compact-connect/lambdas/python/license-data/tests/unit/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/compact-connect/lambdas/python/license-data/tests/unit/test_data_model/__init__.py b/backend/compact-connect/lambdas/python/license-data/tests/unit/test_data_model/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/compact-connect/lambdas/python/license-data/tests/unit/test_data_model/test_schema/__init__.py b/backend/compact-connect/lambdas/python/license-data/tests/unit/test_data_model/test_schema/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/compact-connect/lambdas/python/license-data/tests/unit/test_handlers/__init__.py b/backend/compact-connect/lambdas/python/license-data/tests/unit/test_handlers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/.coveragerc b/backend/compact-connect/lambdas/python/provider-data-v1/.coveragerc index 99c409d65..193e8ec8a 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/.coveragerc +++ b/backend/compact-connect/lambdas/python/provider-data-v1/.coveragerc @@ -1,5 +1,5 @@ [run] -data_file = ../../../.coverage +data_file = ../../../../.coverage omit = */cdk.out/* diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/__init__.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/__init__.py index f1edcd8c3..e1969b777 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/__init__.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/__init__.py @@ -20,9 +20,10 @@ def get_provider_information(compact: str, provider_id: str) -> dict: raise CCInternalException('Unexpected provider data') provider = None - privileges = [] - licenses = [] + privileges = {} + licenses = {} military_affiliations = [] + for record in provider_data['items']: match record['type']: case 'provider': @@ -30,19 +31,31 @@ def get_provider_information(compact: str, provider_id: str) -> dict: provider = record case 'license': logger.debug('Identified license record', provider_id=provider_id) - licenses.append(record) + licenses[record['jurisdiction']] = record + licenses[record['jurisdiction']].setdefault('history', []) case 'privilege': logger.debug('Identified privilege record', provider_id=provider_id) - privileges.append(record) + privileges[record['jurisdiction']] = record + privileges[record['jurisdiction']].setdefault('history', []) case 'militaryAffiliation': logger.debug('Identified military affiliation record', provider_id=provider_id) military_affiliations.append(record) + # Process update records after all base records have been identified + for record in provider_data['items']: + match record['type']: + case 'licenseUpdate': + logger.debug('Identified license update record', provider_id=provider_id) + licenses[record['jurisdiction']]['history'].append(record) + case 'privilegeUpdate': + logger.debug('Identified privilege update record', provider_id=provider_id) + privileges[record['jurisdiction']]['history'].append(record) + if provider is None: logger.error("Failed to find a provider's primary record!", provider_id=provider_id) raise CCInternalException('Unexpected provider data') - provider['licenses'] = licenses - provider['privileges'] = privileges + provider['licenses'] = list(licenses.values()) + provider['privileges'] = list(privileges.values()) provider['militaryAffiliations'] = military_affiliations return provider diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py index 05f03db38..0703cd305 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/bulk_upload.py @@ -7,7 +7,7 @@ from botocore.exceptions import ClientError from botocore.response import StreamingBody from cc_common.config import config, logger -from cc_common.data_model.schema.license import LicensePostSchema, LicensePublicSchema +from cc_common.data_model.schema.license.api import LicenseGeneralResponseSchema, LicensePostRequestSchema from cc_common.exceptions import CCInternalException from cc_common.utils import ResponseEncoder, api_handler, authorize_compact_jurisdiction from event_batch_writer import EventBatchWriter @@ -114,8 +114,8 @@ def process_bulk_upload_file( """ Stream each line of the new CSV file, validating it then publishing an ingest event for each line. """ - public_schema = LicensePublicSchema() - schema = LicensePostSchema() + general_schema = LicenseGeneralResponseSchema() + schema = LicensePostRequestSchema() reader = LicenseCSVReader() stream = TextIOWrapper(body, encoding='utf-8') @@ -127,16 +127,16 @@ def process_bulk_upload_file( except ValidationError as e: # 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 public values that we can still validate. + # an email, we will only include the generally available values that we can still validate. try: - public_license_data = public_schema.load(raw_license) + general_license_data = general_schema.load(raw_license) except ValidationError as exc_second_try: - public_license_data = exc_second_try.valid_data + general_license_data = exc_second_try.valid_data logger.info( 'Invalid license in line %s uploaded: %s', i + 1, str(e), - valid_data=public_license_data, + valid_data=general_license_data, exc_info=e, ) event_writer.put_event( @@ -149,7 +149,7 @@ def process_bulk_upload_file( 'compact': compact, 'jurisdiction': jurisdiction, 'recordNumber': i + 1, - 'validData': public_license_data, + 'validData': general_license_data, 'errors': e.messages, }, cls=ResponseEncoder, diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py index 3ab44e67e..280d8f591 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/ingest.py @@ -2,12 +2,15 @@ from boto3.dynamodb.types import TypeSerializer from cc_common.config import config, logger -from cc_common.data_model.schema.license import LicenseIngestSchema, LicenseRecordSchema -from cc_common.data_model.schema.provider import ProviderRecordSchema +from cc_common.data_model.schema import LicenseRecordSchema, ProviderRecordSchema +from cc_common.data_model.schema.common import Status, UpdateCategory +from cc_common.data_model.schema.license.ingest import LicenseIngestSchema +from cc_common.data_model.schema.license.record import LicenseUpdateRecordSchema from cc_common.exceptions import CCNotFoundException from cc_common.utils import sqs_handler license_schema = LicenseIngestSchema() +license_update_schema = LicenseUpdateRecordSchema() @sqs_handler @@ -24,7 +27,7 @@ def ingest_license_message(message: dict): jurisdiction = license_post['jurisdiction'] provider_id = config.data_client.get_or_create_provider_id(compact=compact, ssn=license_post['ssn']) - logger.info('Updating license data', provider_id=provider_id, compact=compact, jurisdiction=jurisdiction) + logger.info('Ingesting license data', provider_id=provider_id, compact=compact, jurisdiction=jurisdiction) # Start preparing our db transactions dynamo_transactions = [ @@ -65,11 +68,19 @@ def ingest_license_message(message: dict): # If at least one active: last issued active license # If all inactive: last issued inactive license # Set (or replace) the posted license for its jurisdiction + existing_license = licenses.get(license_post['jurisdiction']) + if existing_license is not None: + _process_license_update( + existing_license=existing_license, + new_license=license_post, + dynamo_transactions=dynamo_transactions, + ) licenses[license_post['jurisdiction']] = license_post - best_license = find_best_license(licenses.values()) + best_license = _find_best_license(licenses.values()) if best_license is license_post: - logger.info('Updating provider data', provider_id=provider_id, compact=compact) - provider_record = populate_provider_record( + logger.info('Updating provider data', provider_id=provider_id, compact=compact, jurisdiction=jurisdiction) + + provider_record = _populate_provider_record( provider_id=provider_id, license_post=license_post, privilege_jurisdictions=privilege_jurisdictions, @@ -81,7 +92,7 @@ def ingest_license_message(message: dict): config.dynamodb_client.transact_write_items(TransactItems=dynamo_transactions) -def populate_provider_record(*, provider_id: str, license_post: dict, privilege_jurisdictions: set) -> dict: +def _populate_provider_record(*, provider_id: str, license_post: dict, privilege_jurisdictions: set) -> dict: dynamodb_serializer = TypeSerializer() return dynamodb_serializer.serialize( ProviderRecordSchema().dump( @@ -97,7 +108,76 @@ def populate_provider_record(*, provider_id: str, license_post: dict, privilege_ )['M'] -def find_best_license(all_licenses: Iterable) -> dict: +def _process_license_update(*, existing_license: dict, new_license: dict, dynamo_transactions: 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 + """ + # dateOfUpdate won't show up as a change because the field isn't in new_license, yet + updated_values = { + key: value + for key, value in new_license.items() + if key not in existing_license.keys() or value != existing_license[key] + } + # If any fields are missing from the new license, other than ones we add later, we'll consider them removed + removed_values = (existing_license.keys() - new_license.keys()) - {'type', 'providerId', 'status', 'dateOfUpdate'} + if not updated_values and not removed_values: + return + # Categorize the update + update_record = _populate_update_record( + existing_license=existing_license, updated_values=updated_values, removed_values=removed_values + ) + dynamo_transactions.append({'Put': {'TableName': config.provider_table_name, 'Item': update_record}}) + + +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 new_license: The newly-uploaded license record + :return: The update type, one of 'update', 'revoke', or 'reinstate' + """ + logger.info( + 'Processing license update', + provider_id=existing_license['providerId'], + compact=existing_license['compact'], + jurisdiction=existing_license['jurisdiction'], + ) + update_type = None + if {'dateOfExpiration', 'dateOfRenewal'} == updated_values.keys(): + original_values = {key: value for key, value in existing_license.items() if key in updated_values} + if ( + updated_values['dateOfExpiration'] > original_values['dateOfExpiration'] + and updated_values['dateOfRenewal'] > original_values['dateOfRenewal'] + ): + update_type = UpdateCategory.RENEWAL + elif updated_values == {'jurisdictionStatus': Status.INACTIVE}: + update_type = UpdateCategory.DEACTIVATION + if update_type is None: + update_type = UpdateCategory.OTHER + + dynamodb_serializer = TypeSerializer() + return dynamodb_serializer.serialize( + license_update_schema.dump( + { + 'type': 'licenseUpdate', + 'updateType': update_type, + 'providerId': existing_license['providerId'], + 'compact': existing_license['compact'], + 'jurisdiction': existing_license['jurisdiction'], + '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 {}), + } + ) + )['M'] + + +def _find_best_license(all_licenses: Iterable) -> dict: # Last issued active license, if there are any active licenses latest_active_licenses = sorted( [license_data for license_data in all_licenses if license_data['jurisdictionStatus'] == 'active'], diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/licenses.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/licenses.py index 4c001ea60..77c5f97f4 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/licenses.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/licenses.py @@ -3,13 +3,13 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config, logger -from cc_common.data_model.schema.license import LicensePostSchema +from cc_common.data_model.schema.license.api import LicensePostRequestSchema from cc_common.exceptions import CCInternalException, CCInvalidRequestException from cc_common.utils import api_handler, authorize_compact_jurisdiction from event_batch_writer import EventBatchWriter from marshmallow import ValidationError -schema = LicensePostSchema() +schema = LicensePostRequestSchema() @api_handler diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py index 3a4b412d0..a0c31ee70 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py @@ -6,10 +6,10 @@ from cc_common.data_model.schema.military_affiliation import ( MILITARY_AFFILIATIONS_DOCUMENT_TYPE_KEY_NAME, SUPPORTED_MILITARY_AFFILIATION_FILE_EXTENSIONS, - MilitaryAffiliationRecordSchema, MilitaryAffiliationType, - PostMilitaryAffiliationResponseSchema, ) +from cc_common.data_model.schema.military_affiliation.api import PostMilitaryAffiliationResponseSchema +from cc_common.data_model.schema.military_affiliation.record import MilitaryAffiliationRecordSchema from cc_common.exceptions import CCInternalException, CCInvalidRequestException, CCNotFoundException from cc_common.utils import api_handler @@ -81,7 +81,7 @@ def _post_provider_military_affiliation(event, context): # noqa: ARG001 unused- # verify all files use supported file extensions for file_name in file_names: file_name_without_extension, file_extension = file_name.rsplit('.', 1) - if file_extension not in SUPPORTED_MILITARY_AFFILIATION_FILE_EXTENSIONS: + if file_extension.lower() not in [ext.lower() for ext in SUPPORTED_MILITARY_AFFILIATION_FILE_EXTENSIONS]: raise CCInvalidRequestException( f'Invalid file type "{file_extension}" The following file types ' f'are supported: {SUPPORTED_MILITARY_AFFILIATION_FILE_EXTENSIONS}' @@ -89,7 +89,7 @@ def _post_provider_military_affiliation(event, context): # noqa: ARG001 unused- # generate a UUID for the document key, which includes a random UUID followed by the filename # and the file extension - document_uuid = f'{uuid.uuid4()}#{file_name_without_extension}.{file_extension}' + document_uuid = f'{uuid.uuid4()}#{file_name_without_extension}.{file_extension.lower()}' document_key = s3_document_prefix + document_uuid document_keys.append(document_key) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py index caeb3635b..c8b0bc495 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/providers.py @@ -2,14 +2,20 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config, logger +from cc_common.data_model.schema.provider.api import ProviderGeneralResponseSchema from cc_common.exceptions import CCInvalidRequestException -from cc_common.utils import api_handler, authorize_compact +from cc_common.utils import ( + api_handler, + authorize_compact, + get_event_scopes, + sanitize_provider_data_based_on_caller_scopes, +) from . import get_provider_information @api_handler -@authorize_compact(action='read') +@authorize_compact(action='readGeneral') 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 @@ -84,13 +90,19 @@ def query_providers(event: dict, context: LambdaContext): # noqa: ARG001 unused 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 - resp['providers'] = resp.pop('items', []) + # Convert generic field to more specific one for this API and sanitize data + pre_sanitized_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.dump(provider) for provider in pre_sanitized_providers] + + resp['providers'] = sanitized_providers + return resp @api_handler -@authorize_compact(action='read') +@authorize_compact(action='readGeneral') 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 @@ -104,4 +116,8 @@ def get_provider(event: dict, context: LambdaContext): # noqa: ARG001 unused-ar logger.error(f'Missing parameter: {e}') raise CCInvalidRequestException('Missing required field') from e - return get_provider_information(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/compact-connect/lambdas/python/provider-data-v1/license_csv_reader.py b/backend/compact-connect/lambdas/python/provider-data-v1/license_csv_reader.py index 84c5a2226..380c3e178 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/license_csv_reader.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/license_csv_reader.py @@ -2,12 +2,12 @@ from csv import DictReader from io import TextIOBase -from cc_common.data_model.schema.license import LicensePostSchema +from cc_common.data_model.schema.license.api import LicensePostRequestSchema class LicenseCSVReader: def __init__(self): - self.schema = LicensePostSchema() + self.schema = LicensePostRequestSchema() def licenses(self, stream: TextIOBase) -> Generator[dict, None, None]: reader = DictReader(stream, restkey='invalid', dialect='excel', strict=True) 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 f1962dc4e..41e54500f 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,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/provider-data-v1/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.92 # via moto -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto @@ -25,7 +25,7 @@ faker==28.4.1 # via -r compact-connect/lambdas/python/provider-data-v1/requirements-dev.in idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -35,9 +35,9 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.26 # via -r compact-connect/lambdas/python/provider-data-v1/requirements-dev.in -py-partiql-parser==0.5.6 +py-partiql-parser==0.6.1 # via moto pycparser==2.22 # via cffi @@ -59,9 +59,9 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py index c3fdec1ac..cb119513e 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/__init__.py @@ -17,6 +17,7 @@ def setUpClass(cls): 'BULK_BUCKET_NAME': 'cc-license-data-bulk-bucket', 'EVENT_BUS_NAME': 'license-data-events', 'PROVIDER_TABLE_NAME': 'provider-table', + 'SSN_TABLE_NAME': 'ssn-table', 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', 'ENVIRONMENT_NAME': 'test', 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', @@ -24,6 +25,7 @@ def setUpClass(cls): 'USER_POOL_ID': 'us-east-1-12345', 'USERS_TABLE_NAME': 'provider-table', 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'SSN_INVERTED_INDEX_NAME': 'inverted', 'PROVIDER_USER_BUCKET_NAME': 'provider-user-bucket', 'COMPACTS': '["aslp", "octp", "coun"]', 'JURISDICTIONS': '["ne", "oh", "ky"]', diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py index b66aea92e..80c26b7c5 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/__init__.py @@ -37,6 +37,7 @@ def setUp(self): # noqa: N801 invalid-name def build_resources(self): self._bucket = boto3.resource('s3').create_bucket(Bucket=os.environ['BULK_BUCKET_NAME']) self.create_provider_table() + self.create_ssn_table() boto3.client('events').create_event_bus(Name=os.environ['EVENT_BUS_NAME']) @@ -71,10 +72,32 @@ def create_provider_table(self): ], ) + def create_ssn_table(self): + self._ssn_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', '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_INVERTED_INDEX_NAME'], + 'KeySchema': [ + {'AttributeName': 'sk', 'KeyType': 'HASH'}, + {'AttributeName': 'pk', 'KeyType': 'RANGE'}, + ], + 'Projection': {'ProjectionType': 'ALL'}, + }, + ], + ) + def delete_resources(self): self._bucket.objects.delete() self._bucket.delete() self._provider_table.delete() + self._ssn_table.delete() boto3.client('events').delete_event_bus(Name=os.environ['EVENT_BUS_NAME']) def _load_provider_data(self): @@ -88,10 +111,16 @@ def privilege_jurisdictions_to_set(obj: dict): 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, object_hook=privilege_jurisdictions_to_set, parse_float=Decimal) logger.debug('Loading resource, %s: %s', resource, str(record)) - self._provider_table.put_item(Item=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, privilege: str, start_serial: int, names: tuple[tuple[str, str]] = ()): """Generate 10 providers with one license and one privilege @@ -99,7 +128,7 @@ def _generate_providers(self, *, home: str, privilege: str, start_serial: int, n :param privilege: The jurisdiction for the privilege :param start_serial: Starting number for last portion of the provider's SSN """ - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient from handlers.ingest import ingest_license_message with open('../common/tests/resources/ingest/message.json') as f: @@ -144,11 +173,16 @@ def _generate_providers(self, *, home: str, privilege: str, start_serial: int, n ) # Add a privilege provider_id = data_client.get_provider_id(compact='aslp', ssn=ssn) + provider_record = data_client.get_provider(compact='aslp', provider_id=provider_id, detail=False) data_client.create_provider_privileges( - compact_name='aslp', + compact='aslp', provider_id=provider_id, + provider_record=provider_record, jurisdiction_postal_abbreviations=[privilege], license_expiration_date=date(2050, 6, 6), compact_transaction_id='1234567890', existing_privileges=[], + # This attestation id/version pair is defined in the 'privilege.json' file under the + # common/tests/resources/dynamo directory + attestations=[{'attestationId': 'jurisprudence-confirmation', 'version': '1'}], ) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py index 9f705943b..c6462516a 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_client.py @@ -10,14 +10,14 @@ @mock_aws class TestClient(TstFunction): def test_get_provider_id(self): - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient with open('../common/tests/resources/dynamo/provider-ssn.json') as f: record = json.load(f) provider_ssn = record['ssn'] expected_provider_id = record['providerId'] - self._provider_table.put_item( + self._ssn_table.put_item( # We'll use the schema/serializer to populate index fields for us Item=record, ) @@ -30,7 +30,7 @@ def test_get_provider_id(self): def test_get_provider_id_not_found(self): """Provider ID not found should raise an exception""" - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient from cc_common.exceptions import CCNotFoundException client = DataClient(self.config) @@ -40,7 +40,7 @@ def test_get_provider_id_not_found(self): client.get_provider_id(compact='aslp', ssn='321-21-4321') def test_get_provider(self): - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient provider_id = self._load_provider_data() @@ -59,7 +59,7 @@ def test_get_provider_garbage_in_db(self): 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.client import DataClient + from cc_common.data_model.data_client import DataClient from cc_common.exceptions import CCInternalException provider_id = self._load_provider_data() @@ -85,7 +85,7 @@ def test_get_provider_garbage_in_db(self): ) def test_get_providers_sorted_by_family_name(self): - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient self._generate_providers(home='oh', privilege='ne', start_serial=9999) self._generate_providers(home='ne', privilege='oh', start_serial=9989) @@ -122,7 +122,7 @@ def test_get_providers_sorted_by_family_name(self): self.assertListEqual(sorted(family_names, key=quote), family_names) def test_get_providers_sorted_by_family_name_descending(self): - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient self._generate_providers(home='oh', privilege='ne', start_serial=9999) client = DataClient(self.config) @@ -139,7 +139,7 @@ def test_get_providers_sorted_by_family_name_descending(self): self.assertListEqual(sorted(family_names, key=quote, reverse=True), family_names) def test_get_providers_by_family_name(self): - from cc_common.data_model.client import DataClient + 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( @@ -166,7 +166,7 @@ def test_get_providers_by_family_name(self): self.assertEqual('Testerly', provider['familyName']) def test_get_providers_by_family_and_given_name(self): - from cc_common.data_model.client import DataClient + 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( @@ -194,7 +194,7 @@ def test_get_providers_by_family_and_given_name(self): self.assertEqual('Testerly', resp['items'][0]['familyName']) def test_get_providers_sorted_by_date_updated(self): - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient self._generate_providers(home='oh', privilege='ne', start_serial=9999) self._generate_providers(home='ne', privilege='oh', start_serial=9989) @@ -231,7 +231,7 @@ def test_get_providers_sorted_by_date_updated(self): 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.client import DataClient + from cc_common.data_model.data_client import DataClient self._generate_providers(home='oh', privilege='ne', start_serial=9999) client = DataClient(self.config) @@ -271,7 +271,7 @@ def _get_military_affiliation_records(self, provider_id: str) -> list[dict]: )['Items'] def test_complete_military_affiliation_initialization_sets_expected_status(self): - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient # Here we are testing an edge case where there are two military affiliation records # both in an initializing state. This could happen in the event of a failed file upload. diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py index bfcfa26b1..d93f622c9 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_data_model/test_provider_transformations.py @@ -21,7 +21,7 @@ def test_transformations(self): with open('../common/tests/resources/dynamo/provider-ssn.json') as f: provider_ssn = json.load(f) - self._provider_table.put_item(Item=provider_ssn) + 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 @@ -71,7 +71,7 @@ def test_transformations(self): # This should fully ingest the license, which will result in it being written to the DB ingest_license_message(event, self.mock_context) - from cc_common.data_model.client import DataClient + from cc_common.data_model.data_client import DataClient # We'll use the data client to get the resulting provider id client = DataClient(self.config) @@ -80,16 +80,19 @@ def test_transformations(self): ssn=license_ssn, ) self.assertEqual(expected_provider_id, provider_id) + provider_record = client.get_provider(compact='aslp', provider_id=provider_id, detail=False) # Add a privilege to practice in Nebraska client.create_provider_privileges( - compact_name='aslp', + compact='aslp', provider_id=provider_id, + provider_record=provider_record, # using values in expected privilege json file jurisdiction_postal_abbreviations=['ne'], - license_expiration_date=date(2050, 6, 6), + license_expiration_date=date(2025, 4, 4), compact_transaction_id='1234567890', existing_privileges=[], + attestations=[{'attestationId': 'jurisprudence-confirmation', 'version': '1'}], ) from cc_common.data_model.schema.military_affiliation import MilitaryAffiliationType @@ -101,7 +104,7 @@ def test_transformations(self): affiliation_type=MilitaryAffiliationType.MILITARY_MEMBER, file_names=['military-waiver.pdf'], document_keys=[ - '/provider//document-type/military-affiliations/2024-07-08/1234#military-waiver.pdf' + f'/provider/{provider_id}/document-type/military-affiliations/2024-07-08/1234#military-waiver.pdf' ], ) @@ -111,7 +114,7 @@ def test_transformations(self): KeyConditionExpression=Key('pk').eq(f'aslp#PROVIDER#{provider_id}') & Key('sk').begins_with('aslp#PROVIDER'), ) - # One record for reach of: provider, license, privilege, militaryAffiliation + # One record for each of: provider, license, privilege, militaryAffiliation self.assertEqual(4, len(resp['Items'])) records = {item['type']: item for item in resp['Items']} @@ -165,7 +168,7 @@ def test_transformations(self): event = json.load(f) event['pathParameters'] = {'compact': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' resp = get_provider(event, self.mock_context) @@ -200,5 +203,10 @@ def test_transformations(self): del expected_provider['militaryAffiliations'][0]['dateOfUpload'] del expected_provider['militaryAffiliations'][0]['dateOfUpdate'] + # This lengthy test does not include change records for licenses or privileges, so we'll blank out the + # sample history from our expected_provider + expected_provider['licenses'][0]['history'] = [] + expected_provider['privileges'][0]['history'] = [] + # Phew! We've loaded the data all the way in via the ingest chain and back out via the API! self.assertEqual(expected_provider, provider_data) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py index 765bb2470..3007aea07 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py @@ -7,59 +7,52 @@ @mock_aws class TestIngest(TstFunction): - def test_new_provider_ingest(self): + def _with_ingested_license(self, omit_email: bool = False) -> str: from handlers.ingest import ingest_license_message - from handlers.providers import query_providers + + 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/message.json') as f: - message = f.read() + message = json.load(f) - event = {'Records': [{'messageId': '123', 'body': message}]} + if omit_email: + del message['detail']['emailAddress'] + # 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': 'aslp'} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' - event['body'] = json.dumps({'query': {'ssn': '123-12-1234'}}) - resp = query_providers(event, self.mock_context) + event['pathParameters'] = {'compact': 'aslp', 'providerId': provider_id} + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email stuff aslp/readGeneral aslp/aslp.readPrivate' + ) + resp = get_provider(event, self.mock_context) self.assertEqual(resp['statusCode'], 200) + return json.loads(resp['body']) - with open('../common/tests/resources/api/provider-response.json') as f: - expected_provider = json.load(f) - # The canned response resource assumes that the provider will be given a privilege in NE. We didn't do that, - # so we'll reset the privilege array. - expected_provider['privilegeJurisdictions'] = [] - - provider_data = json.loads(resp['body'])['providers'][0] - # Removing dynamic fields from comparison - del expected_provider['providerId'] - del provider_data['providerId'] - del expected_provider['dateOfUpdate'] - del provider_data['dateOfUpdate'] - - self.assertEqual(expected_provider, provider_data) - - def test_existing_provider_ingest(self): + def test_new_provider_ingest(self): from handlers.ingest import ingest_license_message - from handlers.providers import get_provider - - self._load_provider_data() - with open('../common/tests/resources/dynamo/provider-ssn.json') as f: - provider_id = json.load(f)['providerId'] + from handlers.providers import query_providers with open('../common/tests/resources/ingest/message.json') as f: - message = json.load(f) - # What happens if their license goes inactive? - message['detail']['status'] = 'inactive' + message = f.read() - event = {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + event = {'Records': [{'messageId': '123', 'body': message}]} resp = ingest_license_message(event, self.mock_context) @@ -70,40 +63,43 @@ def test_existing_provider_ingest(self): with open('../common/tests/resources/api-event.json') as f: event = json.load(f) - event['pathParameters'] = {'compact': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' - resp = get_provider(event, self.mock_context) + event['pathParameters'] = {'compact': 'aslp'} + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' + event['body'] = json.dumps({'query': {'ssn': '123-12-1234'}}) + + # Find the provider's id from their ssn + resp = query_providers(event, self.mock_context) self.assertEqual(resp['statusCode'], 200) + provider_id = json.loads(resp['body'])['providers'][0]['providerId'] + + # 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) - # The license status and provider should immediately be inactive - expected_provider['jurisdictionStatus'] = 'inactive' - expected_provider['licenses'][0]['jurisdictionStatus'] = 'inactive' - # these should be calculated as inactive at record load time - expected_provider['status'] = 'inactive' - expected_provider['licenses'][0]['status'] = 'inactive' - # NOTE: when we are supporting privilege applications officially, they should also be set inactive. That will - # be captured in the relevant feature work - this is just to help us remember, since it's pretty important. - # expected_provider['privileges'][0]['status'] = 'inactive' - # add expected compactTransactionId to the expected provider - expected_provider['privileges'][0]['compactTransactionId'] = '1234567890' + # The canned response resource assumes that the provider will be given a privilege, military affiliation, and + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privilegeJurisdictions'] = [] + expected_provider['privileges'] = [] + expected_provider['militaryAffiliations'] = [] + for license_data in expected_provider['licenses']: + license_data['history'] = [] - provider_data = json.loads(resp['body']) - # Removing dynamic fields from comparison - del expected_provider['providerId'] - del provider_data['providerId'] + # Removing/setting dynamic fields for comparison del expected_provider['dateOfUpdate'] del provider_data['dateOfUpdate'] - del expected_provider['licenses'][0]['dateOfUpdate'] - del provider_data['licenses'][0]['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 - from handlers.providers import get_provider # The test resource provider has a license in oh self._load_provider_data() @@ -125,20 +121,10 @@ def test_old_inactive_license(self): self.assertEqual({'batchItemFailures': []}, resp) - # 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': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' - resp = get_provider(event, self.mock_context) - self.assertEqual(resp['statusCode'], 200) - with open('../common/tests/resources/api/provider-detail-response.json') as f: expected_provider = json.load(f) - provider_data = json.loads(resp['body']) + provider_data = self._get_provider_via_api(provider_id) # Removing dynamic fields from comparison del expected_provider['providerId'] @@ -160,7 +146,6 @@ def test_old_inactive_license(self): def test_newer_active_license(self): from handlers.ingest import ingest_license_message - from handlers.providers import get_provider # The test resource provider has a license in oh self._load_provider_data() @@ -182,17 +167,7 @@ def test_newer_active_license(self): self.assertEqual({'batchItemFailures': []}, resp) - # 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': 'aslp', 'providerId': provider_id} - event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/read' - resp = get_provider(event, self.mock_context) - self.assertEqual(resp['statusCode'], 200) - - provider_data = json.loads(resp['body']) + provider_data = self._get_provider_via_api(provider_id) # The new name and jurisdiction should be reflected in the provider data self.assertEqual('Newname', provider_data['familyName']) @@ -200,3 +175,454 @@ def test_newer_active_license(self): # And the second license should now be listed self.assertEqual(2, len(provider_data['licenses'])) + + def test_existing_provider_deactivation(self): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + + with open('../common/tests/resources/ingest/message.json') as f: + message = json.load(f) + + # What happens if their license goes inactive in a subsequent upload? + message['detail']['status'] = 'inactive' + 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['jurisdictionStatus'] = 'inactive' + expected_provider['licenses'][0]['jurisdictionStatus'] = 'inactive' + # these should be calculated as inactive at record load time + expected_provider['status'] = 'inactive' + expected_provider['licenses'][0]['status'] = 'inactive' + + # NOTE: when we are supporting privilege applications officially, they should also be set inactive. That will + # be captured in the relevant feature work - this is just to help us remember, since it's pretty important. + # expected_provider['privileges'][0]['status'] = 'inactive' + + provider_data = self._get_provider_via_api(provider_id) + + # The canned response resource assumes that the provider will be given a privilege, military affiliation, and + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privilegeJurisdictions'] = [] + expected_provider['privileges'] = [] + expected_provider['militaryAffiliations'] = [] + for license_data in expected_provider['licenses']: + # We uploaded a 'deactivation' by just switching 'status' to 'inactive', so this change + # should show up in the license history + license_data['history'] = [ + { + 'type': 'licenseUpdate', + 'updateType': 'deactivation', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'compact': 'aslp', + 'jurisdiction': 'oh', + 'previous': { + 'ssn': '123-12-1234', + 'npi': '0608337260', + 'licenseType': 'speech-language pathologist', + 'jurisdictionStatus': 'active', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'dateOfIssuance': '2010-06-06', + 'dateOfBirth': '1985-06-06', + 'dateOfExpiration': '2025-04-04', + 'dateOfRenewal': '2020-04-04', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'militaryWaiver': False, + }, + 'updatedValues': {'jurisdictionStatus': 'inactive'}, + } + ] + + # 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'] + for hist in license_data['history']: + del hist['dateOfUpdate'] + del hist['previous']['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_existing_provider_renewal(self): + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + + with open('../common/tests/resources/ingest/message.json') as f: + message = json.load(f) + + message['detail'].update({'dateOfRenewal': '2025-03-03', 'dateOfExpiration': '2030-03-03'}) + + # 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' + expected_provider['licenses'][0]['dateOfRenewal'] = '2025-03-03' + + provider_data = self._get_provider_via_api(provider_id) + + # The canned response resource assumes that the provider will be given a privilege, military affiliation, and + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privilegeJurisdictions'] = [] + expected_provider['privileges'] = [] + expected_provider['militaryAffiliations'] = [] + for license_data in expected_provider['licenses']: + # We uploaded a 'renewal' by just updating the dateOfRenewal and dateOfExpiration + # This should show up in the license history + license_data['history'] = [ + { + 'type': 'licenseUpdate', + 'updateType': 'renewal', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'compact': 'aslp', + 'jurisdiction': 'oh', + 'previous': { + 'ssn': '123-12-1234', + 'npi': '0608337260', + 'licenseType': 'speech-language pathologist', + 'jurisdictionStatus': 'active', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'dateOfIssuance': '2010-06-06', + 'dateOfBirth': '1985-06-06', + 'dateOfExpiration': '2025-04-04', + 'dateOfRenewal': '2020-04-04', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'militaryWaiver': False, + }, + 'updatedValues': { + 'dateOfRenewal': '2025-03-03', + 'dateOfExpiration': '2030-03-03', + }, + } + ] + + # 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'] + for hist in license_data['history']: + del hist['dateOfUpdate'] + del hist['previous']['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + 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/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) + + # The canned response resource assumes that the provider will be given a privilege, military affiliation, and + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privilegeJurisdictions'] = [] + expected_provider['privileges'] = [] + expected_provider['militaryAffiliations'] = [] + for license_data in expected_provider['licenses']: + # We uploaded a 'name change' by just updating the familyName + # This should show up in the license history + license_data['history'] = [ + { + 'type': 'licenseUpdate', + 'updateType': 'other', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'compact': 'aslp', + 'jurisdiction': 'oh', + 'previous': { + 'ssn': '123-12-1234', + 'npi': '0608337260', + 'licenseType': 'speech-language pathologist', + 'jurisdictionStatus': 'active', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'dateOfIssuance': '2010-06-06', + 'dateOfBirth': '1985-06-06', + 'dateOfExpiration': '2025-04-04', + 'dateOfRenewal': '2020-04-04', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'militaryWaiver': False, + }, + 'updatedValues': { + 'familyName': 'VonSmitherton', + }, + } + ] + + # 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'] + for hist in license_data['history']: + del hist['dateOfUpdate'] + del hist['previous']['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/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) + + # The canned response resource assumes that the provider will be given a privilege, military affiliation, and + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privilegeJurisdictions'] = [] + expected_provider['privileges'] = [] + expected_provider['militaryAffiliations'] = [] + for license_data in expected_provider['licenses']: + # No changes should show up in the license history + license_data['history'] = [] + + # 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'] + for hist in license_data['history']: + del hist['dateOfUpdate'] + del hist['previous']['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_existing_provider_removed_email(self): + self.maxDiff = None + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + + with open('../common/tests/resources/ingest/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) + + # The canned response resource assumes that the provider will be given a privilege, military affiliation, and + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privilegeJurisdictions'] = [] + expected_provider['privileges'] = [] + expected_provider['militaryAffiliations'] = [] + + # Removing the field we just removed from the license + del expected_provider['emailAddress'] + + 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'] + license_data['history'] = [ + { + 'type': 'licenseUpdate', + 'updateType': 'other', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'compact': 'aslp', + 'jurisdiction': 'oh', + 'previous': { + 'ssn': '123-12-1234', + 'npi': '0608337260', + 'licenseType': 'speech-language pathologist', + 'jurisdictionStatus': 'active', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'dateOfIssuance': '2010-06-06', + 'dateOfBirth': '1985-06-06', + 'dateOfExpiration': '2025-04-04', + 'dateOfRenewal': '2020-04-04', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'emailAddress': 'björk@example.com', + 'phoneNumber': '+13213214321', + 'militaryWaiver': False, + }, + 'updatedValues': {}, + 'removedValues': ['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'] + for hist in license_data['history']: + del hist['dateOfUpdate'] + del hist['previous']['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) + + def test_existing_provider_added_email(self): + self.maxDiff = None + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license(omit_email=True) + + with open('../common/tests/resources/ingest/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) + + # The canned response resource assumes that the provider will be given a privilege, military affiliation, and + # one license renewal. We didn't do any of that here, so we'll reset that data + expected_provider['privilegeJurisdictions'] = [] + expected_provider['privileges'] = [] + expected_provider['militaryAffiliations'] = [] + + for license_data in expected_provider['licenses']: + # We added an emailAddress. This should show up in the license history + license_data['history'] = [ + { + 'type': 'licenseUpdate', + 'updateType': 'other', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'compact': 'aslp', + 'jurisdiction': 'oh', + 'previous': { + 'ssn': '123-12-1234', + 'npi': '0608337260', + 'licenseType': 'speech-language pathologist', + 'jurisdictionStatus': 'active', + 'givenName': 'Björk', + 'middleName': 'Gunnar', + 'familyName': 'Guðmundsdóttir', + 'dateOfIssuance': '2010-06-06', + 'dateOfBirth': '1985-06-06', + 'dateOfExpiration': '2025-04-04', + 'dateOfRenewal': '2020-04-04', + 'homeAddressStreet1': '123 A St.', + 'homeAddressStreet2': 'Apt 321', + 'homeAddressCity': 'Columbus', + 'homeAddressState': 'oh', + 'homeAddressPostalCode': '43004', + 'phoneNumber': '+13213214321', + 'militaryWaiver': False, + }, + 'updatedValues': { + 'emailAddress': 'björk@example.com', + } + } + ] + + # 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'] + for hist in license_data['history']: + del hist['dateOfUpdate'] + del hist['previous']['dateOfUpdate'] + + self.assertEqual(expected_provider, provider_data) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py index bde29ff64..169ae7b9d 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_licenses.py @@ -14,7 +14,9 @@ def test_post_licenses(self): event = json.load(f) # The user has write permission for aslp/oh - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read aslp/write aslp/oh.write' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/readGeneral aslp/write aslp/oh.write' + ) event['pathParameters'] = {'compact': 'aslp', 'jurisdiction': 'oh'} with open('../common/tests/resources/api/license-post.json') as f: event['body'] = json.dumps([json.load(f)]) @@ -30,7 +32,9 @@ def test_post_licenses_invalid_license_type(self): event = json.load(f) # The user has write permission for aslp/oh - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read aslp/write aslp/oh.write' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/readGeneral aslp/write aslp/oh.write' + ) event['pathParameters'] = {'compact': 'aslp', 'jurisdiction': 'oh'} with open('../common/tests/resources/api/license-post.json') as f: license_data = json.load(f) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py index 6dcf7514c..511572201 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py @@ -42,6 +42,8 @@ def test_get_provider_returns_provider_information(self): with open('../common/tests/resources/api/provider-detail-response.json') as f: expected_provider = json.load(f) + + self.maxDiff = None self.assertEqual(expected_provider, provider_data) def test_get_provider_returns_400_if_api_call_made_without_proper_claims(self): @@ -146,6 +148,24 @@ def test_post_provider_military_affiliation_returns_affiliation_information(self military_affiliation_data, ) + @patch('handlers.provider_users.uuid') + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-12-04T08:08:08+00:00')) + def test_post_provider_military_affiliation_handles_file_with_uppercase_extension(self, mock_uuid): + from handlers.provider_users import provider_user_me_military_affiliation + + mock_uuid.uuid4.return_value = '1234' + + event = self._when_testing_post_provider_user_military_affiliation_event_with_custom_claims() + event['body'] = json.dumps( + { + 'fileNames': [MOCK_MILITARY_AFFILIATION_FILE_NAME.upper()], + 'affiliationType': 'militaryMember', + } + ) + + resp = provider_user_me_military_affiliation(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + def test_post_provider_military_affiliation_sets_previous_record_status_to_inactive(self): from handlers.provider_users import provider_user_me_military_affiliation diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py index 9bc8702ea..2bb2d9a0b 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_providers.py @@ -17,7 +17,7 @@ def test_query_by_ssn(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {'ssn': '123-12-1234'}}) @@ -39,7 +39,7 @@ def test_query_by_ssn(self): body, ) - def test_query_by_provider_id(self): + def test_query_by_provider_id_sanitizes_data_even_with_read_private_permission(self): self._load_provider_data() from handlers.providers import query_providers @@ -48,7 +48,7 @@ def test_query_by_provider_id(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'}}) @@ -80,7 +80,7 @@ def test_query_providers_updated_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral aslp/aslp.readPrivate' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( {'sorting': {'key': 'dateOfUpdate'}, 'query': {'jurisdiction': 'oh'}, 'pagination': {'pageSize': 10}}, @@ -109,7 +109,7 @@ def test_query_providers_family_name_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( {'sorting': {'key': 'familyName'}, 'query': {'jurisdiction': 'oh'}, 'pagination': {'pageSize': 10}}, @@ -143,7 +143,7 @@ def test_query_providers_by_family_name(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( { @@ -175,7 +175,7 @@ def test_query_providers_given_name_only_not_allowed(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps( { @@ -201,7 +201,7 @@ def test_query_providers_default_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {}}) @@ -229,7 +229,7 @@ def test_query_providers_invalid_sorting(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'aslp'} event['body'] = json.dumps({'query': {'jurisdiction': 'oh'}, 'sorting': {'key': 'invalid'}}) @@ -241,8 +241,14 @@ def test_query_providers_invalid_sorting(self): @mock_aws class TestGetProvider(TstFunction): - def test_get_provider(self): - """Provider detail response""" + @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 _when_testing_get_provider_response_based_on_read_access(self, scopes: str, expected_provider: dict): self._load_provider_data() from handlers.providers import get_provider @@ -251,19 +257,45 @@ def test_get_provider(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = scopes event['pathParameters'] = {'compact': 'aslp', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} event['queryStringParameters'] = None - with open('../common/tests/resources/api/provider-detail-response.json') as f: - expected_provider = json.load(f) - resp = get_provider(event, self.mock_context) self.assertEqual(200, resp['statusCode']) provider_data = json.loads(resp['body']) self.assertEqual(expected_provider, provider_data) + # 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']) + + 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 aslp/readGeneral aslp/aslp.readPrivate', + ) + + def test_get_provider_with_matching_license_jurisdiction_level_read_private_access(self): + # test provider has a license in oh and a privilege in ne + self._when_testing_get_provider_with_read_private_access( + scopes='openid email aslp/readGeneral aslp/oh.readPrivate' + ) + + def test_get_provider_with_matching_privilege_jurisdiction_level_read_private_access(self): + # test provider has a license in oh and a privilege in ne + self._when_testing_get_provider_with_read_private_access( + scopes='openid email aslp/readGeneral aslp/ne.readPrivate' + ) + def test_get_provider_wrong_compact(self): """Provider detail response""" self._load_provider_data() @@ -274,7 +306,7 @@ def test_get_provider_wrong_compact(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' event['pathParameters'] = {'compact': 'octp', 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570'} event['queryStringParameters'] = None @@ -289,7 +321,7 @@ def test_get_provider_missing_provider_id(self): event = json.load(f) # The user has read permission for aslp - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/readGeneral' # providerId _should_ be included in these pathParameters. We're leaving it out for this test. event['pathParameters'] = {'compact': 'aslp'} event['queryStringParameters'] = None @@ -297,3 +329,20 @@ def test_get_provider_missing_provider_id(self): 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('ssn') + expected_provider.pop('dateOfBirth') + # we do not return the military affiliation document keys if the caller does not have read private scope + expected_provider['militaryAffiliations'][0].pop('documentKeys') + # also remove the ssn from the license record + del expected_provider['licenses'][0]['ssn'] + del expected_provider['licenses'][0]['dateOfBirth'] + del expected_provider['licenses'][0]['history'][0]['previous']['ssn'] + del expected_provider['licenses'][0]['history'][0]['previous']['dateOfBirth'] + + self._when_testing_get_provider_response_based_on_read_access( + scopes='openid email aslp/readGeneral', expected_provider=expected_provider + ) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_license_csv_reader.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_license_csv_reader.py index 429813a78..44f84859d 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_license_csv_reader.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_license_csv_reader.py @@ -10,7 +10,7 @@ class TestCSVParser(TstFunction): def test_csv_parser(self): from cc_common.config import logger - from cc_common.data_model.schema.license import LicensePostSchema + 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 @@ -19,7 +19,7 @@ def test_csv_parser(self): self._bucket.upload_file('../common/tests/resources/licenses.csv', key) stream = TextIOWrapper(self._bucket.Object(key).get()['Body'], encoding='utf-8') - schema = LicensePostSchema() + schema = LicensePostRequestSchema() reader = LicenseCSVReader() for license_row in reader.licenses(stream): validated = schema.load({'compact': 'aslp', 'jurisdiction': 'oh', **license_row}) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/unit/test_license_csv_reader.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/unit/test_license_csv_reader.py index 79e352b47..4120c8ead 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/unit/test_license_csv_reader.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/unit/test_license_csv_reader.py @@ -4,10 +4,10 @@ class TestCSVParser(TstLambdas): def test_csv_parser(self): from cc_common.config import logger - from cc_common.data_model.schema.license import LicensePostSchema + from cc_common.data_model.schema.license.api import LicensePostRequestSchema from license_csv_reader import LicenseCSVReader - schema = LicensePostSchema() + schema = LicensePostRequestSchema() with open('../common/tests/resources/licenses.csv') as f: reader = LicenseCSVReader() for license_row in reader.licenses(f): diff --git a/backend/compact-connect/lambdas/python/purchases/.coveragerc b/backend/compact-connect/lambdas/python/purchases/.coveragerc index 99c409d65..193e8ec8a 100644 --- a/backend/compact-connect/lambdas/python/purchases/.coveragerc +++ b/backend/compact-connect/lambdas/python/purchases/.coveragerc @@ -1,5 +1,5 @@ [run] -data_file = ../../../.coverage +data_file = ../../../../.coverage omit = */cdk.out/* diff --git a/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py b/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py index eb8d383d6..d03bc6310 100644 --- a/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py +++ b/backend/compact-connect/lambdas/python/purchases/handlers/privileges.py @@ -1,14 +1,16 @@ import json +import os from datetime import date from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config, logger -from cc_common.data_model.schema.compact import COMPACT_TYPE, Compact, CompactOptionsApiResponseSchema +from cc_common.data_model.schema.compact import COMPACT_TYPE, Compact +from cc_common.data_model.schema.compact.api import CompactOptionsResponseSchema from cc_common.data_model.schema.jurisdiction import ( JURISDICTION_TYPE, Jurisdiction, - JurisdictionOptionsApiResponseSchema, ) +from cc_common.data_model.schema.jurisdiction.api import JurisdictionOptionsResponseSchema from cc_common.data_model.schema.military_affiliation import MilitaryAffiliationStatus from cc_common.exceptions import ( CCAwsServiceException, @@ -20,6 +22,26 @@ from cc_common.utils import api_handler from purchase_client import PurchaseClient +# List of attestations that are always required +REQUIRED_ATTESTATION_IDS = [ + 'jurisprudence-confirmation', + 'scope-of-practice-attestation', + 'personal-information-home-state-attestation', + 'personal-information-address-attestation', + 'discipline-no-current-encumbrance-attestation', + 'discipline-no-prior-encumbrance-attestation', + 'provision-of-true-information-attestation', +] + +# Attestations where exactly one must be provided +INVESTIGATION_ATTESTATION_IDS = [ + 'not-under-investigation-attestation', + 'under-investigation-attestation', +] + +# Attestation required for users with active military affiliation +MILITARY_ATTESTATION_ID = 'military-affiliation-confirmation-attestation' + def _get_caller_compact_custom_attribute(event: dict) -> str: try: @@ -58,9 +80,9 @@ def get_purchase_privilege_options(event: dict, context: LambdaContext): # noqa serlialized_options = [] for item in options_response['items']: if item['type'] == JURISDICTION_TYPE: - serlialized_options.append(JurisdictionOptionsApiResponseSchema().load(item)) + serlialized_options.append(JurisdictionOptionsResponseSchema().load(item)) elif item['type'] == COMPACT_TYPE: - serlialized_options.append(CompactOptionsApiResponseSchema().load(item)) + serlialized_options.append(CompactOptionsResponseSchema().load(item)) options_response['items'] = serlialized_options @@ -109,30 +131,90 @@ def _determine_military_affiliation_status(provider_records: list[dict]) -> bool return latest_military_affiliation['status'] == MilitaryAffiliationStatus.ACTIVE.value +def _validate_attestations(compact: str, attestations: list[dict], has_active_military_affiliation: bool = False): + """ + Validate that all required attestations are present and are the latest version. + + :param compact: The compact name + :param attestations: List of attestations from the request body + :param has_active_military_affiliation: Whether the user has an active military affiliation + :raises CCInvalidRequestException: If any attestation is not found, not the latest version, + or validation rules are not met + """ + # Get all latest attestations for this compact + latest_attestations = config.compact_configuration_client.get_attestations_by_locale(compact=compact) + + # TODO: We were asked not to enforce attestations until the frontend is updated # noqa: FIX002 + # to pass them in the request body. For now, we will simply check whatever + # attestation is passed in to make sure it is the latest version. This conditional + # should be removed once the frontend is updated to pass in all required attestations. + if os.environ.get('ENFORCE_ATTESTATIONS') == 'true': + # Build list of required attestations + required_ids = REQUIRED_ATTESTATION_IDS.copy() + if has_active_military_affiliation: + required_ids.append(MILITARY_ATTESTATION_ID) + + # Validate investigation attestations - exactly one must be provided + investigation_attestations = [a for a in attestations if a['attestationId'] in INVESTIGATION_ATTESTATION_IDS] + if len(investigation_attestations) != 1: + raise CCInvalidRequestException( + 'Exactly one investigation attestation must be provided ' + f'(either {INVESTIGATION_ATTESTATION_IDS[0]} or {INVESTIGATION_ATTESTATION_IDS[1]})' + ) + required_ids.append(investigation_attestations[0]['attestationId']) + + # Check that all required attestations are present + provided_ids = {a['attestationId'] for a in attestations} + missing_ids = set(required_ids) - provided_ids + if missing_ids: + raise CCInvalidRequestException(f'Missing required attestations: {", ".join(missing_ids)}') + + # Check for any invalid attestation IDs + invalid_ids = provided_ids - set(required_ids) + if invalid_ids: + raise CCInvalidRequestException(f'Invalid attestations provided: {", ".join(invalid_ids)}') + + # Verify all provided attestations are the latest version + for attestation in attestations: + attestation_id = attestation['attestationId'] + latest_attestation = latest_attestations.get(attestation_id) + if not latest_attestation: + raise CCInvalidRequestException(f'Attestation not found: "{attestation_id}"') + if latest_attestation['version'] != attestation['version']: + raise CCInvalidRequestException( + f'Attestation "{attestation_id}" version {attestation["version"]} ' + f'is not the latest version ({latest_attestation["version"]})' + ) + + @api_handler def post_purchase_privileges(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument """ This endpoint allows a provider to purchase privileges. - The request body should include the selected jurisdiction privileges to purchase and billing information - in the following format: + The request body should include the selected jurisdiction privileges to purchase, billing information, + and attestations in the following format: { "selectedJurisdictions": [""], "orderInformation": { - "card": { - "number": "", - "expiration": "", - "cvv": "" + "card": { + "number": "", + "expiration": "", + "cvv": "" + }, + "billing": { + "firstName": "testFirstName", + "lastName": "testLastName", + "streetAddress": "123 Test St", + "streetAddress2": "", # optional + "state": "OH", + "zip": "12345", + } }, - "billing": { - "firstName": "testFirstName", - "lastName": "testLastName", - "streetAddress": "123 Test St", - "streetAddress2": "", # optional - "state": "OH", - "zip": "12345", - } - } + "attestations": [{ + "attestationId": "jurisprudence-confirmation", + "version": "1" + }] } :param event: Standard API Gateway event, API schema documented in the CDK ApiStack @@ -213,6 +295,9 @@ def post_purchase_privileges(event: dict, context: LambdaContext): # noqa: ARG0 license_expiration_date: date = license_record['dateOfExpiration'] user_active_military = _determine_military_affiliation_status(user_provider_data['items']) + # Validate attestations are the latest versions before proceeding with the purchase + _validate_attestations(compact_name, body['attestations'], user_active_military) + purchase_client = PurchaseClient() transaction_response = None try: @@ -226,12 +311,14 @@ def post_purchase_privileges(event: dict, context: LambdaContext): # noqa: ARG0 # transaction was successful, now we create privilege records for the selected jurisdictions config.data_client.create_provider_privileges( - compact_name=compact_name, + compact=compact_name, provider_id=provider_id, jurisdiction_postal_abbreviations=selected_jurisdictions_postal_abbreviations, license_expiration_date=license_expiration_date, compact_transaction_id=transaction_response['transactionId'], + provider_record=provider_record, existing_privileges=existing_privileges, + attestations=body['attestations'], ) return transaction_response diff --git a/backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py b/backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py new file mode 100644 index 000000000..a07e33e73 --- /dev/null +++ b/backend/compact-connect/lambdas/python/purchases/handlers/transaction_history.py @@ -0,0 +1,80 @@ +from datetime import timedelta + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config +from cc_common.exceptions import TransactionBatchSettlementFailureException +from purchase_client import PurchaseClient + + +def process_settled_transactions(event: dict, context: LambdaContext) -> dict: # noqa: ARG001 unused-argument + """ + Process settled transactions from the payment processor. + + This lambda is invoked as part of the transaction history processing workflow. It retrieves settled transactions + from the payment processor and stores them in DynamoDB. It is designed to be invoked multiple times until all + transactions have been processed. The lambda will return a status of 'IN_PROGRESS' until all transactions have been + processed, at which point it will return a status of 'COMPLETE'. + + If the payment processor reports that there was a settlement failure, the lambda will return a status of + 'BATCH_FAILURE', which will cause the workflow to send an alert to the compact operations team. + + :param event: Lambda event containing: + - compact: The compact name + - lastProcessedTransactionId: Optional last processed transaction ID + - currentBatchId: Optional current batch ID being processed + - processedBatchIds: Optional list of batch IDs that have been processed, this ensures we don't process the same + batch multiple times. + :param context: Lambda context + :return: Dictionary indicating processing status and optional pagination info + """ + compact = event['compact'] + last_processed_transaction_id = event.get('lastProcessedTransactionId') + current_batch_id = event.get('currentBatchId') + processed_batch_ids = event.get('processedBatchIds', []) + + # Calculate time range for the last 24 hours + end_time = config.current_standard_datetime + start_time = end_time - timedelta(days=1) + + # Format timestamps for API call + start_time_str = start_time.strftime('%Y-%m-%dT%H:%M:%SZ') + end_time_str = end_time.strftime('%Y-%m-%dT%H:%M:%SZ') + + try: + # Get transactions from payment processor + purchase_client = PurchaseClient() + transaction_response = purchase_client.get_settled_transactions( + compact=compact, + start_time=start_time_str, + end_time=end_time_str, + # we set the transaction limit to 500 to avoid hitting the 15-minute timeout for lambda + transaction_limit=500, + last_processed_transaction_id=last_processed_transaction_id, + current_batch_id=current_batch_id, + processed_batch_ids=processed_batch_ids, + ) + + # Store transactions in DynamoDB + if transaction_response['transactions']: + config.transaction_client.store_transactions(compact, transaction_response['transactions']) + + # Return appropriate response based on whether there are more transactions to process + response = { + 'compact': compact, # Always include the compact name + 'status': 'IN_PROGRESS' if 'lastProcessedTransactionId' in transaction_response else 'COMPLETE', + 'processedBatchIds': transaction_response['processedBatchIds'], + } + + # Only include pagination values if we're not done processing + if 'lastProcessedTransactionId' in transaction_response: + response.update( + { + 'lastProcessedTransactionId': transaction_response['lastProcessedTransactionId'], + 'currentBatchId': transaction_response['currentBatchId'], + } + ) + + return response + + except TransactionBatchSettlementFailureException as e: + return {'compact': compact, 'status': 'BATCH_FAILURE', 'batchFailureErrorMessage': str(e)} diff --git a/backend/compact-connect/lambdas/python/purchases/purchase_client.py b/backend/compact-connect/lambdas/python/purchases/purchase_client.py index a4c0e6512..064e44e82 100644 --- a/backend/compact-connect/lambdas/python/purchases/purchase_client.py +++ b/backend/compact-connect/lambdas/python/purchases/purchase_client.py @@ -1,15 +1,44 @@ import json +import logging from abc import ABC, abstractmethod from authorizenet import apicontractsv1 -from authorizenet.apicontrollers import createTransactionController, getMerchantDetailsController +from authorizenet.apicontrollers import ( + createTransactionController, + getMerchantDetailsController, + getSettledBatchListController, + getTransactionDetailsController, + getTransactionListController, +) from authorizenet.constants import constants from cc_common.config import config, logger from cc_common.data_model.schema.compact import Compact, CompactFeeType from cc_common.data_model.schema.jurisdiction import Jurisdiction, JurisdictionMilitaryDiscountType -from cc_common.exceptions import CCFailedTransactionException, CCInternalException, CCInvalidRequestException +from cc_common.exceptions import ( + CCFailedTransactionException, + CCInternalException, + CCInvalidRequestException, + TransactionBatchSettlementFailureException, +) AUTHORIZE_DOT_NET_CLIENT_TYPE = 'authorize.net' +OK_TRANSACTION_MESSAGE_RESULT_CODE = 'Ok' +MAXIMUM_TRANSACTION_API_LIMIT = 1000 + +# The authorizenet SDK emits many warnings due to a Pyxb issue that they will not address, +# see https://github.com/AuthorizeNet/sdk-python/issues/133, +# so we are ignoring warnings to reduce noise in our logging +logging.getLogger('pyxb.binding.content').setLevel(logging.ERROR) + + +# We also want to ignore a specific 'error' message from them that does not actually impact the system +# see https://github.com/AuthorizeNet/sdk-python/issues/109 +class IgnoreContentNondeterminismFilter(logging.Filter): + def filter(self, record): + return 'ContentNondeterminismExceededError' not in record.getMessage() + + +logging.getLogger('authorizenet.sdk').addFilter(IgnoreContentNondeterminismFilter()) def _calculate_jurisdiction_fee(jurisdiction: Jurisdiction, user_active_military: bool) -> float: @@ -106,6 +135,28 @@ def validate_credentials(self) -> dict: Verify that the provided credentials are valid. """ + @abstractmethod + def get_settled_transactions( + self, + start_time: str, + end_time: str, + transaction_limit: int, + last_processed_transaction_id: str = None, + current_batch_id: str = None, + processed_batch_ids: list[str] = None, + ) -> dict: + """ + Get settled transactions from the payment processor. + + :param start_time: UTC timestamp string for start of range + :param end_time: UTC timestamp string for end of range + :param transaction_limit: Maximum number of transactions to return + :param last_processed_transaction_id: Optional last processed transaction ID for pagination + :param current_batch_id: Optional current batch ID being processed + :param processed_batch_ids: Optional list of batch IDs that have already been processed + :return: Dictionary containing transaction details and optional pagination info + """ + class AuthorizeNetPaymentProcessorClient(PaymentProcessorClient): def __init__(self, api_login_id: str, transaction_key: str): @@ -385,6 +436,295 @@ def validate_credentials(self) -> dict: raise CCInvalidRequestException(f'{logger_message} Error code: {error_code}, Error message: {error_message}') + def _get_settled_batch_list(self, start_time: str, end_time: str) -> apicontractsv1.getSettledBatchListResponse: + """ + Get the list of settled batches from the payment processor. + + :param start_time: UTC timestamp string for start of range + :param end_time: UTC timestamp string for end of range + :return: Response containing the list of settled batches + :raises CCInternalException: If the API call fails + :raises TransactionBatchSettlementFailureException: If any batch has a settlement error + """ + merchant_auth = apicontractsv1.merchantAuthenticationType() + merchant_auth.name = self.api_login_id + merchant_auth.transactionKey = self.transaction_key + + batch_request = apicontractsv1.getSettledBatchListRequest() + batch_request.merchantAuthentication = merchant_auth + batch_request.includeStatistics = True + batch_request.firstSettlementDate = start_time + batch_request.lastSettlementDate = end_time + + batch_controller = getSettledBatchListController(batch_request) + if config.environment_name != 'prod': + batch_controller.setenvironment(constants.SANDBOX) + else: + batch_controller.setenvironment(constants.PRODUCTION) + + logger.info('Getting settled batch list for timeframe', start_time=start_time, end_time=end_time) + batch_controller.execute() + batch_response = batch_controller.getresponse() + + if batch_response is None or batch_response.messages.resultCode != OK_TRANSACTION_MESSAGE_RESULT_CODE: + logger.error('Failed to get settled batch list') + raise CCInternalException('Failed to get settled batch list') + + # Check for settlement errors in any batch + if hasattr(batch_response, 'batchList'): + batch_ids = [str(batch.batchId) for batch in batch_response.batchList.batch] + logger.info('Found settled batches', batch_ids=batch_ids) + for batch in batch_response.batchList.batch: + if batch.settlementState == 'settlementError': + logger.info( + 'Settlement error found in batch. Raising exception to send error email.', + batch_id=batch.batchId, + ) + raise TransactionBatchSettlementFailureException(f'Settlement error found in batch {batch.batchId}') + + return batch_response + + def _get_transaction_list(self, batch_id: str, page_offset: int = 1) -> apicontractsv1.getTransactionListResponse: + """ + Get the list of transactions for a specific batch. + + :param batch_id: The batch ID to get transactions for + :param page_offset: The page offset for pagination (1-based) + :return: Response containing the list of transactions + :raises CCInternalException: If the API call fails + """ + merchant_auth = apicontractsv1.merchantAuthenticationType() + merchant_auth.name = self.api_login_id + merchant_auth.transactionKey = self.transaction_key + + transaction_request = apicontractsv1.getTransactionListRequest() + transaction_request.merchantAuthentication = merchant_auth + transaction_request.batchId = batch_id + + # Set sorting + sorting = apicontractsv1.TransactionListSorting() + sorting.orderBy = apicontractsv1.TransactionListOrderFieldEnum.submitTimeUTC + sorting.orderDescending = True + transaction_request.sorting = sorting + + # Set paging + paging = apicontractsv1.Paging() + paging.limit = MAXIMUM_TRANSACTION_API_LIMIT # Maximum allowed by API + paging.offset = page_offset + transaction_request.paging = paging + + transaction_controller = getTransactionListController(transaction_request) + if config.environment_name != 'prod': + transaction_controller.setenvironment(constants.SANDBOX) + else: + transaction_controller.setenvironment(constants.PRODUCTION) + + logger.info('Getting transaction list for batch', batch_id=batch_id, page_offset=page_offset) + transaction_controller.execute() + transaction_response = transaction_controller.getresponse() + + if ( + transaction_response is None + or transaction_response.messages.resultCode != OK_TRANSACTION_MESSAGE_RESULT_CODE + ): + logger.error( + 'Failed to get transaction list', + batch_id=batch_id, + page_offset=page_offset, + response=transaction_response, + ) + raise CCInternalException('Failed to get transaction list') + + transaction_ids = [str(tx.transId) for tx in transaction_response.transactions.transaction] + logger.info('Found transactions in batch', batch_id=batch_id, transaction_ids=transaction_ids) + + return transaction_response + + def _get_transaction_details(self, transaction_id: str) -> apicontractsv1.getTransactionDetailsResponse: + """ + Get detailed information for a specific transaction. + + :param transaction_id: The transaction ID to get details for + :return: Response containing the transaction details + :raises CCInternalException: If the API call fails + """ + merchant_auth = apicontractsv1.merchantAuthenticationType() + merchant_auth.name = self.api_login_id + merchant_auth.transactionKey = self.transaction_key + + details_request = apicontractsv1.getTransactionDetailsRequest() + details_request.merchantAuthentication = merchant_auth + details_request.transId = transaction_id + + details_controller = getTransactionDetailsController(details_request) + if config.environment_name != 'prod': + details_controller.setenvironment(constants.SANDBOX) + else: + details_controller.setenvironment(constants.PRODUCTION) + + logger.info('Getting transaction details', transaction_id=transaction_id) + details_controller.execute() + details_response = details_controller.getresponse() + + if details_response is None or details_response.messages.resultCode != OK_TRANSACTION_MESSAGE_RESULT_CODE: + logger.error('Failed to get transaction details', transaction_id=transaction_id, response=details_response) + raise CCInternalException('Failed to get transaction details') + + return details_response + + def get_settled_transactions( + self, + start_time: str, + end_time: str, + transaction_limit: int, + last_processed_transaction_id: str = None, + current_batch_id: str = None, + processed_batch_ids: list[str] = None, + ) -> dict: + """ + Get settled transactions from the payment processor. + + :param start_time: UTC timestamp string for start of range + :param end_time: UTC timestamp string for end of range + :param transaction_limit: Maximum number of transactions to return + :param last_processed_transaction_id: Optional last processed transaction ID for pagination + :param current_batch_id: Optional current batch ID being processed + :param processed_batch_ids: Optional list of batch IDs that have already been processed + :return: Dictionary containing transaction details and optional pagination info + """ + # Get settled batch list + batch_response = self._get_settled_batch_list(start_time, end_time) + + transactions = [] + last_batch_id = None + last_transaction_id = None + processed_transaction_count = 0 + found_last_processed = last_processed_transaction_id is None + processed_batch_ids = processed_batch_ids or [] + found_current_batch = current_batch_id is None + + if hasattr(batch_response, 'batchList'): + for batch in batch_response.batchList.batch: + batch_id = str(batch.batchId) + + # Skip batches we've already processed + if batch_id in processed_batch_ids: + continue + + # Skip batches until we find the current batch we were processing + if not found_current_batch: + if batch_id == current_batch_id: + logger.info('Found current batch to process', batch_id=current_batch_id) + found_current_batch = True + else: + continue + + if processed_transaction_count >= transaction_limit: + last_batch_id = batch_id + break + + # Get transaction list for batch with pagination + page_offset = 1 + transactions_in_page = 0 + while page_offset == 1 or transactions_in_page >= MAXIMUM_TRANSACTION_API_LIMIT: + transaction_response = self._get_transaction_list(batch_id, page_offset) + transactions_in_page = int(transaction_response.totalNumInResultSet) + + if hasattr(transaction_response, 'transactions'): + for transaction in transaction_response.transactions.transaction: + # Skip transactions until we find the last processed one + if not found_last_processed: + if str(transaction.transId) == last_processed_transaction_id: + logger.info( + 'Found last processed transaction', + transaction_id=last_processed_transaction_id, + batch_id=batch_id, + ) + found_last_processed = True + continue + + # Get detailed transaction information + details_response = self._get_transaction_details(str(transaction.transId)) + logger.debug( + 'Received transaction details', + batch_id=batch_id, + transaction_id=str(transaction.transId), + ) + tx = details_response.transaction + + licensee_id = None + if hasattr(tx, 'order') and tx.order.description: + # Extract licensee ID from order description (format: "LICENSEE#uuid#") + parts = str(tx.order.description).split('#') + if len(parts) >= 3 and parts[0] == 'LICENSEE': + licensee_id = parts[1] + + line_items = [] + if hasattr(tx, 'lineItems') and hasattr(tx.lineItems, 'lineItem'): + for item in tx.lineItems.lineItem: + line_items.append( + { + # we must cast these to strings, or they will cause an error when we + # try to serialize in other parts of the system + 'itemId': str(item.itemId), + 'name': str(item.name), + 'description': str(item.description), + 'quantity': str(item.quantity), + 'unitPrice': str(item.unitPrice), + 'taxable': str(item.taxable), + } + ) + + transaction_data = { + # we must cast these to strings, or they will cause an error when we try to serialize + # in other parts of the system + 'transactionId': str(tx.transId), + 'submitTimeUTC': str(tx.submitTimeUTC), + 'transactionType': str(tx.transactionType), + 'transactionStatus': str(tx.transactionStatus), + 'responseCode': str(tx.responseCode), + 'settleAmount': str(tx.settleAmount), + 'licenseeId': licensee_id, + 'batch': { + 'batchId': str(batch.batchId), + 'settlementTimeUTC': str(batch.settlementTimeUTC), + 'settlementTimeLocal': str(batch.settlementTimeLocal), + 'settlementState': str(batch.settlementState), + }, + 'lineItems': line_items, + # this defines the type of transaction processor that processed the transaction + 'transactionProcessor': AUTHORIZE_DOT_NET_CLIENT_TYPE, + } + transactions.append(transaction_data) + processed_transaction_count += 1 + if processed_transaction_count >= transaction_limit: + last_transaction_id = str(tx.transId) + break + + # Check if we need to get the next page of transactions + page_offset += 1 + + if processed_transaction_count >= transaction_limit: + last_batch_id = batch_id + logger.info( + 'Transaction limit reached. Returning last processed transaction', + last_processed_transaction_id=last_transaction_id, + current_batch_id=batch_id, + ) + break + + # If we've processed all transactions in this batch, add it to the processed list + logger.info('Finished processing batch', batch_id=batch_id) + processed_batch_ids.append(batch_id) + + response = {'transactions': transactions, 'processedBatchIds': processed_batch_ids} + + if last_transaction_id and last_batch_id: + response['lastProcessedTransactionId'] = last_transaction_id + response['currentBatchId'] = last_batch_id + + return response + class PaymentProcessorClientFactory: @staticmethod @@ -435,6 +775,7 @@ def _get_compact_payment_processor_client(self, compact_name: str) -> PaymentPro Get the payment processor credentials for a compact """ secret_name = self._get_payment_processor_secret_name_for_compact(compact_name) + logger.info('Getting payment processor credentials for compact', compact_name=compact_name) secret = self.secrets_manager_client.get_secret_value(SecretId=secret_name) return PaymentProcessorClientFactory.create_payment_processor_client(json.loads(secret['SecretString'])) @@ -529,3 +870,43 @@ def validate_and_store_credentials(self, compact_name: str, credentials: dict) - ) return response + + def get_settled_transactions( + self, + compact: str, + start_time: str, + end_time: str, + transaction_limit: int, + last_processed_transaction_id: str = None, + current_batch_id: str = None, + processed_batch_ids: list[str] = None, + ) -> dict: + """ + Get settled transactions from the payment processor. + + :param compact: The compact name + :param start_time: UTC timestamp string for start of range + :param end_time: UTC timestamp string for end of range + :param transaction_limit: Maximum number of transactions to return + :param last_processed_transaction_id: Optional last processed transaction ID for pagination + :param current_batch_id: Optional current batch ID being processed + :param processed_batch_ids: Optional list of batch IDs that have already been processed + :return: Dictionary containing transaction details and optional pagination info + """ + if not self.payment_processor_client: + self.payment_processor_client = self._get_compact_payment_processor_client(compact) + + response = self.payment_processor_client.get_settled_transactions( + start_time=start_time, + end_time=end_time, + transaction_limit=transaction_limit, + last_processed_transaction_id=last_processed_transaction_id, + current_batch_id=current_batch_id, + processed_batch_ids=processed_batch_ids, + ) + + # Add compact to each transaction + for transaction in response['transactions']: + transaction['compact'] = compact + + return response diff --git a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt index 9fac71ffd..a9e6b089c 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt @@ -4,26 +4,26 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/purchases/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.92 # via moto -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -33,9 +33,9 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.26 # via -r compact-connect/lambdas/python/purchases/requirements-dev.in -py-partiql-parser==0.5.6 +py-partiql-parser==0.6.1 # via moto pycparser==2.22 # via cffi @@ -56,9 +56,9 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/purchases/requirements.txt b/backend/compact-connect/lambdas/python/purchases/requirements.txt index 81c409b26..67ff2f5e0 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements.txt @@ -6,9 +6,9 @@ # authorizenet==1.1.5 # via -r compact-connect/lambdas/python/purchases/requirements.in -certifi==2024.8.30 +certifi==2024.12.14 # via requests -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests idna==3.10 # via requests @@ -18,5 +18,5 @@ pyxb-x==1.2.6.2 # via authorizenet requests==2.32.3 # via authorizenet -urllib3==2.2.3 +urllib3==2.3.0 # via requests diff --git a/backend/compact-connect/lambdas/python/purchases/tests/__init__.py b/backend/compact-connect/lambdas/python/purchases/tests/__init__.py index 74417b790..13fb71b2d 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/__init__.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/__init__.py @@ -15,6 +15,7 @@ def setUpClass(cls): 'DEBUG': 'false', 'AWS_DEFAULT_REGION': 'us-east-1', 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'TRANSACTION_HISTORY_TABLE_NAME': 'transaction-history-table', 'COMPACTS': '["aslp", "octp", "coun"]', 'JURISDICTIONS': '["ne", "oh", "ky"]', 'ENVIRONMENT_NAME': 'test', diff --git a/backend/compact-connect/lambdas/python/purchases/tests/function/__init__.py b/backend/compact-connect/lambdas/python/purchases/tests/function/__init__.py index 6c6694865..964fde090 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/function/__init__.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/function/__init__.py @@ -33,6 +33,7 @@ def setUp(self): # noqa: N801 invalid-name def build_resources(self): self.create_compact_configuration_table() self.create_provider_table() + self.create_transaction_history_table() def create_compact_configuration_table(self): self._compact_configuration_table = boto3.resource('dynamodb').create_table( @@ -45,6 +46,17 @@ def create_compact_configuration_table(self): BillingMode='PAY_PER_REQUEST', ) + def create_transaction_history_table(self): + self._transaction_history_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['TRANSACTION_HISTORY_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=[ @@ -79,6 +91,7 @@ def create_provider_table(self): def delete_resources(self): self._compact_configuration_table.delete() self._provider_table.delete() + self._transaction_history_table.delete() def _load_compact_configuration_data(self): """Use the canned test resources to load compact and jurisdiction information into the DB""" diff --git a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py index 225a698eb..845988093 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py @@ -1,4 +1,5 @@ import json +import os from datetime import UTC, date, datetime from unittest.mock import MagicMock, patch @@ -13,11 +14,41 @@ TEST_PROVIDER_ID = '89a6377e-c3a5-40e5-bca5-317ec854c570' MOCK_TRANSACTION_ID = '1234' - - -def _generate_test_request_body(selected_jurisdictions: list[str] = None): +ALL_ATTESTATION_IDS = [ + 'jurisprudence-confirmation', + 'scope-of-practice-attestation', + 'personal-information-home-state-attestation', + 'personal-information-address-attestation', + 'discipline-no-current-encumbrance-attestation', + 'discipline-no-prior-encumbrance-attestation', + 'provision-of-true-information-attestation', + 'not-under-investigation-attestation', + 'military-affiliation-confirmation-attestation', + 'under-investigation-attestation', +] + + +def generate_default_attestation_list(): + return [ + {'attestationId': 'jurisprudence-confirmation', 'version': '1'}, + {'attestationId': 'scope-of-practice-attestation', 'version': '1'}, + {'attestationId': 'personal-information-home-state-attestation', 'version': '1'}, + {'attestationId': 'personal-information-address-attestation', 'version': '1'}, + {'attestationId': 'discipline-no-current-encumbrance-attestation', 'version': '1'}, + {'attestationId': 'discipline-no-prior-encumbrance-attestation', 'version': '1'}, + {'attestationId': 'provision-of-true-information-attestation', 'version': '1'}, + {'attestationId': 'not-under-investigation-attestation', 'version': '1'}, + ] + + +def _generate_test_request_body( + selected_jurisdictions: list[str] = None, + attestations: list[dict] = None, +): if not selected_jurisdictions: selected_jurisdictions = ['ky'] + if attestations is None: + attestations = generate_default_attestation_list() return json.dumps( { @@ -33,6 +64,7 @@ def _generate_test_request_body(selected_jurisdictions: list[str] = None): 'zip': '12345', }, }, + 'attestations': attestations, } ) @@ -44,6 +76,25 @@ class TestPostPurchasePrivileges(TstFunction): purchasing a privilege in kentucky. """ + def setUp(self): + from cc_common.data_model.schema.attestation import AttestationRecordSchema + + super().setUp() + # set the feature flag to enable the attestation validation feature + # this should be removed when the feature is enabled by default + os.environ.update({'ENFORCE_ATTESTATIONS': 'true'}) + # Load test attestation data + with open('../common/tests/resources/dynamo/attestation.json') as f: + test_attestation = json.load(f) + # put in one attestation record for each attestation id + for attestation_id in ALL_ATTESTATION_IDS: + test_attestation['attestationId'] = attestation_id + test_attestation.pop('pk') + test_attestation.pop('sk') + serialized_data = AttestationRecordSchema().dump(test_attestation) + + self.config.compact_configuration_table.put_item(Item=serialized_data) + def _load_test_jurisdiction(self): with open('../common/tests/resources/dynamo/jurisdiction.json') as f: jurisdiction = json.load(f) @@ -133,10 +184,14 @@ def _when_testing_military_affiliation_status( ) event = self._when_testing_provider_user_event_with_custom_claims() self._load_military_affiliation_record_data(status=military_affiliation_status) - event['body'] = _generate_test_request_body() + attestations = generate_default_attestation_list() + # add the military affiliation attestation if active + if military_affiliation_status == 'active': + attestations.append({'attestationId': 'military-affiliation-confirmation-attestation', 'version': '1'}) + event['body'] = _generate_test_request_body(attestations=attestations) resp = post_purchase_privileges(event, self.mock_context) - self.assertEqual(200, resp['statusCode']) + self.assertEqual(200, resp['statusCode'], resp['body']) purchase_client_call_kwargs = mock_purchase_client.process_charge_for_licensee_privileges.call_args.kwargs self.assertEqual(expected_military_parameter, purchase_client_call_kwargs['user_active_military']) @@ -265,7 +320,7 @@ def test_purchase_privileges_invalid_if_existing_privilege_expiration_matches_li event['body'] = _generate_test_request_body() resp = post_purchase_privileges(event, self.mock_context) - self.assertEqual(200, resp['statusCode']) + self.assertEqual(200, resp['statusCode'], resp['body']) # now make the same call with the same jurisdiction resp = post_purchase_privileges(event, self.mock_context) @@ -314,7 +369,7 @@ def test_purchase_privileges_allows_existing_privilege_purchase_if_license_expir # now make the same call with the same jurisdiction resp = post_purchase_privileges(event, self.mock_context) - self.assertEqual(200, resp['statusCode']) + self.assertEqual(200, resp['statusCode'], resp['body']) response_body = json.loads(resp['body']) self.assertEqual({'transactionId': MOCK_TRANSACTION_ID}, response_body) @@ -377,7 +432,7 @@ def test_post_purchase_privileges_adds_privilege_record_if_transaction_successfu event['body'] = _generate_test_request_body() resp = post_purchase_privileges(event, self.mock_context) - self.assertEqual(200, resp['statusCode']) + self.assertEqual(200, resp['statusCode'], resp['body']) # check that the privilege record for ky was created provider_records = self.config.data_client.get_provider(compact=TEST_COMPACT, provider_id=TEST_PROVIDER_ID) @@ -398,6 +453,7 @@ def test_post_purchase_privileges_adds_privilege_record_if_transaction_successfu self.assertEqual(TEST_PROVIDER_ID, str(privilege_record['providerId'])) self.assertEqual('active', privilege_record['status']) self.assertEqual('privilege', privilege_record['type']) + self.assertEqual(len(generate_default_attestation_list()), len(privilege_record['attestations'])) # make sure we are tracking the transaction id self.assertEqual(MOCK_TRANSACTION_ID, privilege_record['compactTransactionId']) @@ -427,3 +483,141 @@ def test_post_purchase_privileges_voids_transaction_if_aws_error_occurs( mock_purchase_client.void_privilege_purchase_transaction.assert_called_once_with( compact_name=TEST_COMPACT, order_information={'transactionId': MOCK_TRANSACTION_ID} ) + + @patch('handlers.privileges.PurchaseClient') + def test_post_purchase_privileges_validates_attestation_version(self, mock_purchase_client_constructor): + """Test that the endpoint validates attestation versions.""" + from handlers.privileges import post_purchase_privileges + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + + event = self._when_testing_provider_user_event_with_custom_claims() + attestations = generate_default_attestation_list() + # Use an old version number + attestations[0]['version'] = '0' + event['body'] = _generate_test_request_body(attestations=attestations) + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(400, resp['statusCode']) + response_body = json.loads(resp['body']) + + self.assertEqual( + {'message': f'Attestation "{attestations[0]['attestationId']}" version 0 is not the latest version (1)'}, + response_body, + ) + mock_purchase_client_constructor.assert_not_called() + + @patch('handlers.privileges.PurchaseClient') + def test_post_purchase_privileges_validates_attestation_exists_in_list_of_required_attestations( + self, mock_purchase_client_constructor + ): + """Test that the endpoint validates attestation existence.""" + from handlers.privileges import post_purchase_privileges + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + + event = self._when_testing_provider_user_event_with_custom_claims() + attestations = generate_default_attestation_list() + # Use an attestation that doesn't exist + attestations.append({'attestationId': 'nonexistent-attestation', 'version': '1'}) + event['body'] = _generate_test_request_body(attestations=attestations) + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(400, resp['statusCode']) + response_body = json.loads(resp['body']) + + self.assertEqual( + {'message': 'Invalid attestations provided: nonexistent-attestation'}, + response_body, + ) + + @patch('handlers.privileges.PurchaseClient') + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat('2024-11-08T23:59:59+00:00')) + def test_post_purchase_privileges_stores_attestations_in_privilege_record(self, mock_purchase_client_constructor): + """Test that attestations are stored in the privilege record.""" + from handlers.privileges import post_purchase_privileges + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + + event = self._when_testing_provider_user_event_with_custom_claims(license_expiration_date='2050-01-01') + event['body'] = _generate_test_request_body() + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(200, resp['statusCode'], resp['body']) + + # check that the privilege record for ky was created with attestations + provider_records = self.config.data_client.get_provider(compact=TEST_COMPACT, provider_id=TEST_PROVIDER_ID) + privilege_record = next(record for record in provider_records['items'] if record['type'] == 'privilege') + + self.assertEqual(generate_default_attestation_list(), privilege_record['attestations']) + + @patch('handlers.privileges.PurchaseClient') + def test_post_purchase_privileges_validates_investigation_attestations(self, mock_purchase_client_constructor): + """Test that exactly one investigation attestation must be provided.""" + from handlers.privileges import post_purchase_privileges + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + + event = self._when_testing_provider_user_event_with_custom_claims() + + # Test with no investigation attestation + mock_attestation_list_copy = generate_default_attestation_list() + mock_attestation_list_copy.remove({'attestationId': 'not-under-investigation-attestation', 'version': '1'}) + event['body'] = _generate_test_request_body(attestations=mock_attestation_list_copy) + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(400, resp['statusCode']) + self.assertIn('Exactly one investigation attestation must be provided', json.loads(resp['body'])['message']) + + # Test with both investigation attestations + attestations = [ + {'attestationId': 'not-under-investigation-attestation', 'version': '1'}, + {'attestationId': 'under-investigation-attestation', 'version': '1'}, + ] + event['body'] = _generate_test_request_body(attestations=attestations) + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(400, resp['statusCode']) + self.assertIn('Exactly one investigation attestation must be provided', json.loads(resp['body'])['message']) + + @patch('handlers.privileges.PurchaseClient') + def test_post_purchase_privileges_validates_military_attestation(self, mock_purchase_client_constructor): + """Test that military attestation is required when user has active military affiliation.""" + from handlers.privileges import post_purchase_privileges + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + self._load_military_affiliation_record_data(status='active') + + event = self._when_testing_provider_user_event_with_custom_claims() + event['body'] = _generate_test_request_body() + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(400, resp['statusCode'], resp['body']) + self.assertIn('military-affiliation-confirmation-attestation', json.loads(resp['body'])['message']) + + # Add military attestation and verify it works + event_body = json.loads(event['body']) + event_body['attestations'].append( + {'attestationId': 'military-affiliation-confirmation-attestation', 'version': '1'} + ) + event['body'] = json.dumps(event_body) + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(200, resp['statusCode'], resp['body']) + + # TODO - remove this test once the feature flag is removed # noqa: FIX002 + @patch('handlers.privileges.PurchaseClient') + def test_post_purchase_privileges_should_not_validate_attestations_if_flag_not_set( + self, mock_purchase_client_constructor + ): + """Test that military attestation is required when user has active military affiliation.""" + from handlers.privileges import post_purchase_privileges + + os.environ.update({'ENFORCE_ATTESTATIONS': 'false'}) + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + self._load_military_affiliation_record_data(status='active') + + event = self._when_testing_provider_user_event_with_custom_claims() + event['body'] = _generate_test_request_body(attestations=[]) + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(200, resp['statusCode'], resp['body']) diff --git a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py new file mode 100644 index 000000000..4160974b7 --- /dev/null +++ b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_transaction_history.py @@ -0,0 +1,242 @@ +from datetime import datetime +from unittest.mock import ANY, MagicMock, patch + +from moto import mock_aws + +from .. import TstFunction + +TEST_COMPACT = 'aslp' +MOCK_START_TIME = '2024-01-01T00:00:00Z' +MOCK_END_TIME = '2024-01-02T00:00:00Z' +MOCK_TRANSACTION_LIMIT = 500 + +# Test transaction data +MOCK_TRANSACTION_ID = '12345' +MOCK_BATCH_ID = '67890' +MOCK_LICENSEE_ID = '89a6377e-c3a5-40e5-bca5-317ec854c570' +MOCK_SUBMIT_TIME_UTC = '2024-01-01T12:00:00.000Z' +MOCK_SETTLEMENT_TIME_UTC = '2024-01-01T13:00:00.000Z' +MOCK_SETTLEMENT_TIME_LOCAL = '2024-01-01T09:00:00' + +# Test Pagination values +MOCK_LAST_PROCESSED_TRANSACTION_ID = 'mock_last_processed_transaction_id' +MOCK_CURRENT_BATCH_ID = 'mock_current_batch_id' +MOCK_PROCESSED_BATCH_IDS = ['mock_processed_batch_id'] + + +def _generate_mock_transaction(): + return { + 'transactionId': MOCK_TRANSACTION_ID, + 'submitTimeUTC': MOCK_SUBMIT_TIME_UTC, + 'transactionType': 'authCaptureTransaction', + 'transactionStatus': 'settledSuccessfully', + 'responseCode': '1', + 'settleAmount': '100.00', + 'licenseeId': MOCK_LICENSEE_ID, + 'batch': { + 'batchId': MOCK_BATCH_ID, + 'settlementTimeUTC': MOCK_SETTLEMENT_TIME_UTC, + 'settlementTimeLocal': MOCK_SETTLEMENT_TIME_LOCAL, + 'settlementState': 'settledSuccessfully', + }, + 'lineItems': [ + { + 'itemId': 'aslp-oh', + 'name': 'Ohio Compact Privilege', + 'description': 'Compact Privilege for Ohio', + 'quantity': '1', + 'unitPrice': '100.00', + 'taxable': False, + } + ], + 'compact': TEST_COMPACT, + 'transactionProcessor': 'authorize.net', + } + + +@mock_aws +class TestProcessSettledTransactions(TstFunction): + """Test the process_settled_transactions Lambda function.""" + + def _when_testing_non_paginated_event(self, test_compact=TEST_COMPACT): + return { + 'compact': test_compact, + 'lastProcessedTransactionId': None, + 'currentBatchId': None, + 'processedBatchIds': None, + } + + def _when_testing_paginated_event(self, test_compact=TEST_COMPACT): + return { + 'compact': test_compact, + 'lastProcessedTransactionId': MOCK_LAST_PROCESSED_TRANSACTION_ID, + 'currentBatchId': MOCK_CURRENT_BATCH_ID, + 'processedBatchIds': MOCK_PROCESSED_BATCH_IDS, + } + + def _when_purchase_client_returns_transactions(self, mock_purchase_client_constructor, transactions=None): + if transactions is None: + transactions = [_generate_mock_transaction()] + + mock_purchase_client = MagicMock() + mock_purchase_client_constructor.return_value = mock_purchase_client + mock_purchase_client.get_settled_transactions.return_value = { + 'transactions': transactions, + 'processedBatchIds': [MOCK_BATCH_ID], + } + + return mock_purchase_client + + def _when_purchase_client_returns_paginated_transactions(self, mock_purchase_client_constructor): + mock_purchase_client = MagicMock() + mock_purchase_client_constructor.return_value = mock_purchase_client + + # First call returns first page with pagination info + mock_purchase_client.get_settled_transactions.side_effect = [ + { + 'transactions': [_generate_mock_transaction()], + 'processedBatchIds': [MOCK_BATCH_ID], + 'lastProcessedTransactionId': MOCK_LAST_PROCESSED_TRANSACTION_ID, + 'currentBatchId': MOCK_CURRENT_BATCH_ID, + }, + # Second call returns final page + {'transactions': [_generate_mock_transaction()], 'processedBatchIds': [MOCK_BATCH_ID]}, + ] + + return mock_purchase_client + + @patch('handlers.transaction_history.PurchaseClient') + def test_process_settled_transactions_returns_complete_status(self, mock_purchase_client_constructor): + """Test successful processing of settled transactions.""" + from handlers.transaction_history import process_settled_transactions + + self._when_purchase_client_returns_transactions(mock_purchase_client_constructor) + event = self._when_testing_non_paginated_event() + resp = process_settled_transactions(event, self.mock_context) + + self.assertEqual({'compact': 'aslp', 'processedBatchIds': [MOCK_BATCH_ID], 'status': 'COMPLETE'}, resp) + + @patch('handlers.transaction_history.PurchaseClient') + def test_process_settled_transactions_passes_pagination_values_into_purchase_client( + self, mock_purchase_client_constructor + ): + """Test handling of paginated transaction results.""" + from handlers.transaction_history import process_settled_transactions + + mock_purchase_client = self._when_purchase_client_returns_paginated_transactions( + mock_purchase_client_constructor + ) + event = self._when_testing_paginated_event() + + process_settled_transactions(event, self.mock_context) + + mock_purchase_client.get_settled_transactions.assert_called_with( + compact=TEST_COMPACT, + # timestamp check handled in another test + start_time=ANY, + end_time=ANY, + transaction_limit=500, + last_processed_transaction_id=MOCK_LAST_PROCESSED_TRANSACTION_ID, + current_batch_id=MOCK_CURRENT_BATCH_ID, + processed_batch_ids=MOCK_PROCESSED_BATCH_IDS, + ) + + @patch('handlers.transaction_history.PurchaseClient') + def test_process_settled_transactions_stores_transactions_in_dynamodb(self, mock_purchase_client_constructor): + """Test that transactions are stored in DynamoDB.""" + from handlers.transaction_history import process_settled_transactions + + self._when_purchase_client_returns_transactions(mock_purchase_client_constructor) + event = self._when_testing_non_paginated_event() + + process_settled_transactions(event, self.mock_context) + + # Verify transactions were stored in DynamoDB + stored_transactions = self.config.transaction_history_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': f'COMPACT#{TEST_COMPACT}#TRANSACTIONS#MONTH#2024-01'}, + ) + + expected_epoch_timestamp = int(datetime.fromisoformat(MOCK_SETTLEMENT_TIME_UTC).timestamp()) + self.assertEqual( + [ + { + 'batch': { + 'batchId': MOCK_BATCH_ID, + 'settlementState': 'settledSuccessfully', + 'settlementTimeLocal': '2024-01-01T09:00:00', + 'settlementTimeUTC': '2024-01-01T13:00:00.000Z', + }, + 'compact': TEST_COMPACT, + 'licenseeId': MOCK_LICENSEE_ID, + 'lineItems': [ + { + 'description': 'Compact Privilege for Ohio', + 'itemId': 'aslp-oh', + 'name': 'Ohio Compact Privilege', + 'quantity': '1', + 'taxable': False, + 'unitPrice': '100.00', + } + ], + 'pk': f'COMPACT#{TEST_COMPACT}#TRANSACTIONS#MONTH#2024-01', + 'responseCode': '1', + 'settleAmount': '100.00', + 'sk': f'COMPACT#{TEST_COMPACT}#TIME#{expected_epoch_timestamp}#BATCH#{MOCK_BATCH_ID}#' + f'TX#{MOCK_TRANSACTION_ID}', + 'submitTimeUTC': MOCK_SUBMIT_TIME_UTC, + 'transactionId': MOCK_TRANSACTION_ID, + 'transactionStatus': 'settledSuccessfully', + 'transactionType': 'authCaptureTransaction', + 'transactionProcessor': 'authorize.net', + } + ], + stored_transactions['Items'], + ) + + @patch('handlers.transaction_history.PurchaseClient') + def test_process_settled_transactions_returns_in_progress_status_with_pagination_values( + self, mock_purchase_client_constructor + ): + """Test that method returns IN_PROGRESS status with pagination values when more transactions are available.""" + from handlers.transaction_history import process_settled_transactions + + self._when_purchase_client_returns_paginated_transactions(mock_purchase_client_constructor) + + event = self._when_testing_non_paginated_event() + resp = process_settled_transactions(event, self.mock_context) + + self.assertEqual( + { + 'compact': TEST_COMPACT, + 'status': 'IN_PROGRESS', + 'lastProcessedTransactionId': MOCK_LAST_PROCESSED_TRANSACTION_ID, + 'currentBatchId': MOCK_CURRENT_BATCH_ID, + 'processedBatchIds': [MOCK_BATCH_ID], + }, + resp, + ) + + @patch('handlers.transaction_history.PurchaseClient') + def test_process_settled_transactions_returns_batch_failure_status(self, mock_purchase_client_constructor): + """Test that method returns BATCH_FAILURE status when a batch settlement error is encountered.""" + from cc_common.exceptions import TransactionBatchSettlementFailureException + from handlers.transaction_history import process_settled_transactions + + mock_purchase_client = MagicMock() + mock_purchase_client_constructor.return_value = mock_purchase_client + mock_purchase_client.get_settled_transactions.side_effect = TransactionBatchSettlementFailureException( + f'Settlement error in batch {MOCK_BATCH_ID}' + ) + + event = self._when_testing_non_paginated_event() + resp = process_settled_transactions(event, self.mock_context) + + self.assertEqual( + { + 'status': 'BATCH_FAILURE', + 'compact': TEST_COMPACT, + 'batchFailureErrorMessage': f'Settlement error in batch {MOCK_BATCH_ID}', + }, + resp, + ) diff --git a/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py b/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py index 1a288a594..0bcd68c95 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/unit/test_purchase_client.py @@ -22,6 +22,33 @@ EXPECTED_TOTAL_FEE_AMOUNT = 150.50 +# Test constants for transaction history tests +MOCK_BATCH_ID = '12345' +MOCK_BATCH_ID_2 = '12346' +MOCK_PROCESSED_BATCH_ID = '12344' +MOCK_PREVIOUS_TRANSACTION_ID = '67889' +MOCK_ITEM_ID = 'ITEM001' +MOCK_ITEM_NAME = 'Test Item' +MOCK_ITEM_DESCRIPTION = 'Test Description' +MOCK_ITEM_QUANTITY = '1' +MOCK_ITEM_PRICE = '10.00' + +# these transaction ids must be numeric to match the authorize.net SDK response +MOCK_TRANSACTION_ID_1_BATCH_1 = '11' +MOCK_TRANSACTION_ID_1_BATCH_2 = '12' +MOCK_TRANSACTION_ID_2_BATCH_2 = '22' + +# Test constants for transaction states and types +SUCCESSFUL_RESULT_CODE = 'Ok' +SUCCESSFUL_SETTLED_STATE = 'settledSuccessfully' +AUTH_CAPTURE_TRANSACTION_TYPE = 'authCaptureTransaction' + +# Test timestamps +MOCK_SETTLEMENT_TIME_UTC = '2024-12-27T17:49:20.757Z' +MOCK_SETTLEMENT_TIME_LOCAL = '2024-12-27T13:49:20.757' +MOCK_SETTLEMENT_TIME_UTC_2 = '2024-12-26T15:15:20.007Z' +MOCK_SETTLEMENT_TIME_LOCAL_2 = '2024-12-26T11:15:20.007Z' + def json_to_magic_mock(json_obj): """ @@ -592,3 +619,256 @@ def test_purchase_client_raises_exception_if_invalid_credentials(self, mock_get_ ) self.assertIn('Failed to verify credentials', str(context.exception.message)) + + def _when_authorize_dot_net_batch_list_is_successful(self, mock_controller): + mock_response = { + 'messages': { + 'resultCode': SUCCESSFUL_RESULT_CODE, + }, + 'batchList': { + 'batch': [ + { + 'batchId': MOCK_BATCH_ID, + 'settlementTimeUTC': MOCK_SETTLEMENT_TIME_UTC, + 'settlementTimeLocal': MOCK_SETTLEMENT_TIME_LOCAL, + 'settlementState': SUCCESSFUL_SETTLED_STATE, + } + ] + }, + } + return self._setup_mock_transaction_controller(mock_controller, mock_response) + + def _when_authorize_dot_net_transaction_list_is_successful(self, mock_controller): + mock_response = { + 'messages': { + 'resultCode': SUCCESSFUL_RESULT_CODE, + }, + 'transactions': { + 'transaction': [ + { + 'transId': MOCK_TRANSACTION_ID, + 'transactionStatus': SUCCESSFUL_SETTLED_STATE, + } + ] + }, + 'totalNumInResultSet': 1, + } + return self._setup_mock_transaction_controller(mock_controller, mock_response) + + def _generate_mock_transaction_list_response(self, transaction_ids: list[str]): + return { + 'messages': { + 'resultCode': SUCCESSFUL_RESULT_CODE, + }, + 'transactions': { + 'transaction': [ + { + 'transId': transaction_id, + 'transactionStatus': SUCCESSFUL_SETTLED_STATE, + } + for transaction_id in transaction_ids + ] + }, + 'totalNumInResultSet': 1, + } + + def _generate_mock_transaction_detail_response(self, transaction_id: str): + return { + 'messages': { + 'resultCode': SUCCESSFUL_RESULT_CODE, + }, + 'transaction': { + 'transId': transaction_id, + 'submitTimeUTC': MOCK_SETTLEMENT_TIME_UTC, + 'transactionType': AUTH_CAPTURE_TRANSACTION_TYPE, + 'transactionStatus': SUCCESSFUL_SETTLED_STATE, + 'responseCode': '1', + 'settleAmount': MOCK_ITEM_PRICE, + 'order': {'description': f'LICENSEE#{MOCK_LICENSEE_ID}#'}, + 'lineItems': { + 'lineItem': [ + { + 'itemId': MOCK_ITEM_ID, + 'name': MOCK_ITEM_NAME, + 'description': MOCK_ITEM_DESCRIPTION, + 'quantity': MOCK_ITEM_QUANTITY, + 'unitPrice': MOCK_ITEM_PRICE, + 'taxable': 'false', + } + ] + }, + }, + } + + def _when_authorize_dot_net_transaction_details_are_successful(self, mock_controller): + mock_response = self._generate_mock_transaction_detail_response(MOCK_TRANSACTION_ID) + return self._setup_mock_transaction_controller(mock_controller, mock_response) + + @patch('purchase_client.getSettledBatchListController') + @patch('purchase_client.getTransactionListController') + @patch('purchase_client.getTransactionDetailsController') + def test_purchase_client_gets_settled_transactions_successfully( + self, + mock_details_controller, + mock_transaction_controller, + mock_batch_controller, + ): + from purchase_client import PurchaseClient + + mock_secrets_manager_client = self._generate_mock_secrets_manager_client() + self._when_authorize_dot_net_batch_list_is_successful(mock_batch_controller) + self._when_authorize_dot_net_transaction_list_is_successful(mock_transaction_controller) + self._when_authorize_dot_net_transaction_details_are_successful(mock_details_controller) + + test_purchase_client = PurchaseClient(secrets_manager_client=mock_secrets_manager_client) + response = test_purchase_client.get_settled_transactions( + compact='aslp', + start_time='2024-01-01T00:00:00Z', + end_time='2024-01-02T00:00:00Z', + transaction_limit=500, + ) + + # Verify response structure + self.assertIn('transactions', response) + self.assertEqual(len(response['transactions']), 1) + self.assertIn('processedBatchIds', response) + self.assertEqual(len(response['processedBatchIds']), 1) + + # Verify transaction data + transaction = response['transactions'][0] + self.assertEqual(transaction['transactionId'], MOCK_TRANSACTION_ID) + self.assertEqual(transaction['compact'], 'aslp') + self.assertEqual(transaction['licenseeId'], MOCK_LICENSEE_ID) + self.assertEqual(transaction['batch']['batchId'], MOCK_BATCH_ID) + self.assertEqual(len(transaction['lineItems']), 1) + + @patch('purchase_client.getSettledBatchListController') + @patch('purchase_client.getTransactionListController') + @patch('purchase_client.getTransactionDetailsController') + def test_purchase_client_handles_pagination_for_settled_transactions( + self, + mock_details_controller, + mock_transaction_controller, + mock_batch_controller, + ): + from purchase_client import PurchaseClient + + mock_secrets_manager_client = self._generate_mock_secrets_manager_client() + + # Setup multiple batches + mock_batch_response = json_to_magic_mock( + { + 'messages': { + 'resultCode': SUCCESSFUL_RESULT_CODE, + }, + 'batchList': { + 'batch': [ + { + 'batchId': MOCK_BATCH_ID, + 'settlementTimeUTC': MOCK_SETTLEMENT_TIME_UTC, + 'settlementTimeLocal': MOCK_SETTLEMENT_TIME_LOCAL, + 'settlementState': SUCCESSFUL_SETTLED_STATE, + }, + { + 'batchId': MOCK_BATCH_ID_2, + 'settlementTimeUTC': MOCK_SETTLEMENT_TIME_UTC_2, + 'settlementTimeLocal': MOCK_SETTLEMENT_TIME_LOCAL_2, + 'settlementState': SUCCESSFUL_SETTLED_STATE, + }, + ] + }, + } + ) + mock_batch_controller.return_value.getresponse.return_value = mock_batch_response + + mock_transaction_list_responses = [ + # first batch returns one transaction + json_to_magic_mock(self._generate_mock_transaction_list_response([MOCK_TRANSACTION_ID_1_BATCH_1])), + # second api call returns two transactions from second call + json_to_magic_mock( + self._generate_mock_transaction_list_response( + [MOCK_TRANSACTION_ID_1_BATCH_2, MOCK_TRANSACTION_ID_2_BATCH_2] + ) + ), + # third api call should be called for second batch again to process remaining transactions, + # with same transaction list returned + json_to_magic_mock( + self._generate_mock_transaction_list_response( + [MOCK_TRANSACTION_ID_1_BATCH_2, MOCK_TRANSACTION_ID_2_BATCH_2] + ) + ), + ] + mock_transaction_controller.return_value.getresponse.side_effect = mock_transaction_list_responses + + mock_details_responses = [ + json_to_magic_mock(self._generate_mock_transaction_detail_response(MOCK_TRANSACTION_ID_1_BATCH_1)), + json_to_magic_mock(self._generate_mock_transaction_detail_response(MOCK_TRANSACTION_ID_1_BATCH_2)), + json_to_magic_mock(self._generate_mock_transaction_detail_response(MOCK_TRANSACTION_ID_2_BATCH_2)), + ] + mock_details_controller.return_value.getresponse.side_effect = mock_details_responses + + test_purchase_client = PurchaseClient(secrets_manager_client=mock_secrets_manager_client) + response = test_purchase_client.get_settled_transactions( + compact='aslp', + start_time='2024-01-01T00:00:00Z', + end_time='2024-01-02T00:00:00Z', + transaction_limit=2, + ) + + # Verify pagination info is returned + self.assertEqual(MOCK_TRANSACTION_ID_1_BATCH_2, response['lastProcessedTransactionId']) + self.assertEqual(MOCK_BATCH_ID_2, response['currentBatchId']) + self.assertEqual([MOCK_BATCH_ID], response['processedBatchIds']) + # Verify transaction data + self.assertEqual(2, len(response['transactions'])) + self.assertEqual(response['transactions'][0]['transactionId'], MOCK_TRANSACTION_ID_1_BATCH_1) + self.assertEqual(response['transactions'][1]['transactionId'], MOCK_TRANSACTION_ID_1_BATCH_2) + + # now fetch the remaining results + response = test_purchase_client.get_settled_transactions( + compact='aslp', + start_time='2024-01-01T00:00:00Z', + end_time='2024-01-02T00:00:00Z', + transaction_limit=2, + last_processed_transaction_id=response['lastProcessedTransactionId'], + current_batch_id=response['currentBatchId'], + processed_batch_ids=response['processedBatchIds'], + ) + + # Verify no pagination info is returned + self.assertNotIn('lastProcessedTransactionId', response) + self.assertNotIn('currentBatchId', response) + + # assert that the second transaction is returned, the first being skipped + self.assertEqual([MOCK_BATCH_ID, MOCK_BATCH_ID_2], response['processedBatchIds']) + self.assertEqual(1, len(response['transactions'])) + self.assertEqual(MOCK_TRANSACTION_ID_2_BATCH_2, response['transactions'][0]['transactionId']) + + @patch('purchase_client.getSettledBatchListController') + def test_purchase_client_handles_no_batches_for_settled_transactions(self, mock_batch_controller): + from purchase_client import PurchaseClient + + mock_secrets_manager_client = self._generate_mock_secrets_manager_client() + + # Return empty batch list + mock_response = json_to_magic_mock( + { + 'messages': { + 'resultCode': SUCCESSFUL_RESULT_CODE, + }, + 'batchList': {'batch': []}, + } + ) + mock_batch_controller.return_value.getresponse.return_value = mock_response + + test_purchase_client = PurchaseClient(secrets_manager_client=mock_secrets_manager_client) + response = test_purchase_client.get_settled_transactions( + compact='aslp', + start_time='2024-01-01T00:00:00Z', + end_time='2024-01-02T00:00:00Z', + transaction_limit=500, + ) + + # Verify empty response is returned when no batches exist + self.assertEqual(len(response['transactions']), 0) + self.assertEqual(len(response['processedBatchIds']), 0) diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/.coveragerc b/backend/compact-connect/lambdas/python/staff-user-pre-token/.coveragerc index 99c409d65..193e8ec8a 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/.coveragerc +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/.coveragerc @@ -1,5 +1,5 @@ [run] -data_file = ../../../.coverage +data_file = ../../../../.coverage omit = */cdk.out/* 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 4f112aff9..d383e3f7e 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,26 +4,26 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.92 # via moto -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via moto docker==7.1.0 # via moto idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via @@ -33,9 +33,9 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.0.21 +moto[dynamodb,s3]==5.0.26 # via -r compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.in -py-partiql-parser==0.5.6 +py-partiql-parser==0.6.1 # via moto pycparser==2.22 # via cffi @@ -56,9 +56,9 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py index 2dbf94ce7..57e47c139 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_main.py @@ -22,7 +22,6 @@ def test_happy_path(self): 'sk': 'COMPACT#aslp', 'compact': 'aslp', 'permissions': { - 'actions': {'read'}, 'jurisdictions': { # should correspond to the 'aslp/write' and 'aslp/al.write' scopes 'al': {'write'} @@ -34,7 +33,7 @@ def test_happy_path(self): resp = customize_scopes(event, self.mock_context) self.assertEqual( - sorted(['profile', 'aslp/read', 'aslp/write', 'aslp/al.write']), + sorted(['profile', 'aslp/readGeneral', 'aslp/write', 'aslp/al.write']), sorted(resp['response']['claimsAndScopeOverrideDetails']['accessTokenGeneration']['scopesToAdd']), ) diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py index 39bb1a516..c3a1905f1 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py @@ -20,13 +20,15 @@ def test_compact_ed_user(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read', 'admin'}, 'jurisdictions': {}}, + 'permissions': {'actions': {'read', 'admin', 'readPrivate'}, 'jurisdictions': {}}, } ) scopes = UserScopes(self._user_sub) - self.assertEqual({'profile', 'aslp/read', 'aslp/admin', 'aslp/aslp.admin'}, scopes) + self.assertEqual( + {'profile', 'aslp/readGeneral', 'aslp/admin', 'aslp/aslp.admin', 'aslp/aslp.readPrivate'}, scopes + ) def test_board_ed_user(self): from user_scopes import UserScopes @@ -37,13 +39,24 @@ def test_board_ed_user(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin', 'readPrivate'}}}, } ) scopes = UserScopes(self._user_sub) - self.assertEqual({'profile', 'aslp/read', 'aslp/admin', 'aslp/write', 'aslp/al.admin', 'aslp/al.write'}, scopes) + self.assertEqual( + { + 'profile', + 'aslp/readGeneral', + 'aslp/admin', + 'aslp/write', + 'aslp/al.admin', + 'aslp/al.write', + 'aslp/al.readPrivate', + }, + scopes, + ) def test_board_ed_user_multi_compact(self): """ @@ -58,7 +71,7 @@ def test_board_ed_user_multi_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) self._table.put_item( @@ -66,7 +79,7 @@ def test_board_ed_user_multi_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#octp', 'compact': 'octp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) @@ -75,12 +88,12 @@ def test_board_ed_user_multi_compact(self): self.assertEqual( { 'profile', - 'aslp/read', + 'aslp/readGeneral', 'aslp/admin', 'aslp/write', 'aslp/al.admin', 'aslp/al.write', - 'octp/read', + 'octp/readGeneral', 'octp/admin', 'octp/write', 'octp/al.admin', @@ -99,7 +112,6 @@ def test_board_staff(self): 'sk': 'COMPACT#aslp', 'compact': 'aslp', 'permissions': { - 'actions': {'read'}, 'jurisdictions': { 'al': {'write'} # should correspond to the 'aslp/al.write' scope }, @@ -109,7 +121,7 @@ def test_board_staff(self): scopes = UserScopes(self._user_sub) - self.assertEqual({'profile', 'aslp/read', 'aslp/write', 'aslp/al.write'}, scopes) + self.assertEqual({'profile', 'aslp/readGeneral', 'aslp/write', 'aslp/al.write'}, scopes) def test_missing_user(self): from user_scopes import UserScopes @@ -131,7 +143,7 @@ def test_disallowed_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) self._table.put_item( @@ -139,7 +151,7 @@ def test_disallowed_compact(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'abc', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'admin'}}}, } ) @@ -161,7 +173,7 @@ def test_disallowed_compact_action(self): 'compact': 'aslp', 'permissions': { # Write is jurisdiction-specific - 'actions': {'read', 'write'}, + 'actions': {'write'}, 'jurisdictions': {'al': {'write', 'admin'}}, }, } @@ -183,7 +195,7 @@ def test_disallowed_jurisdiction(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'ab': {'write', 'admin'}}}, + 'permissions': {'jurisdictions': {'ab': {'write', 'admin'}}}, } ) @@ -203,7 +215,7 @@ def test_disallowed_action(self): 'pk': f'USER#{self._user_sub}', 'sk': 'COMPACT#aslp', 'compact': 'aslp', - 'permissions': {'actions': {'read'}, 'jurisdictions': {'al': {'write', 'hack'}}}, + 'permissions': {'jurisdictions': {'al': {'write', 'hack'}}}, } ) diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py b/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py index 5db28567a..d5ed845e3 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/user_scopes.py @@ -21,7 +21,7 @@ def _get_scopes_from_db(self, sub: str): user_data = self._get_user_data(sub) permissions = { compact_record['compact']: { - 'actions': set(compact_record['permissions']['actions']), + 'actions': set(compact_record['permissions'].get('actions', [])), 'jurisdictions': compact_record['permissions']['jurisdictions'], } for compact_record in user_data @@ -48,13 +48,18 @@ def _process_compact_permissions(self, compact_name, compact_permissions): compact_actions = compact_permissions.get('actions', set()) # Ensure included actions are limited to supported values - disallowed_actions = compact_actions - {'read', 'admin'} + # Note we are keeping in the 'read' permission for backwards compatibility + # Though we are not using it in the codebase + disallowed_actions = compact_actions - {'read', 'admin', 'readPrivate'} if disallowed_actions: raise ValueError(f'User {compact_name} permissions include disallowed actions: {disallowed_actions}') - # Read is the only truly compact-level permission - if 'read' in compact_actions: - self.add(f'{compact_name}/read') + # readGeneral is always added an implicit permission granted to all staff users at the compact level + self.add(f'{compact_name}/readGeneral') + + if 'readPrivate' in compact_actions: + # This action only has one level of authz, since there is no external scope for it + self.add(f'{compact_name}/{compact_name}.readPrivate') if 'admin' in compact_actions: # Two levels of authz for admin @@ -74,13 +79,17 @@ def _process_compact_permissions(self, compact_name, compact_permissions): def _process_jurisdiction_permissions(self, compact_name, jurisdiction_name, jurisdiction_actions): # Ensure included actions are limited to supported values - disallowed_actions = jurisdiction_actions - {'write', 'admin'} + disallowed_actions = jurisdiction_actions - {'write', 'admin', 'readPrivate'} if disallowed_actions: raise ValueError( f'User {compact_name}/{jurisdiction_name} permissions include disallowed actions: ' f'{disallowed_actions}', ) for action in jurisdiction_actions: - # Two levels of authz - self.add(f'{compact_name}/{action}') self.add(f'{compact_name}/{jurisdiction_name}.{action}') + + # Grant coarse-grain scope, which provides access to the API itself + # Since `readGeneral` is implicitly granted to all users, we do not + # need to grant a coarse-grain scope for the `readPrivate` action. + if action != 'readPrivate': + self.add(f'{compact_name}/{action}') diff --git a/backend/compact-connect/lambdas/python/staff-users/.coveragerc b/backend/compact-connect/lambdas/python/staff-users/.coveragerc index 99c409d65..193e8ec8a 100644 --- a/backend/compact-connect/lambdas/python/staff-users/.coveragerc +++ b/backend/compact-connect/lambdas/python/staff-users/.coveragerc @@ -1,5 +1,5 @@ [run] -data_file = ../../../.coverage +data_file = ../../../../.coverage omit = */cdk.out/* 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 a43c369b8..baa4f2bfb 100644 --- a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url compact-connect/lambdas/python/staff-users/requirements-dev.in # -boto3==1.35.67 +boto3==1.35.92 # via moto -botocore==1.35.67 +botocore==1.35.92 # via # boto3 # moto # s3transfer -certifi==2024.8.30 +certifi==2024.12.14 # via requests cffi==1.17.1 # via cryptography -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -cryptography==43.0.3 +cryptography==44.0.0 # via # joserfc # moto @@ -27,21 +27,21 @@ faker==28.4.1 # via -r compact-connect/lambdas/python/staff-users/requirements-dev.in idna==3.10 # via requests -jinja2==3.1.4 +jinja2==3.1.5 # via moto jmespath==1.0.1 # via # boto3 # botocore -joserfc==1.0.0 +joserfc==1.0.1 # via moto markupsafe==3.0.2 # via # jinja2 # werkzeug -moto[cognitoidp,dynamodb,s3]==5.0.21 +moto[cognitoidp,dynamodb,s3]==5.0.26 # via -r compact-connect/lambdas/python/staff-users/requirements-dev.in -py-partiql-parser==0.5.6 +py-partiql-parser==0.6.1 # via moto pycparser==2.22 # via cffi @@ -63,9 +63,9 @@ responses==0.25.3 # via moto s3transfer==0.10.4 # via boto3 -six==1.16.0 +six==1.17.0 # via python-dateutil -urllib3==2.2.3 +urllib3==2.3.0 # via # botocore # docker diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/__init__.py b/backend/compact-connect/lambdas/python/staff-users/tests/function/__init__.py index 5135f275c..3ae4e091c 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/function/__init__.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/function/__init__.py @@ -71,7 +71,7 @@ def delete_resources(self): cognito_client.delete_user_pool(UserPoolId=self._user_pool_id) def _load_user_data(self) -> str: - with open('tests/resources/dynamo/user.json') as f: + 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)}) diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/__init__.py b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_data_model/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py index f7eb983f5..2eb7c081b 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py @@ -30,7 +30,7 @@ def test_patch_user(self): 'dateOfUpdate': '2024-09-12T23:59:59+00:00', 'permissions': { 'aslp': { - 'actions': {'read': True}, + 'actions': {'readPrivate': True}, 'jurisdictions': {'oh': {'actions': {'admin': True, 'write': True}}}, }, }, @@ -50,7 +50,7 @@ def test_patch_user_document_path_overlap(self): 'givenName': 'Test', }, 'compact': 'octp', - 'dateOfUpdate': '2024-10-21', + 'dateOfUpdate': '2024-09-12T12:34:56+00:00', 'famGiv': 'User#Test', 'permissions': {'actions': {'read'}, 'jurisdictions': {'oh': {'admin', 'write'}}}, 'type': 'user', @@ -128,7 +128,7 @@ def test_patch_user_add_to_empty_actions(self): # Add compact read and oh admin permissions to the user event['pathParameters'] = {'compact': 'aslp', 'userId': user_id} api_user['permissions'] = { - 'aslp': {'actions': {'read': True}, 'jurisdictions': {'oh': {'actions': {'admin': True}}}} + 'aslp': {'actions': {'readPrivate': True}, 'jurisdictions': {'oh': {'actions': {'admin': True}}}} } event['body'] = json.dumps(api_user) @@ -163,7 +163,7 @@ def test_patch_user_remove_all_actions(self): # Remove all the permissions from the user event['pathParameters'] = {'compact': 'aslp', 'userId': user_id} api_user['permissions'] = { - 'aslp': {'actions': {'read': False}, 'jurisdictions': {'oh': {'actions': {'write': False}}}} + 'aslp': {'actions': {'readPrivate': False}, 'jurisdictions': {'oh': {'actions': {'write': False}}}} } event['body'] = json.dumps(api_user) @@ -194,3 +194,50 @@ def test_patch_user_forbidden(self): resp = patch_user(event, self.mock_context) self.assertEqual(403, resp['statusCode']) + + def test_patch_user_allows_adding_read_private_permission(self): + self._load_user_data() + + 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 aslp/oh + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/admin aslp/oh.admin aslp/aslp.admin' + ) + event['pathParameters'] = {'compact': 'aslp', 'userId': 'a4182428-d061-701c-82e5-a3d1d547d797'} + event['body'] = json.dumps( + { + 'permissions': { + 'aslp': { + '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', + 'permissions': { + 'aslp': { + '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, + ) diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py index c6af4ca19..656e6f95b 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py @@ -19,7 +19,9 @@ def test_post_user(self): api_user = json.load(f) # The user has admin permission for aslp/oh - event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/admin aslp/oh.admin' + event['requestContext']['authorizer']['claims']['scope'] = ( + 'openid email aslp/admin aslp/aslp.admin aslp/oh.admin' + ) event['pathParameters'] = {'compact': 'aslp'} resp = post_user(event, self.mock_context) diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json index 9cda12771..f234d5355 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json +++ b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-post.json @@ -8,7 +8,7 @@ "permissions": { "aslp": { "actions": { - "read": true + "readPrivate": true }, "jurisdictions": { "oh": { diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json index cbf7f145b..18426954f 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json +++ b/backend/compact-connect/lambdas/python/staff-users/tests/resources/api/user-response.json @@ -10,7 +10,7 @@ "permissions": { "aslp": { "actions": { - "read": true + "readPrivate": true }, "jurisdictions": { "oh": { diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py index 68a4b3c3e..2f69d3a78 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_authorize_compact.py @@ -9,14 +9,14 @@ class TestAuthorizeCompact(TstLambdas): def test_authorize_compact(self): from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') 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 aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = { 'compact': 'aslp', } @@ -27,13 +27,13 @@ def test_no_path_param(self): from cc_common.exceptions import CCInvalidRequestException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') 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 aslp/read' + event['requestContext']['authorizer']['claims']['scope'] = 'openid email stuff aslp/readGeneral' event['pathParameters'] = {} with self.assertRaises(CCInvalidRequestException): @@ -43,7 +43,7 @@ def test_no_authorizer(self): from cc_common.exceptions import CCUnauthorizedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} @@ -59,7 +59,7 @@ def test_missing_scope(self): from cc_common.exceptions import CCAccessDeniedException from cc_common.utils import authorize_compact - @authorize_compact(action='read') + @authorize_compact(action='readGeneral') def example_entrypoint(event: dict, context: LambdaContext): # noqa: ARG001 unused-argument return {'body': 'Hurray!'} diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py index bf1199b92..a41fc9850 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_collect_changes.py @@ -10,12 +10,12 @@ def test_compact_changes(self): resp = collect_and_authorize_changes( path_compact='aslp', scopes={'openid', 'email', 'aslp/admin', 'aslp/aslp.admin'}, - compact_changes={'actions': {'admin': True, 'read': False}, 'jurisdictions': {}}, + compact_changes={'actions': {'admin': True, 'readPrivate': False}, 'jurisdictions': {}}, ) self.assertEqual( { 'compact_action_additions': {'admin'}, - 'compact_action_removals': {'read'}, + 'compact_action_removals': {'readPrivate'}, 'jurisdiction_action_additions': {}, 'jurisdiction_action_removals': {}, }, @@ -69,14 +69,14 @@ def test_compact_and_jurisdiction_changes(self): path_compact='aslp', scopes={'openid', 'email', 'aslp/admin', 'aslp/aslp.admin'}, compact_changes={ - 'actions': {'admin': True, 'read': False}, + 'actions': {'admin': True, 'readPrivate': False}, 'jurisdictions': {'oh': {'actions': {'admin': True, 'write': False}}}, }, ) self.assertEqual( { 'compact_action_additions': {'admin'}, - 'compact_action_removals': {'read'}, + 'compact_action_removals': {'readPrivate'}, 'jurisdiction_action_additions': {'oh': {'admin'}}, 'jurisdiction_action_removals': {'oh': {'write'}}, }, diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_paginated.py b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_paginated.py index e9c1cfb1b..789cdff15 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_paginated.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_paginated.py @@ -10,7 +10,7 @@ class TestPaginated(TstLambdas): def setUp(self): # noqa: N801 invalid-name - with open('tests/resources/dynamo/user.json') as f: + with open('../common/tests/resources/dynamo/user.json') as f: self._item = TypeDeserializer().deserialize({'M': json.load(f)}) def test_pagination_parameters(self): diff --git a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py index 46a345a72..8d03a6380 100644 --- a/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py +++ b/backend/compact-connect/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py @@ -13,7 +13,7 @@ def test_transform_api_to_dynamo_permissions(self): with open('tests/resources/api/user-post.json') as f: api_user = json.load(f) - with open('tests/resources/dynamo/user.json') as f: + with open('../common/tests/resources/dynamo/user.json') as f: dynamo_user = TypeDeserializer().deserialize({'M': json.load(f)}) schema = UserAPISchema() @@ -30,7 +30,7 @@ def test_transform_dynamo_to_api_permissions(self): with open('tests/resources/api/user-post.json') as f: api_user = json.load(f) - with open('tests/resources/dynamo/user.json') as f: + with open('../common/tests/resources/dynamo/user.json') as f: dynamo_user = UserRecordSchema().load(TypeDeserializer().deserialize({'M': json.load(f)})) schema = UserAPISchema() @@ -45,7 +45,7 @@ def test_serde_record(self): """Test round-trip serialization/deserialization of user records""" from cc_common.data_model.schema.user import UserRecordSchema - with open('tests/resources/dynamo/user.json') as f: + with open('../common/tests/resources/dynamo/user.json') as f: expected_user = TypeDeserializer().deserialize({'M': json.load(f)}) schema = UserRecordSchema() @@ -60,7 +60,7 @@ def test_serde_record(self): def test_invalid_record(self): from cc_common.data_model.schema.user import UserRecordSchema - with open('tests/resources/dynamo/user.json') as f: + with open('../common/tests/resources/dynamo/user.json') as f: user_data = TypeDeserializer().deserialize({'M': json.load(f)}) user_data.pop('attributes') diff --git a/backend/compact-connect/pipeline/backend_stage.py b/backend/compact-connect/pipeline/backend_stage.py index 1b31027f7..d682c9cfd 100644 --- a/backend/compact-connect/pipeline/backend_stage.py +++ b/backend/compact-connect/pipeline/backend_stage.py @@ -5,6 +5,7 @@ from stacks.ingest_stack import IngestStack from stacks.persistent_stack import PersistentStack from stacks.reporting_stack import ReportingStack +from stacks.transaction_monitoring_stack import TransactionMonitoringStack from stacks.ui_stack import UIStack @@ -76,3 +77,13 @@ def __init__( standard_tags=standard_tags, persistent_stack=self.persistent_stack, ) + + self.transaction_monitoring_stack = TransactionMonitoringStack( + self, + 'TransactionMonitoringStack', + env=environment, + environment_name=environment_name, + environment_context=environment_context, + standard_tags=standard_tags, + persistent_stack=self.persistent_stack, + ) diff --git a/backend/compact-connect/requirements-dev.txt b/backend/compact-connect/requirements-dev.txt index a4dbea512..ef9e6bc8a 100644 --- a/backend/compact-connect/requirements-dev.txt +++ b/backend/compact-connect/requirements-dev.txt @@ -12,13 +12,13 @@ cachecontrol[filecache]==0.14.1 # via # cachecontrol # pip-audit -certifi==2024.8.30 +certifi==2024.12.14 # via requests -charset-normalizer==3.4.0 +charset-normalizer==3.4.1 # via requests -click==8.1.7 +click==8.1.8 # via pip-tools -coverage[toml]==7.6.7 +coverage[toml]==7.6.10 # via # -r compact-connect/requirements-dev.in # pytest-cov @@ -64,15 +64,15 @@ pluggy==1.5.0 # via pytest py-serializable==1.1.2 # via cyclonedx-python-lib -pygments==2.18.0 +pygments==2.19.0 # via rich -pyparsing==3.2.0 +pyparsing==3.2.1 # via pip-requirements-parser pyproject-hooks==1.2.0 # via # build # pip-tools -pytest==8.3.3 +pytest==8.3.4 # via # -r compact-connect/requirements-dev.in # pytest-cov @@ -86,9 +86,9 @@ requests==2.32.3 # pip-audit rich==13.9.4 # via pip-audit -ruff==0.7.4 +ruff==0.8.6 # via -r compact-connect/requirements-dev.in -six==1.16.0 +six==1.17.0 # via # html5lib # python-dateutil @@ -96,11 +96,11 @@ sortedcontainers==2.4.0 # via cyclonedx-python-lib toml==0.10.2 # via pip-audit -urllib3==2.2.3 +urllib3==2.3.0 # via requests webencodings==0.5.1 # via html5lib -wheel==0.45.0 +wheel==0.45.1 # 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 dc3d575f6..bdc771299 100644 --- a/backend/compact-connect/requirements.txt +++ b/backend/compact-connect/requirements.txt @@ -4,28 +4,28 @@ # # pip-compile --no-emit-index-url compact-connect/requirements.in # -attrs==24.2.0 +attrs==24.3.0 # via # cattrs # jsii -aws-cdk-asset-awscli-v1==2.2.212 +aws-cdk-asset-awscli-v1==2.2.218 # via aws-cdk-lib aws-cdk-asset-kubectl-v20==2.1.3 # via aws-cdk-lib aws-cdk-asset-node-proxy-agent-v6==2.1.0 # via aws-cdk-lib -aws-cdk-aws-lambda-python-alpha==2.169.0a0 +aws-cdk-aws-lambda-python-alpha==2.174.0a0 # via -r compact-connect/requirements.in -aws-cdk-cloud-assembly-schema==38.0.1 +aws-cdk-cloud-assembly-schema==39.1.38 # via aws-cdk-lib -aws-cdk-lib==2.169.0 +aws-cdk-lib==2.174.0 # via # -r compact-connect/requirements.in # aws-cdk-aws-lambda-python-alpha # cdk-nag cattrs==24.1.2 # via jsii -cdk-nag==2.34.3 +cdk-nag==2.34.23 # via -r compact-connect/requirements.in constructs==10.4.2 # via @@ -33,9 +33,9 @@ constructs==10.4.2 # aws-cdk-aws-lambda-python-alpha # aws-cdk-lib # cdk-nag -importlib-resources==6.4.5 +importlib-resources==6.5.2 # via jsii -jsii==1.105.0 +jsii==1.106.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-kubectl-v20 @@ -60,7 +60,7 @@ python-dateutil==2.9.0.post0 # via jsii pyyaml==6.0.2 # via -r compact-connect/requirements.in -six==1.16.0 +six==1.17.0 # via python-dateutil typeguard==2.13.3 # via diff --git a/backend/compact-connect/stacks/api_stack/cc_api.py b/backend/compact-connect/stacks/api_stack/cc_api.py index c507fb3d7..da28471a8 100644 --- a/backend/compact-connect/stacks/api_stack/cc_api.py +++ b/backend/compact-connect/stacks/api_stack/cc_api.py @@ -116,14 +116,13 @@ def __init__( { 'source_ip': '$context.identity.sourceIp', 'identity': { - 'caller': '$context.identity.caller', - 'user': '$context.identity.user', + 'user': '$context.authorizer.claims.sub', 'user_agent': '$context.identity.userAgent', }, 'level': 'INFO', 'message': 'API Access log', 'request_time': '[$context.requestTime]', - 'http_method': '$context.httpMethod', + 'method': '$context.httpMethod', 'domain_name': '$context.domainName', 'resource_path': '$context.resourcePath', 'path': '$context.path', @@ -131,6 +130,9 @@ def __init__( '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', } ) ), @@ -195,7 +197,7 @@ def __init__( 'RuntimeQuery', query_definition_name=f'{construct_id}/Lambdas', query_string=QueryString( - fields=['@timestamp', '@log', 'level', 'status', 'message', 'http_method', 'path', '@message'], + fields=['@timestamp', '@log', 'level', 'status', 'message', 'method', 'path', '@message'], filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], sort='@timestamp desc', ), @@ -209,7 +211,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs' ], 'reason': 'This policy is crafted specifically for the account-level role created here.', @@ -234,7 +236,10 @@ def __init__( @cached_property def staff_users_authorizer(self): return CognitoUserPoolsAuthorizer( - self, 'StaffPoolsAuthorizer', cognito_user_pools=[self._persistent_stack.staff_users] + self, + 'StaffPoolsAuthorizer', + cognito_user_pools=[self._persistent_stack.staff_users], + results_cache_ttl=Duration.minutes(5), # Default ttl is 5 minutes. We're setting this just to be explicit ) @cached_property diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api.py b/backend/compact-connect/stacks/api_stack/v1_api/api.py index 456655634..4f49740e0 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api.py @@ -4,6 +4,7 @@ from stacks import persistent_stack as ps from stacks.api_stack import cc_api +from stacks.api_stack.v1_api.attestations import Attestations from stacks.api_stack.v1_api.bulk_upload_url import BulkUploadUrl from stacks.api_stack.v1_api.provider_users import ProviderUsers from stacks.api_stack.v1_api.purchases import Purchases @@ -25,7 +26,7 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): self.api: cc_api.CCApi = root.api self.api_model = ApiModel(api=self.api) read_scopes = [ - f'{resource_server}/read' for resource_server in persistent_stack.staff_users.resource_servers.keys() + f'{resource_server}/readGeneral' for resource_server in persistent_stack.staff_users.resource_servers.keys() ] write_scopes = [ f'{resource_server}/write' for resource_server in persistent_stack.staff_users.resource_servers.keys() @@ -73,6 +74,14 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): # /v1/compacts/{compact} self.compact_resource = self.compacts_resource.add_resource('{compact}') + # /v1/compacts/{compact}/attestations + self.attestations_resource = self.compact_resource.add_resource('attestations') + self.attestations = Attestations( + resource=self.attestations_resource, + persistent_stack=persistent_stack, + api_model=self.api_model, + ) + # /v1/compacts/{compact}/credentials credentials_resource = self.compact_resource.add_resource('credentials') self.credentials = Credentials( @@ -89,6 +98,7 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): method_options=read_auth_method_options, data_encryption_key=persistent_stack.shared_encryption_key, provider_data_table=persistent_stack.provider_table, + ssn_table=persistent_stack.ssn_table, api_model=self.api_model, ) @@ -111,17 +121,17 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack): ) # /v1/staff-users - staff_users_admin_resource = self.compact_resource.add_resource('staff-users') - staff_users_self_resource = self.resource.add_resource('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} - StaffUsers( - admin_resource=staff_users_admin_resource, - self_resource=staff_users_self_resource, + self.staff_users = StaffUsers( + admin_resource=self.staff_users_admin_resource, + self_resource=self.staff_users_self_resource, admin_scopes=admin_scopes, persistent_stack=persistent_stack, api_model=self.api_model, diff --git a/backend/compact-connect/stacks/api_stack/v1_api/api_model.py b/backend/compact-connect/stacks/api_stack/v1_api/api_model.py index c2cb9992b..6766a6e3c 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/api_model.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/api_model.py @@ -294,8 +294,11 @@ def _staff_user_permissions_schema(self): 'actions': JsonSchema( type=JsonSchemaType.OBJECT, properties={ - 'read': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), + # TODO keeping 'read' action for backwards compatibility # noqa: FIX002 + # this should be removed after the frontend is updated + 'read': JsonSchema(type=JsonSchemaType.BOOLEAN), }, ), 'jurisdictions': JsonSchema( @@ -309,6 +312,7 @@ def _staff_user_permissions_schema(self): properties={ 'write': JsonSchema(type=JsonSchemaType.BOOLEAN), 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), }, ), }, @@ -464,27 +468,7 @@ def patch_provider_user_military_affiliation_request_model(self) -> Model: @property def post_purchase_privileges_request_model(self) -> Model: - """Return the purchase privilege request model, which should only be created once per API - create a schema that defines the following object example: - { - "selectedJurisdictions": [""], - "orderInformation": { - "card": { - "number": "", - "expiration": "", - "cvv": "" - }, - "billing": { - "firstName": "testFirstName", - "lastName": "testLastName", - "streetAddress": "123 Test St", - "streetAddress2": "", # optional - "state": "OH", - "zip": "12345", - } - } - } - """ + """Return the purchase privilege request model, which should only be created once per API""" if hasattr(self.api, '_v1_post_purchase_privileges_request_model'): return self.api._v1_post_purchase_privileges_request_model self.api._v1_post_purchase_privileges_request_model = self.api.add_model( @@ -492,7 +476,7 @@ def post_purchase_privileges_request_model(self) -> Model: description='Post purchase privileges request model', schema=JsonSchema( type=JsonSchemaType.OBJECT, - required=['selectedJurisdictions', 'orderInformation'], + required=['selectedJurisdictions', 'orderInformation', 'attestations'], properties={ 'selectedJurisdictions': JsonSchema( type=JsonSchemaType.ARRAY, @@ -578,6 +562,29 @@ def post_purchase_privileges_request_model(self) -> Model: ), }, ), + 'attestations': JsonSchema( + type=JsonSchemaType.ARRAY, + description='List of attestations that the user has agreed to', + items=JsonSchema( + type=JsonSchemaType.OBJECT, + required=['attestationId', 'version'], + properties={ + 'attestationId': JsonSchema( + type=JsonSchemaType.STRING, + max_length=100, + description='The ID of the attestation', + ), + 'version': JsonSchema( + # we store the version as a string, rather than an integer, to avoid + # type casting between DynamoDB's Decimal and Python's int + type=JsonSchemaType.STRING, + max_length=10, + description='The version of the attestation', + pattern=r'^\d+$', + ), + }, + ), + ), }, ), ) @@ -847,13 +854,84 @@ def _provider_detail_response_schema(self): format='date', pattern=cc_api.YMD_FORMAT, ), + 'history': JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['licenseUpdate']), + 'updateType': JsonSchema( + type=JsonSchemaType.STRING, enum=['renewal', 'deactivation', 'other'] + ), + 'compact': JsonSchema( + type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts') + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions') + ), + 'dateOfUpdate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=cc_api.YMD_FORMAT + ), + 'previous': JsonSchema( + type=JsonSchemaType.OBJECT, properties=self._common_license_properties + ), + 'updatedValues': JsonSchema( + type=JsonSchemaType.OBJECT, properties=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), + ), + }, + ), + ), **self._common_license_properties, }, ), ), 'privileges': JsonSchema( type=JsonSchemaType.ARRAY, - items=JsonSchema(type=JsonSchemaType.OBJECT, properties=self._common_privilege_properties), + items=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'history': JsonSchema( + type=JsonSchemaType.ARRAY, + items=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['privilegeUpdate']), + 'updateType': JsonSchema( + type=JsonSchemaType.STRING, enum=['renewal', 'deactivation', 'other'] + ), + 'compact': JsonSchema( + type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts') + ), + 'jurisdiction': JsonSchema( + type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions') + ), + 'dateOfUpdate': JsonSchema( + type=JsonSchemaType.STRING, format='date', pattern=cc_api.YMD_FORMAT + ), + 'previous': JsonSchema( + type=JsonSchemaType.OBJECT, 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), + ), + }, + ), + ), + **self._common_privilege_properties, + }, + ), ), **self._common_provider_properties, }, @@ -979,3 +1057,28 @@ def _pagination_response_schema(self): 'pageSize': JsonSchema(type=JsonSchemaType.INTEGER, minimum=5, maximum=100), }, ) + + @property + def get_attestations_response_model(self) -> Model: + """Return the attestations response model, which should only be created once per API""" + if hasattr(self.api, '_v1_get_attestations_response_model'): + return self.api._v1_get_attestations_response_model + + stack: AppStack = AppStack.of(self.api) + self.api._v1_get_attestations_response_model = self.api.add_model( + 'V1GetAttestationsResponseModel', + description='Get attestations response model', + schema=JsonSchema( + type=JsonSchemaType.OBJECT, + properties={ + 'type': JsonSchema(type=JsonSchemaType.STRING, enum=['attestation']), + 'attestationType': JsonSchema(type=JsonSchemaType.STRING), + 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')), + 'version': JsonSchema(type=JsonSchemaType.STRING), + 'dateCreated': JsonSchema(type=JsonSchemaType.STRING, format='date-time'), + 'text': JsonSchema(type=JsonSchemaType.STRING), + 'required': JsonSchema(type=JsonSchemaType.BOOLEAN), + }, + ), + ) + return self.api._v1_get_attestations_response_model diff --git a/backend/compact-connect/stacks/api_stack/v1_api/attestations.py b/backend/compact-connect/stacks/api_stack/v1_api/attestations.py new file mode 100644 index 000000000..358b5bd73 --- /dev/null +++ b/backend/compact-connect/stacks/api_stack/v1_api/attestations.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import os + +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 stacks import persistent_stack as ps + +# Importing module level to allow lazy loading for typing +from stacks.api_stack import cc_api + +from .api_model import ApiModel + + +class Attestations: + def __init__( + self, + *, + resource: Resource, + persistent_stack: ps.PersistentStack, + api_model: ApiModel, + ): + super().__init__() + + self.resource = resource + self.api: cc_api.CCApi = resource.api + self.api_model = api_model + + stack: Stack = Stack.of(resource) + lambda_environment = { + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, + **stack.common_env_vars, + } + + # Create the attestations lambda function that will be shared by all attestation related endpoints + self.attestations_function = PythonFunction( + self.api, + 'AttestationsFunction', + index=os.path.join('handlers', 'attestations.py'), + lambda_dir='attestations', + handler='attestations', + environment=lambda_environment, + timeout=Duration.seconds(30), + ) + persistent_stack.shared_encryption_key.grant_decrypt(self.attestations_function) + persistent_stack.compact_configuration_table.grant_read_write_data(self.attestations_function) + self.api.log_groups.append(self.attestations_function.log_group) + + NagSuppressions.add_resource_suppressions_by_path( + stack, + path=f'{self.attestations_function.node.path}/ServiceRole/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.', + }, + ], + ) + + # GET /v1/compacts/{compact}/attestations/{attestationId} + self.attestation_id_resource = self.resource.add_resource('{attestationId}') + self._add_get_attestation( + attestations_function=self.attestations_function, + ) + + def _add_get_attestation( + self, + attestations_function: PythonFunction, + ): + self.attestation_id_resource.add_method( + 'GET', + LambdaIntegration(attestations_function), + method_responses=[ + MethodResponse( + status_code='200', + response_models={'application/json': self.api_model.get_attestations_response_model}, + ), + ], + request_parameters={'method.request.header.Authorization': True}, + authorizer=self.api.provider_users_authorizer, + ) diff --git a/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py b/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py index 67d3aa0ae..6557d62b1 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/query_providers.py @@ -11,7 +11,7 @@ # Importing module level to allow lazy loading for typing from stacks.api_stack import cc_api -from stacks.persistent_stack import ProviderTable +from stacks.persistent_stack import ProviderTable, SSNTable from .api_model import ApiModel @@ -23,6 +23,7 @@ def __init__( resource: Resource, method_options: MethodOptions, data_encryption_key: IKey, + ssn_table: SSNTable, provider_data_table: ProviderTable, api_model: ApiModel, ): @@ -35,8 +36,10 @@ def __init__( stack: Stack = Stack.of(resource) lambda_environment = { 'PROVIDER_TABLE_NAME': provider_data_table.table_name, - 'PROV_FAM_GIV_MID_INDEX_NAME': 'providerFamGivMid', - 'PROV_DATE_OF_UPDATE_INDEX_NAME': 'providerDateOfUpdate', + 'PROV_FAM_GIV_MID_INDEX_NAME': provider_data_table.provider_fam_giv_mid_index_name, + 'PROV_DATE_OF_UPDATE_INDEX_NAME': provider_data_table.provider_date_of_update_index_name, + 'SSN_TABLE_NAME': ssn_table.table_name, + 'SSN_INVERTED_INDEX_NAME': ssn_table.inverted_index_name, **stack.common_env_vars, } @@ -44,6 +47,7 @@ def __init__( method_options=method_options, data_encryption_key=data_encryption_key, provider_data_table=provider_data_table, + ssn_table=ssn_table, lambda_environment=lambda_environment, ) self._add_get_provider( @@ -88,6 +92,7 @@ def _add_query_providers( method_options: MethodOptions, data_encryption_key: IKey, provider_data_table: ProviderTable, + ssn_table: SSNTable, lambda_environment: dict, ): query_resource = self.resource.add_resource('query') @@ -95,6 +100,7 @@ def _add_query_providers( handler = self._query_providers_handler( data_encryption_key=data_encryption_key, provider_data_table=provider_data_table, + ssn_table=ssn_table, lambda_environment=lambda_environment, ) self.api.log_groups.append(handler.log_group) @@ -153,13 +159,14 @@ def _query_providers_handler( self, data_encryption_key: IKey, provider_data_table: ProviderTable, + ssn_table: SSNTable, lambda_environment: dict, ) -> PythonFunction: - stack = Stack.of(self.api) handler = PythonFunction( self.resource, 'QueryProvidersHandler', description='Query providers handler', + role=ssn_table.api_query_role, lambda_dir='provider-data-v1', index=os.path.join('handlers', 'providers.py'), handler='query_providers', @@ -170,11 +177,16 @@ def _query_providers_handler( provider_data_table.grant_read_data(handler) NagSuppressions.add_resource_suppressions_by_path( - stack, - path=f'{handler.node.path}/ServiceRole/DefaultPolicy/Resource', + 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.', }, diff --git a/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py b/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py index 093b64049..10b006209 100644 --- a/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py +++ b/backend/compact-connect/stacks/api_stack/v1_api/staff_users.py @@ -51,10 +51,10 @@ def __init__( self._add_get_users(self.admin_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) self._add_post_user(self.admin_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) - user_id_resource = self.admin_resource.add_resource('{userId}') + self.user_id_resource = self.admin_resource.add_resource('{userId}') # /{userId} - self._add_get_user(user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) - self._add_patch_user(user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) + self._add_get_user(self.user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) + self._add_patch_user(self.user_id_resource, admin_scopes, env_vars=env_vars, persistent_stack=persistent_stack) self.me_resource = self_resource.add_resource('me') # /me @@ -301,7 +301,7 @@ def _add_patch_user( env_vars: dict, persistent_stack: ps.PersistentStack, ): - patch_user_handler = self._patch_user_handler( + self.patch_user_handler = self._patch_user_handler( env_vars=env_vars, data_encryption_key=persistent_stack.shared_encryption_key, users_table=persistent_stack.staff_users.user_table, @@ -310,7 +310,7 @@ def _add_patch_user( # Add the PATCH method to the me_resource user_resource.add_method( 'PATCH', - integration=LambdaIntegration(patch_user_handler), + integration=LambdaIntegration(self.patch_user_handler), request_validator=self.api.parameter_body_validator, request_models={'application/json': self.api_model.patch_staff_user_model}, method_responses=[ @@ -359,7 +359,7 @@ def _add_post_user( env_vars: dict, persistent_stack: ps.PersistentStack, ): - post_user_handler = self._post_user_handler( + self.post_user_handler = self._post_user_handler( env_vars=env_vars, data_encryption_key=persistent_stack.shared_encryption_key, users_table=persistent_stack.staff_users.user_table, @@ -369,7 +369,7 @@ def _add_post_user( # Add the POST method to the me_resource users_resource.add_method( 'POST', - integration=LambdaIntegration(post_user_handler), + integration=LambdaIntegration(self.post_user_handler), request_validator=self.api.parameter_body_validator, request_models={'application/json': self.api_model.post_staff_user_model}, method_responses=[ diff --git a/backend/compact-connect/stacks/ingest_stack.py b/backend/compact-connect/stacks/ingest_stack.py index 9f9dc1314..ac73f4676 100644 --- a/backend/compact-connect/stacks/ingest_stack.py +++ b/backend/compact-connect/stacks/ingest_stack.py @@ -3,14 +3,14 @@ import os from aws_cdk import Duration -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +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 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.stack import AppStack +from common_constructs.stack import AppStack, Stack from constructs import Construct from stacks import persistent_stack as ps @@ -29,18 +29,21 @@ def _add_v1_ingest_chain(self, persistent_stack: ps.PersistentStack): lambda_dir='provider-data-v1', index=os.path.join('handlers', 'ingest.py'), handler='ingest_license_message', + role=persistent_stack.ssn_table.ingest_role, timeout=Duration.minutes(1), environment={ 'EVENT_BUS_NAME': persistent_stack.data_event_bus.event_bus_name, 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'SSN_TABLE_NAME': persistent_stack.ssn_table.table_name, + 'SSN_INVERTED_INDEX_NAME': persistent_stack.ssn_table.inverted_index_name, **self.common_env_vars, }, alarm_topic=persistent_stack.alarm_topic, ) persistent_stack.provider_table.grant_read_write_data(ingest_handler) NagSuppressions.add_resource_suppressions_by_path( - self, - f'{ingest_handler.node.path}/ServiceRole/DefaultPolicy/Resource', + Stack.of(ingest_handler.role), + f'{ingest_handler.role.node.path}/DefaultPolicy/Resource', suppressions=[ { 'id': 'AwsSolutions-IAM5', @@ -73,14 +76,37 @@ def _add_v1_ingest_chain(self, persistent_stack: ps.PersistentStack): max_batching_window=Duration.minutes(5), max_receive_count=3, batch_size=50, - encryption_key=persistent_stack.shared_encryption_key, + # Note that we're using the ssn key here, which has a much more restrictive policy. + # The messages on this queue include SSN, so we want it just as locked down as our + # permanent storage of SSN data. + encryption_key=persistent_stack.ssn_table.key, alarm_topic=persistent_stack.alarm_topic, ) - Rule( + ingest_rule = Rule( self, 'V1IngestEventRule', event_bus=persistent_stack.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': persistent_stack.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/compact-connect/stacks/persistent_stack/__init__.py b/backend/compact-connect/stacks/persistent_stack/__init__.py index 22a9862a9..350162f09 100644 --- a/backend/compact-connect/stacks/persistent_stack/__init__.py +++ b/backend/compact-connect/stacks/persistent_stack/__init__.py @@ -1,12 +1,15 @@ import os -from aws_cdk import RemovalPolicy, aws_ssm +from aws_cdk import Duration, RemovalPolicy, aws_ssm 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_lambda_python_alpha import PythonLayerVersion +from cdk_nag import NagSuppressions from common_constructs.access_logs_bucket import AccessLogsBucket from common_constructs.alarm_topic import AlarmTopic +from common_constructs.nodejs_function import NodejsFunction from common_constructs.python_function import COMMON_PYTHON_LAMBDA_LAYER_SSM_PARAMETER_NAME from common_constructs.security_profile import SecurityProfile from common_constructs.stack import AppStack @@ -17,11 +20,12 @@ 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.license_table import LicenseTable from stacks.persistent_stack.provider_table import ProviderTable from stacks.persistent_stack.provider_users import ProviderUsers from stacks.persistent_stack.provider_users_bucket import ProviderUsersBucket +from stacks.persistent_stack.ssn_table import SSNTable from stacks.persistent_stack.staff_users import StaffUsers +from stacks.persistent_stack.transaction_history_table import TransactionHistoryTable from stacks.persistent_stack.user_email_notifications import UserEmailNotifications @@ -97,11 +101,6 @@ def __init__( self.data_event_bus = EventBus(self, 'DataEventBus') - # Both of these are slated for deprecation/deletion soon, so we'll mark included resources for removal - self._add_mock_data_resources() - self._add_deprecated_data_resources() - - # The new data resources self._add_data_resources(removal_policy=removal_policy) self.compact_configuration_upload = CompactConfigurationUpload( @@ -130,6 +129,8 @@ def __init__( # if domain name is not provided, use the default cognito email settings user_pool_email_settings = UserPoolEmail.with_cognito() + self._create_email_notification_service(environment_name) + security_profile = SecurityProfile[environment_context.get('security_profile', 'RECOMMENDED')] staff_prefix = f'{app_name}-staff' @@ -169,42 +170,6 @@ def __init__( self.provider_users.node.add_dependency(self.user_email_notifications.email_identity) self.provider_users.node.add_dependency(self.user_email_notifications.dmarc_record) - def _add_mock_data_resources(self): - self.mock_bulk_uploads_bucket = BulkUploadsBucket( - self, - 'MockBulkUploadsBucket', - mock_bucket=True, - access_logs_bucket=self.access_logs_bucket, - encryption_key=self.shared_encryption_key, - removal_policy=RemovalPolicy.DESTROY, - auto_delete_objects=True, - event_bus=self.data_event_bus, - ) - - # These dummy exports are required until we remove dependencies from the api stack - # see https://github.com/aws/aws-cdk/issues/3414 - self.export_value(self.mock_bulk_uploads_bucket.bucket_name) - self.export_value(self.mock_bulk_uploads_bucket.bucket_arn) - - self.mock_license_table = LicenseTable( - self, 'MockLicenseTable', encryption_key=self.shared_encryption_key, removal_policy=RemovalPolicy.DESTROY - ) - - # These dummy exports are required until we remove dependencies from the api stack - # see https://github.com/aws/aws-cdk/issues/3414 - self.export_value(self.mock_license_table.table_name) - self.export_value(self.mock_license_table.table_arn) - - def _add_deprecated_data_resources(self): - self.license_table = LicenseTable( - self, 'LicenseTable', encryption_key=self.shared_encryption_key, removal_policy=RemovalPolicy.DESTROY - ) - - # These dummy exports are required until we remove dependencies from the api stack - # see https://github.com/aws/aws-cdk/issues/3414 - self.export_value(self.license_table.table_name) - self.export_value(self.license_table.table_arn) - def _add_data_resources(self, removal_policy: RemovalPolicy): self.bulk_uploads_bucket = BulkUploadsBucket( self, @@ -220,9 +185,11 @@ def _add_data_resources(self, removal_policy: RemovalPolicy): self, 'ProviderTable', encryption_key=self.shared_encryption_key, removal_policy=removal_policy ) + self.ssn_table = SSNTable(self, 'SSNTable', removal_policy=removal_policy) + self.data_event_table = DataEventTable( - self, - 'DataEventTable', + scope=self, + construct_id='DataEventTable', encryption_key=self.shared_encryption_key, event_bus=self.data_event_bus, alarm_topic=self.alarm_topic, @@ -230,7 +197,17 @@ def _add_data_resources(self, removal_policy: RemovalPolicy): ) self.compact_configuration_table = CompactConfigurationTable( - self, 'CompactConfigurationTable', encryption_key=self.shared_encryption_key, removal_policy=removal_policy + scope=self, + construct_id='CompactConfigurationTable', + encryption_key=self.shared_encryption_key, + removal_policy=removal_policy, + ) + + self.transaction_history_table = TransactionHistoryTable( + scope=self, + construct_id='TransactionHistoryTable', + encryption_key=self.shared_encryption_key, + removal_policy=removal_policy, ) # bucket for holding documentation for providers @@ -242,3 +219,109 @@ def _add_data_resources(self, removal_policy: RemovalPolicy): provider_table=self.provider_table, removal_policy=removal_policy, ) + + def _create_email_notification_service(self, environment_name: str) -> 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(), + 'ENVIRONMENT_NAME': environment_name, + **self.common_env_vars, + }, + ) + + # 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.node.path}/ServiceRole/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': 'Compact Connect', + } + }, + ) + ) + + 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/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py b/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py index 0dae0b499..e40db18b3 100644 --- a/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py +++ b/backend/compact-connect/stacks/persistent_stack/bulk_uploads_bucket.py @@ -27,7 +27,6 @@ def __init__( *, access_logs_bucket: AccessLogsBucket, encryption_key: IKey, - mock_bucket: bool = False, event_bus: EventBus, **kwargs, ): @@ -49,8 +48,7 @@ def __init__( ) self.log_groups = [] - if not mock_bucket: - self._add_v1_ingest_object_events(event_bus) + self._add_v1_ingest_object_events(event_bus) QueryDefinition( self, @@ -132,7 +130,7 @@ def _add_v1_ingest_object_events(self, event_bus: EventBus): suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': """ The lambda policy is scoped specifically to the PutBucketNotification action, which suits its purpose. @@ -146,7 +144,7 @@ def _add_v1_ingest_object_events(self, event_bus: EventBus): suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', ], 'reason': 'The BasicExecutionRole policy is appropriate for this lambda', 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 b44ebcb13..0656fb110 100644 --- a/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py +++ b/backend/compact-connect/stacks/persistent_stack/compact_configuration_upload.py @@ -106,7 +106,9 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', # noqa: E501 line-too-long + 'appliesTo': [ + 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' + ], # noqa: E501 line-too-long 'reason': 'This policy is appropriate for the log retention lambda', }, ], 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 0e8987bdd..13ecd2d52 100644 --- a/backend/compact-connect/stacks/persistent_stack/data_event_table.py +++ b/backend/compact-connect/stacks/persistent_stack/data_event_table.py @@ -3,7 +3,7 @@ import os from aws_cdk import Duration, RemovalPolicy -from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +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, Table, TableEncryption from aws_cdk.aws_events import EventPattern, IEventBus, Match, Rule @@ -98,7 +98,7 @@ def __init__( alarm_topic=alarm_topic, ) - Rule( + event_receiver_rule = Rule( self, 'EventReceiverRule', event_bus=event_bus, @@ -107,6 +107,27 @@ def __init__( 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': stack.data_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)) + NagSuppressions.add_resource_suppressions( self, suppressions=[ diff --git a/backend/compact-connect/stacks/persistent_stack/provider_users.py b/backend/compact-connect/stacks/persistent_stack/provider_users.py index eef3444a5..7ddc5d618 100644 --- a/backend/compact-connect/stacks/persistent_stack/provider_users.py +++ b/backend/compact-connect/stacks/persistent_stack/provider_users.py @@ -46,23 +46,10 @@ def __init__( ) stack: ps.PersistentStack = ps.PersistentStack.of(self) - callback_urls = [] - if stack.ui_domain_name is not None: - callback_urls.append(f'https://{stack.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." - ) - # Create an app client to allow the front-end to authenticate. self.ui_client = self.add_ui_client( - callback_urls=callback_urls, + ui_domain_name=stack.ui_domain_name, + environment_context=environment_context, # For now, we are allowing the user to read and update their email, given name, and family name. # we only allow the user to be able to see their providerId and compact, which are custom attributes. # If we ever want other attributes to be read or written, they must be added here. 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 68f255d65..17b896039 100644 --- a/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py +++ b/backend/compact-connect/stacks/persistent_stack/provider_users_bucket.py @@ -131,7 +131,7 @@ def _add_v1_object_events(self, provider_table: Table, encryption_key: IKey): suppressions=[ { 'id': 'AwsSolutions-IAM5', - 'applies_to': ['Resource::*'], + 'appliesTo': ['Resource::*'], 'reason': """ The lambda policy is scoped specifically to the PutBucketNotification action, which suits its purpose. @@ -145,7 +145,7 @@ def _add_v1_object_events(self, provider_table: Table, encryption_key: IKey): suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', ], 'reason': 'The BasicExecutionRole policy is appropriate for this lambda', diff --git a/backend/compact-connect/stacks/persistent_stack/ssn_table.py b/backend/compact-connect/stacks/persistent_stack/ssn_table.py new file mode 100644 index 000000000..c8348374f --- /dev/null +++ b/backend/compact-connect/stacks/persistent_stack/ssn_table.py @@ -0,0 +1,172 @@ +from aws_cdk import RemovalPolicy +from aws_cdk.aws_dynamodb import Attribute, AttributeType, BillingMode, ProjectionType, Table, TableEncryption +from aws_cdk.aws_iam import ( + Effect, + ManagedPolicy, + PolicyDocument, + PolicyStatement, + Role, + ServicePrincipal, + StarPrincipal, +) +from aws_cdk.aws_kms import Key +from cdk_nag import NagSuppressions +from common_constructs.stack import Stack +from constructs import Construct + + +class SSNTable(Table): + """DynamoDB table to house provider Social Security Numbers""" + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + removal_policy: RemovalPolicy, + **kwargs, + ): + self.key = Key( + scope, + 'SSNKey', + enable_key_rotation=True, + alias='ssn-key', + removal_policy=removal_policy, + ) + + 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-DataEventsLog', + encryption=TableEncryption.CUSTOMER_MANAGED, + encryption_key=self.key, + billing_mode=BillingMode.PAY_PER_REQUEST, + removal_policy=removal_policy, + point_in_time_recovery=True, + 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 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:Query', + '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', + } + }, + ) + ] + ), + **kwargs, + ) + + # We create a GSI here in anticipation of a future change, where we will need to facilitate a lookup + # of provider_id -> ssn, in addition to our current ssn -> provider_id pattern. + self.inverted_index_name = 'inverted' + self.add_global_secondary_index( + index_name=self.inverted_index_name, + partition_key=Attribute(name='sk', type=AttributeType.STRING), + sort_key=Attribute(name='pk', type=AttributeType.STRING), + projection_type=ProjectionType.ALL, + ) + + NagSuppressions.add_resource_suppressions( + self, + suppressions=[ + { + 'id': 'HIPAA.Security-DynamoDBInBackupPlan', + 'reason': 'We will implement data back-ups after we better understand regulatory data deletion' + ' requirements', + }, + ], + ) + + self._configure_access() + + 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) + + # This role is to be removed, once full SSN access is removed from the /query API endpoint + # (https://github.com/csg-org/CompactConnect/issues/391). In the meantime, we will need to have a role the + # corresponding lambda can use. + self.api_query_role = Role( + self, + 'ProviderQueryRole', + assumed_by=ServicePrincipal('lambda.amazonaws.com'), + description='Dedicated role for API provider queries, with access to full SSNs', + managed_policies=[ManagedPolicy.from_aws_managed_policy_name('service-role/AWSLambdaBasicExecutionRole')], + ) + self.grant_read_data(self.api_query_role) + self._role_suppressions(self.api_query_role) + + # 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 + 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': [self.ingest_role.role_arn, self.api_query_role.role_arn], + 'aws:PrincipalServiceName': ['dynamodb.amazonaws.com', 'events.amazonaws.com'], + } + }, + ) + ) + self.key.grant_decrypt(self.api_query_role) + self.key.grant_encrypt_decrypt(self.ingest_role) + + 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/compact-connect/stacks/persistent_stack/staff_users.py b/backend/compact-connect/stacks/persistent_stack/staff_users.py index d83671b7b..a53036990 100644 --- a/backend/compact-connect/stacks/persistent_stack/staff_users.py +++ b/backend/compact-connect/stacks/persistent_stack/staff_users.py @@ -57,24 +57,11 @@ def __init__( self._add_resource_servers() self._add_scope_customization(persistent_stack=stack) - callback_urls = [] - if stack.ui_domain_name is not None: - callback_urls.append(f'https://{stack.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.", - ) - # 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( - callback_urls=callback_urls, + 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. @@ -83,11 +70,11 @@ def __init__( def _add_resource_servers(self): """Add scopes for all compact/jurisdictions""" - # {compact}.write, {compact}.admin, {compact}.read for every compact - # Note: the .write and .admin scopes will control access to API endpoints via the Cognito authorizer, however - # there will be a secondary level of authorization within the business logic that controls further granularity - # of authorization (i.e. 'aslp/write' will grant access to POST license data, but the business logic inside - # the endpoint also expects an 'aslp/co.write' if the POST includes data for Colorado.) + # {compact}.write, {compact}.admin, {compact}.readGeneral for every compact + # Note: the .readGeneral .write and .admin scopes will control access to API endpoints via the Cognito + # authorizer, however there will be a secondary level of authorization within the business logic that controls + # further granularity of authorization (i.e. 'aslp/write' will grant access to POST license data, but the + # business logic inside the endpoint also expects an 'aslp/co.write' if the POST includes data for Colorado.) self.write_scope = ResourceServerScope( scope_name='write', scope_description='Write access for the compact, paired with a more specific scope', @@ -96,7 +83,10 @@ def _add_resource_servers(self): scope_name='admin', scope_description='Admin access for the compact, paired with a more specific scope', ) - self.read_scope = ResourceServerScope(scope_name='read', scope_description='Read access for the compact') + self.read_scope = ResourceServerScope( + scope_name='readGeneral', + scope_description='Read access for generally available data (not private) in the compact', + ) # One resource server for each compact self.resource_servers = { diff --git a/backend/compact-connect/stacks/persistent_stack/license_table.py b/backend/compact-connect/stacks/persistent_stack/transaction_history_table.py similarity index 51% rename from backend/compact-connect/stacks/persistent_stack/license_table.py rename to backend/compact-connect/stacks/persistent_stack/transaction_history_table.py index ce1bd32e7..d743f4477 100644 --- a/backend/compact-connect/stacks/persistent_stack/license_table.py +++ b/backend/compact-connect/stacks/persistent_stack/transaction_history_table.py @@ -1,12 +1,12 @@ from aws_cdk import RemovalPolicy -from aws_cdk.aws_dynamodb import Attribute, AttributeType, BillingMode, ProjectionType, Table, TableEncryption +from aws_cdk.aws_dynamodb import Attribute, AttributeType, BillingMode, Table, TableEncryption from aws_cdk.aws_kms import IKey from cdk_nag import NagSuppressions from constructs import Construct -class LicenseTable(Table): - """DynamoDB table to house license data""" +class TransactionHistoryTable(Table): + """DynamoDB table to house transaction history data""" def __init__( self, @@ -29,28 +29,6 @@ def __init__( sort_key=Attribute(name='sk', type=AttributeType.STRING), **kwargs, ) - self.cj_name_index_name = 'cj_name' - self.cj_updated_index_name = 'cj_updated' - self.ssn_index_name = 'ssn' - - self.add_global_secondary_index( - index_name=self.cj_name_index_name, - partition_key=Attribute(name='compactJur', type=AttributeType.STRING), - sort_key=Attribute(name='famGivMid', type=AttributeType.STRING), - projection_type=ProjectionType.ALL, - ) - self.add_global_secondary_index( - index_name=self.cj_updated_index_name, - partition_key=Attribute(name='compactJur', type=AttributeType.STRING), - sort_key=Attribute(name='dateOfUpdate', type=AttributeType.STRING), - projection_type=ProjectionType.ALL, - ) - self.add_global_secondary_index( - index_name=self.ssn_index_name, - partition_key=Attribute(name='ssn', type=AttributeType.STRING), - sort_key=Attribute(name='licenseHomeProviderId', type=AttributeType.STRING), - projection_type=ProjectionType.KEYS_ONLY, - ) NagSuppressions.add_resource_suppressions( self, suppressions=[ diff --git a/backend/compact-connect/stacks/reporting_stack.py b/backend/compact-connect/stacks/reporting_stack.py index 0a9053836..f020bf9fe 100644 --- a/backend/compact-connect/stacks/reporting_stack.py +++ b/backend/compact-connect/stacks/reporting_stack.py @@ -5,7 +5,6 @@ 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_logs import QueryDefinition, QueryString from cdk_nag import NagSuppressions from common_constructs.nodejs_function import NodejsFunction @@ -23,11 +22,7 @@ def __init__(self, scope: Construct, construct_id: str, *, persistent_stack: ps. 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 - if self.ui_domain_name is not None: - ui_base_path_url = f'https://{self.ui_domain_name}' - else: - # default to csg test environment - ui_base_path_url = 'https://app.test.compactconnect.org' + 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 @@ -50,50 +45,7 @@ def _add_ingest_event_reporting_chain(self, persistent_stack: ps.PersistentStack ) persistent_stack.data_event_table.grant_read_data(event_collector) persistent_stack.compact_configuration_table.grant_read_data(event_collector) - - ses_resources = [ - persistent_stack.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=persistent_stack.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='*', - ), - ) - - event_collector.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': from_address, - 'ses:FromDisplayName': 'Compact Connect', - } - }, - ) - ) + persistent_stack.setup_ses_permissions_for_lambda(event_collector) NagSuppressions.add_resource_suppressions_by_path( self, @@ -163,3 +115,11 @@ def _add_ingest_event_reporting_chain(self, persistent_stack: ps.PersistentStack ), 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/compact-connect/stacks/transaction_monitoring_stack/__init__.py b/backend/compact-connect/stacks/transaction_monitoring_stack/__init__.py new file mode 100644 index 000000000..58f7cd786 --- /dev/null +++ b/backend/compact-connect/stacks/transaction_monitoring_stack/__init__.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import json + +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import persistent_stack as ps + +from .transaction_history_processing_workflow import TransactionHistoryProcessingWorkflow + + +class TransactionMonitoringStack(AppStack): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + + self.compact_state_machines = {} + # we create a state machine for each compact in order to keep permissions separate + # and to prevent errors with one compact's transaction account impacting others. + for compact in json.loads(self.common_env_vars['COMPACTS']): + self.compact_state_machines[compact] = TransactionHistoryProcessingWorkflow( + self, + f'{compact}-TransactionHistoryProcessingWorkflow', + compact=compact, + environment_name=environment_name, + persistent_stack=persistent_stack, + ) 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 new file mode 100644 index 000000000..4eb4cb3d1 --- /dev/null +++ b/backend/compact-connect/stacks/transaction_monitoring_stack/transaction_history_processing_workflow.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import os + +from aws_cdk import ArnFormat, Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, TreatMissingData # noqa: F401 temporarily unused +from aws_cdk.aws_cloudwatch_actions import SnsAction # noqa: F401 temporarily unused +from aws_cdk.aws_events import Rule, Schedule +from aws_cdk.aws_events_targets import SfnStateMachine +from aws_cdk.aws_iam import Effect, PolicyStatement +from aws_cdk.aws_logs import LogGroup, RetentionDays +from aws_cdk.aws_stepfunctions import ( + Choice, + Condition, + DefinitionBody, + Fail, + JsonPath, + LogLevel, + LogOptions, + Pass, + Result, + StateMachine, + Succeed, + TaskInput, + Timeout, +) +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 stacks import persistent_stack as ps + + +class TransactionHistoryProcessingWorkflow(Construct): + def __init__( + self, + scope: Construct, + construct_id: str, + *, + compact: str, + environment_name: str, + persistent_stack: ps.PersistentStack, + **kwargs, + ): + super().__init__(scope, construct_id, **kwargs) + stack = Stack.of(self) + self.transaction_processor_handler = PythonFunction( + self, + f'{compact}-TransactionHistoryProcessor', + description=f'Processes transaction history records for {compact} compact', + lambda_dir='purchases', + index=os.path.join('handlers', 'transaction_history.py'), + handler='process_settled_transactions', + timeout=Duration.minutes(15), + environment={ + 'TRANSACTION_HISTORY_TABLE_NAME': persistent_stack.transaction_history_table.table_name, + 'ENVIRONMENT_NAME': environment_name, + **stack.common_env_vars, + }, + alarm_topic=persistent_stack.alarm_topic, + # required as this lambda is bundled with the authorize.net SDK which is large + memory_size=512, + ) + persistent_stack.transaction_history_table.grant_write_data(self.transaction_processor_handler) + persistent_stack.shared_encryption_key.grant_encrypt(self.transaction_processor_handler) + # grant access to the compact specific secrets manager secrets following this namespace pattern + # compact-connect/env/{environment_name}/compact/{compact_name}/credentials/payment-processor + self.transaction_processor_handler.add_to_role_policy( + PolicyStatement( + effect=Effect.ALLOW, + actions=[ + 'secretsmanager:GetSecretValue', + ], + resources=[ + self._get_secrets_manager_compact_payment_processor_arn_for_compact( + compact=compact, environment_name=environment_name + ) + ], + ) + ) + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{self.transaction_processor_handler.node.path}/ServiceRole/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 needs access to. + """, + }, + ], + ) + + # Create Step Function definition + # set initial values for the process task to use + self.initialize_state = Pass( + self, + f'{compact}-InitializeState', + result=Result.from_object({'Payload': {'compact': compact, 'processedBatchIds': []}}), + ) + + self.processor_task = LambdaInvoke( + self, + f'{compact}-ProcessTransactionHistory', + lambda_function=self.transaction_processor_handler, + input_path=JsonPath.string_at('$.Payload'), + result_path='$', + task_timeout=Timeout.duration(Duration.minutes(15)), + ) + self.check_status = Choice(self, f'{compact}-CheckProcessingStatus') + + self.email_notification_service_invoke_step = LambdaInvoke( + self, + f'{compact}-BatchFailureNotification', + lambda_function=persistent_stack.email_notification_service_lambda, + payload=TaskInput.from_object( + { + 'compact': compact, + 'template': 'transactionBatchSettlementFailure', + 'recipientType': 'COMPACT_OPERATIONS_TEAM', + } + ), + result_path='$.notificationResult', + task_timeout=Timeout.duration(Duration.minutes(15)), + ) + + success = Succeed(self, f'{compact}-ProcessingComplete') + fail = Fail(self, f'{compact}-ProcessingFailed', cause='Transaction processing failed') + + # Here we define the chaining between the steps in the state machine + self.initialize_state.next(self.processor_task) + self.processor_task.next(self.check_status) + self.check_status.when(Condition.string_equals('$.Payload.status', 'COMPLETE'), success) + self.check_status.when(Condition.string_equals('$.Payload.status', 'IN_PROGRESS'), self.processor_task) + self.check_status.when( + Condition.string_equals('$.Payload.status', 'BATCH_FAILURE'), + self.email_notification_service_invoke_step, + ) + self.check_status.otherwise(fail) + + # after the email has been sent, we end in a success state even though the batch failed, + # since that is the result of the external system and not a failure of the state machine + self.email_notification_service_invoke_step.next(success) + + state_machine_log_group = LogGroup( + self, + f'{compact}-TransactionHistoryStateMachineLogs', + retention=RetentionDays.ONE_MONTH, + ) + NagSuppressions.add_resource_suppressions( + state_machine_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.', + } + ], + ) + + # Create the state machine + state_machine = StateMachine( + self, + f'{compact}-TransactionHistoryStateMachine', + definition_body=DefinitionBody.from_chainable(self.initialize_state), + timeout=Duration.hours(2), + logs=LogOptions( + destination=state_machine_log_group, + level=LogLevel.ALL, + include_execution_data=True, + ), + tracing_enabled=True, + ) + self.transaction_processor_handler.grant_invoke(state_machine) + persistent_stack.shared_encryption_key.grant_encrypt_decrypt(state_machine) + + NagSuppressions.add_resource_suppressions_by_path( + stack=stack, + path=f'{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 + actions, KMS key, and Lambda function that this state machine needs access to. + """, + }, + ], + ) + + # Create EventBridge rule to trigger state machine daily at noon UTC-4 + Rule( + self, + f'{compact}-DailyTransactionProcessingRule', + schedule=Schedule.cron(week_day='*', hour='16', minute='0', month='*', year='*'), + targets=[SfnStateMachine(state_machine)], + ) + + # Create alarm for failed step function executions + alarm = Alarm( + self, + f'{compact}-StateMachineExecutionFailedAlarm', + metric=state_machine.metric_failed(), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{state_machine.node.path} failed to collect transactions', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ) + # TODO: we have been asked to disable this until all compacts have valid authorize.net # noqa: FIX002 + # accounts put into place to avoid unnecessary alerting. Once the system is ready to + # go live, this alarm action should be re-enabled. + # .add_alarm_action(SnsAction(persistent_stack.alarm_topic))) + NagSuppressions.add_resource_suppressions( + alarm, + suppressions=[ + { + 'id': 'HIPAA.Security-CloudWatchAlarmAction', + 'reason': 'This alarm is temporary silenced until all compacts have valid authorize.net accounts', + } + ], + ) + + def _get_secrets_manager_compact_payment_processor_arn_for_compact( + self, compact: str, environment_name: str + ) -> str: + """ + Generate the secret arn for the payment processor credentials. + The secret arn follows this pattern: + compact-connect/env/{environment_name}/compact/{compact_name}/credentials/payment-processor + + This is used to scope the permissions granted to the lambda specifically for the secret it needs to access. + """ + stack = Stack.of(self) + return stack.format_arn( + service='secretsmanager', + arn_format=ArnFormat.COLON_RESOURCE_NAME, + resource='secret', + resource_name=( + # add wildcard characters to account for 6-character + # random version suffix appended to secret name by secrets manager + f'compact-connect/env/{environment_name}/compact/{compact}/credentials/payment-processor-??????' + ), + ) diff --git a/backend/compact-connect/stacks/ui_stack/distribution.py b/backend/compact-connect/stacks/ui_stack/distribution.py index def326097..8e6333b86 100644 --- a/backend/compact-connect/stacks/ui_stack/distribution.py +++ b/backend/compact-connect/stacks/ui_stack/distribution.py @@ -84,7 +84,7 @@ def __init__( suppressions=[ { 'id': 'AwsSolutions-IAM4', - 'applies_to': [ + 'appliesTo': [ 'Policy::arn::iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' ], 'reason': 'This policy enables CloudWatch logging and is appropriate for this lambda', diff --git a/backend/compact-connect/tests/app/base.py b/backend/compact-connect/tests/app/base.py index 44632eec1..c1b45ff04 100644 --- a/backend/compact-connect/tests/app/base.py +++ b/backend/compact-connect/tests/app/base.py @@ -12,6 +12,7 @@ from aws_cdk.aws_cognito import CfnUserPool, CfnUserPoolClient 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_s3 import CfnBucket from aws_cdk.aws_sqs import CfnQueue @@ -144,6 +145,72 @@ def _inspect_persistent_stack( provider_users_user_pool_app_client['WriteAttributes'], ['email', 'family_name', 'given_name'] ) self._inspect_data_events_table(persistent_stack, persistent_stack_template) + self._inspect_ssn_table(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 + ) + api_query_role_logical_id = persistent_stack.get_logical_id( + persistent_stack.ssn_table.api_query_role.node.default_child + ) + ssn_table_template = self.get_resource_properties_by_logical_id( + persistent_stack.get_logical_id(persistent_stack.ssn_table.node.default_child), + persistent_stack_template.find_resources(CfnTable.CFN_RESOURCE_TYPE_NAME), + ) + ssn_key_template = self.get_resource_properties_by_logical_id( + ssn_key_logical_id, persistent_stack_template.find_resources(CfnKey.CFN_RESOURCE_TYPE_NAME) + ) + # This naming convention is important for opting into future CloudTrail organization access logging + self.assertTrue(ssn_table_template['TableName'].endswith('-DataEventsLog')) + # Ensure our SSN Key is locked down by resource policy + self.assertEqual( + ssn_key_template['KeyPolicy'], + { + 'Statement': [ + { + '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': [ + {'Fn::GetAtt': [ingest_role_logical_id, 'Arn']}, + {'Fn::GetAtt': [api_query_role_logical_id, 'Arn']}, + ], + 'aws:PrincipalServiceName': ['dynamodb.amazonaws.com', 'events.amazonaws.com'], + } + }, + 'Effect': 'Deny', + 'Principal': '*', + 'Resource': '*', + }, + { + 'Action': ['kms:Decrypt', 'kms:Encrypt', 'kms:GenerateDataKey*', 'kms:ReEncrypt*'], + 'Condition': {'StringEquals': {'aws:SourceAccount': persistent_stack.account}}, + 'Effect': 'Allow', + 'Principal': {'Service': 'events.amazonaws.com'}, + 'Resource': '*', + }, + ], + 'Version': '2012-10-17', + }, + ) + # Ensure we're using our locked down KMS key for encryption + self.assertEqual( + ssn_table_template['SSESpecification'], + {'KMSMasterKeyId': {'Fn::GetAtt': [ssn_key_logical_id, 'Arn']}, 'SSEEnabled': True, 'SSEType': 'KMS'}, + ) + self.compare_snapshot( + ssn_table_template['ResourcePolicy']['PolicyDocument'], + 'SSN_TABLE_RESOURCE_POLICY', + overwrite_snapshot=False, + ) def _inspect_data_events_table(self, persistent_stack: PersistentStack, persistent_stack_template: Template): # Ensure our DataEventTable and queues are created @@ -238,6 +305,7 @@ def _check_no_stage_annotations(self, stage: BackendStage): self._check_no_stack_annotations(stage.ui_stack) self._check_no_stack_annotations(stage.api_stack) self._check_no_stack_annotations(stage.ingest_stack) + self._check_no_stack_annotations(stage.transaction_monitoring_stack) # There is on reporting stack if no hosted zone is configured if stage.persistent_stack.hosted_zone: self._check_no_stack_annotations(stage.reporting_stack) diff --git a/backend/compact-connect/tests/app/test_api/test_attestations_api.py b/backend/compact-connect/tests/app/test_api/test_attestations_api.py new file mode 100644 index 000000000..5914f7b64 --- /dev/null +++ b/backend/compact-connect/tests/app/test_api/test_attestations_api.py @@ -0,0 +1,99 @@ +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 TestAttestationsApi(TestApi): + """ + These tests are focused on checking that the API endpoints for the `/v1/compacts/{compact}/attestations` path + are configured correctly. + + When adding or modifying API resources under /attestations/, 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_attestations_resource(self): + api_stack = self.app.sandbox_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': 'attestations', + }, + ) + + def test_synth_generates_get_attestation_resource(self): + api_stack = self.app.sandbox_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 'attestations' resource + 'Ref': api_stack.get_logical_id(api_stack.api.v1_api.attestations_resource.node.default_child), + }, + 'PathPart': '{attestationId}', + }, + ) + + # Ensure the lambda is created with expected code path + attestation_handler = TestApi.get_resource_properties_by_logical_id( + api_stack.get_logical_id(api_stack.api.v1_api.attestations.attestations_function.node.default_child), + api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual(attestation_handler['Handler'], 'handlers.attestations.attestations') + + # 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', + # the provider users endpoints uses a separate authorizer from the staff endpoints + 'AuthorizerId': { + 'Ref': api_stack.get_logical_id(api_stack.api.provider_users_authorizer.node.default_child), + }, + # ensure the lambda integration is configured with the expected handler + 'Integration': TestApi.generate_expected_integration_object( + api_stack.get_logical_id( + api_stack.api.v1_api.attestations.attestations_function.node.default_child, + ), + ), + 'MethodResponses': [ + { + 'ResponseModels': {'application/json': {'Ref': method_model_logical_id_capture}}, + 'StatusCode': '200', + }, + ], + }, + ) + + # now check the response model matches expected contract + get_attestation_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_attestation_response_model['Schema'], + 'GET_ATTESTATION_BY_ID_RESPONSE_SCHEMA', + overwrite_snapshot=False, + ) diff --git a/backend/compact-connect/tests/app/test_api/test_purchases_api.py b/backend/compact-connect/tests/app/test_api/test_purchases_api.py index db923e024..92589818b 100644 --- a/backend/compact-connect/tests/app/test_api/test_purchases_api.py +++ b/backend/compact-connect/tests/app/test_api/test_purchases_api.py @@ -86,15 +86,13 @@ def test_synth_generates_post_purchases_privileges_handler_with_required_secret_ ) # We need to ensure the lambda can read these secrets, else all transactions will fail + # sort the compact names to ensure the order is consistent + self.context['compacts'].sort() self.assertIn( { 'Action': 'secretsmanager:GetSecretValue', 'Effect': 'Allow', - 'Resource': [ - _generate_expected_secret_arn('aslp'), - _generate_expected_secret_arn('coun'), - _generate_expected_secret_arn('octp'), - ], + 'Resource': [_generate_expected_secret_arn(compact) for compact in self.context['compacts']], }, policy['Properties']['PolicyDocument']['Statement'], ) diff --git a/backend/compact-connect/tests/app/test_api/test_staff_users_api.py b/backend/compact-connect/tests/app/test_api/test_staff_users_api.py new file mode 100644 index 000000000..81eedaf4d --- /dev/null +++ b/backend/compact-connect/tests/app/test_api/test_staff_users_api.py @@ -0,0 +1,194 @@ +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 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_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_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}', + }, + ) + + patch_handler_properties = self.get_resource_properties_by_logical_id( + logical_id=api_stack.get_logical_id(api_stack.api.v1_api.staff_users.patch_user_handler.node.default_child), + resources=api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.users.patch_user', + patch_handler_properties['Handler'], + ) + 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( + api_stack.get_logical_id( + api_stack.api.v1_api.staff_users.patch_user_handler.node.default_child, + ), + ), + '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', + }, + ], + }, + ) + + # 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_stage.api_stack + api_stack_template = Template.from_stack(api_stack) + + post_user_handler_properties = self.get_resource_properties_by_logical_id( + logical_id=api_stack.get_logical_id(api_stack.api.v1_api.staff_users.post_user_handler.node.default_child), + resources=api_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.users.post_user', + post_user_handler_properties['Handler'], + ) + 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( + api_stack.get_logical_id( + api_stack.api.v1_api.staff_users.post_user_handler.node.default_child, + ), + ), + '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, + ) diff --git a/backend/compact-connect/tests/app/test_pipeline.py b/backend/compact-connect/tests/app/test_pipeline.py index f1429e609..a29d877e4 100644 --- a/backend/compact-connect/tests/app/test_pipeline.py +++ b/backend/compact-connect/tests/app/test_pipeline.py @@ -5,7 +5,12 @@ from app import CompactConnectApp from aws_cdk.assertions import Match, Template -from aws_cdk.aws_cognito import CfnUserPool, CfnUserPoolClient, CfnUserPoolRiskConfigurationAttachment +from aws_cdk.aws_cognito import ( + CfnUserPool, + CfnUserPoolClient, + CfnUserPoolResourceServer, + CfnUserPoolRiskConfigurationAttachment, +) from aws_cdk.aws_lambda import CfnFunction, CfnLayerVersion from aws_cdk.aws_ssm import CfnParameter @@ -53,6 +58,29 @@ def test_synth_pipeline(self): for ui_stack in (self.app.pipeline_stack.test_stage.ui_stack, self.app.pipeline_stack.prod_stage.ui_stack): self._inspect_ui_stack(ui_stack) + def test_synth_generates_resource_servers_with_expected_scopes_for_staff_users(self): + persistent_stack = self.app.pipeline_stack.test_stage.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.resource_servers + # We must confirm that these scopes are being explicitly created for each compact + # which are absolutely critical for the system to function as expected. + self.assertEqual(self.context['compacts'], list(resource_servers.keys())) + + for compact, resource_server in resource_servers.items(): + # for this test, we just get the 'aslp' compact resource server + 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 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_cognito_using_recommended_security_in_prod(self): stack = self.app.pipeline_stack.prod_stage.persistent_stack template = Template.from_stack(stack) diff --git a/backend/compact-connect/tests/app/test_transaction_monitoring.py b/backend/compact-connect/tests/app/test_transaction_monitoring.py new file mode 100644 index 000000000..0b650ebe6 --- /dev/null +++ b/backend/compact-connect/tests/app/test_transaction_monitoring.py @@ -0,0 +1,222 @@ +import json +import re +from unittest import TestCase + +from aws_cdk.assertions import Template +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.base import TstAppABC + + +def _generate_expected_secret_arn(compact: str) -> str: + return ( + f'arn:aws:secretsmanager:us-east-1:000011112222:secret:compact-connect/env' + f'/prod/compact/{compact}/credentials/payment-processor-??????' + ) + + +class TestTransactionMonitoring(TstAppABC, TestCase): + """ + These tests verify that the transaction monitoring resources are configured as expected. + + When adding or modifying the transaction monitoring workflow definition, tests should be added to ensure that the + steps are configured correctly. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + + return context + + def remove_dynamic_tokens_numbers(self, definition: dict) -> dict: + """ + Helper function to replace dynamic numbers from the tokens with static placeholders in a definition. + + Here is an example of a state machine definition that contains tokens: + {'Next': 'TranscriberChoiceStep', + 'Parameters': {'MessageBody': {'input.$': '$', 'taskToken.$': '$$.Task.Token'}, + 'QueueUrl': '${Token[TOKEN.317]}'}, + 'Resource': 'arn:${Token[AWS.Partition.8]}:states:::sqs:sendMessage.waitForTaskToken', + 'TimeoutSeconds': 1123200, + 'Type': 'Task'} + + Notice sometimes the token is embedded in a string, and sometimes it is a standalone value. + We need to account for both cases here. + + the dynamic token numbers are removed from the string so the tests are stable. + """ + # this matches the token pattern so we can replace the numbers + token_pattern = re.compile(r'\$\{Token\[([^\]]+)\]\}') + + def replace_tokens(value): + if isinstance(value, dict): + return {k: replace_tokens(v) for k, v in value.items()} + if isinstance(value, list): + return [replace_tokens(v) for v in value] + if isinstance(value, str): + + def strip_numbers(match): + token_content = match.group(1) + parts = token_content.split('.') + stripped_parts = [part for part in parts if not part.isdigit()] + return '${Token[' + '.'.join(stripped_parts) + ']}' + + return token_pattern.sub(strip_numbers, value) + return value + + return replace_tokens(definition) + + def test_workflow_generate_process_transaction_history_lambda_with_permissions(self): + transaction_monitoring_stack = self.app.pipeline_stack.prod_stage.transaction_monitoring_stack + transaction_monitoring_stack_template = Template.from_stack(transaction_monitoring_stack) + + compacts = transaction_monitoring_stack.node.get_context('compacts') + for compact in compacts: + lambda_properties = self.get_resource_properties_by_logical_id( + transaction_monitoring_stack.get_logical_id( + transaction_monitoring_stack.compact_state_machines[ + compact + ].transaction_processor_handler.node.default_child + ), + transaction_monitoring_stack_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual('handlers.transaction_history.process_settled_transactions', lambda_properties['Handler']) + + handler_role_logical_id = transaction_monitoring_stack.get_logical_id( + transaction_monitoring_stack.compact_state_machines[ + compact + ].transaction_processor_handler.role.node.default_child + ) + # get the policy attached to the role using this match + # "Roles": [ + # { + # "Ref": "" + # } + # ] + policy = next( + policy + for policy_logical_id, policy in transaction_monitoring_stack_template.find_resources( + 'AWS::IAM::Policy' + ).items() + if handler_role_logical_id in policy['Properties']['Roles'][0]['Ref'] + ) + + # We need to ensure the lambda can only read the secret for its compact + self.assertIn( + { + 'Action': 'secretsmanager:GetSecretValue', + 'Effect': 'Allow', + 'Resource': _generate_expected_secret_arn(compact), + }, + policy['Properties']['PolicyDocument']['Statement'], + ) + + def test_workflow_generates_expected_process_transaction_history_lambda_invoke_step(self): + aslp_transaction_history_proccessing_workflow = ( + self.app.pipeline_stack.prod_stage.transaction_monitoring_stack.compact_state_machines['aslp'] + ) + + self.assertEqual( + { + 'InputPath': '${Token[Payload]}', + 'Next': 'aslp-CheckProcessingStatus', + 'Parameters': {'FunctionName': '${Token[TOKEN]}', 'Payload.$': '$'}, + 'Resource': 'arn:${Token[AWS.Partition]}:states:::lambda:invoke', + 'ResultPath': '$', + 'Retry': [ + { + 'BackoffRate': 2, + 'ErrorEquals': [ + 'Lambda.ClientExecutionTimeoutException', + 'Lambda.ServiceException', + 'Lambda.AWSLambdaException', + 'Lambda.SdkClientException', + ], + 'IntervalSeconds': 2, + 'MaxAttempts': 6, + } + ], + 'TimeoutSeconds': 900, + 'Type': 'Task', + }, + self.remove_dynamic_tokens_numbers( + aslp_transaction_history_proccessing_workflow.processor_task.to_state_json() + ), + ) + + def test_workflow_generates_expected_choice_step(self): + aslp_transaction_history_proccessing_workflow = ( + self.app.pipeline_stack.prod_stage.transaction_monitoring_stack.compact_state_machines['aslp'] + ) + + self.assertEqual( + { + 'Choices': [ + { + 'Next': 'aslp-ProcessingComplete', + 'StringEquals': 'COMPLETE', + 'Variable': '$.Payload.status', + }, + { + 'Next': 'aslp-ProcessTransactionHistory', + 'StringEquals': 'IN_PROGRESS', + 'Variable': '$.Payload.status', + }, + { + 'Next': 'aslp-BatchFailureNotification', + 'StringEquals': 'BATCH_FAILURE', + 'Variable': '$.Payload.status', + }, + ], + 'Default': 'aslp-ProcessingFailed', + 'Type': 'Choice', + }, + self.remove_dynamic_tokens_numbers( + aslp_transaction_history_proccessing_workflow.check_status.to_state_json() + ), + ) + + def test_workflow_generates_expected_batch_failure_notification_step(self): + aslp_transaction_history_proccessing_workflow = ( + self.app.pipeline_stack.prod_stage.transaction_monitoring_stack.compact_state_machines['aslp'] + ) + + self.assertEqual( + { + 'Next': 'aslp-ProcessingComplete', + 'Parameters': { + 'FunctionName': '${Token[TOKEN]}', + 'Payload': { + 'compact': 'aslp', + 'recipientType': 'COMPACT_OPERATIONS_TEAM', + 'template': 'transactionBatchSettlementFailure', + }, + }, + 'Resource': 'arn:${Token[AWS.Partition]}:states:::lambda:invoke', + 'ResultPath': '$.notificationResult', + 'Retry': [ + { + 'BackoffRate': 2, + 'ErrorEquals': [ + 'Lambda.ClientExecutionTimeoutException', + 'Lambda.ServiceException', + 'Lambda.AWSLambdaException', + 'Lambda.SdkClientException', + ], + 'IntervalSeconds': 2, + 'MaxAttempts': 6, + } + ], + 'TimeoutSeconds': 900, + 'Type': 'Task', + }, + self.remove_dynamic_tokens_numbers( + aslp_transaction_history_proccessing_workflow.email_notification_service_invoke_step.to_state_json() + ), + ) diff --git a/backend/compact-connect/tests/resources/snapshots/COMPACT_CONFIGURATION_UPLOADER_INPUT.json b/backend/compact-connect/tests/resources/snapshots/COMPACT_CONFIGURATION_UPLOADER_INPUT.json index db70391ef..bf8be4c21 100644 --- a/backend/compact-connect/tests/resources/snapshots/COMPACT_CONFIGURATION_UPLOADER_INPUT.json +++ b/backend/compact-connect/tests/resources/snapshots/COMPACT_CONFIGURATION_UPLOADER_INPUT.json @@ -11,6 +11,88 @@ "compactSummaryReportNotificationEmails": [], "activeEnvironments": [ "test" + ], + "attestations": [ + { + "attestationId": "jurisprudence-confirmation", + "displayName": "Jurisprudence Confirmation.", + "description": "For displaying the jurisprudence confirmation.", + "text": "I understand that an attestation is a legally binding statement. I understand that providing false information on this application could result in a loss of my licenses and/or privileges. I acknowledge that the Commission may audit jurisprudence attestations at their discretion.", + "required": true, + "locale": "en" + }, + { + "attestationId": "scope-of-practice-attestation", + "displayName": "Scope of Practice Attestation", + "description": "For displaying the scope of practice attestation.", + "text": "I hereby attest and affirm that I have reviewed, understand, and will abide by this state's scope of practice and all applicable laws and rules when practicing in the state. I understand that the issuance of a Compact Privilege authorizes me to legally practice in the member jurisdiction in accordance with the laws and rules governing practice of my profession in that jurisdiction.\n\nIf I violate the practice act, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years.", + "required": true, + "locale": "en" + }, + { + "attestationId": "personal-information-home-state-attestation", + "displayName": "Personal Information Home State Attestation", + "description": "For declaring that the applicant is a resident of the state they have listed as their home state.", + "text": "I hereby attest and affirm that this is my personal and licensure information and that I am a resident of the state listed on this page.*", + "required": true, + "locale": "en" + }, + { + "attestationId": "personal-information-address-attestation", + "displayName": "Personal Information Address Attestation", + "description": "For declaring that the applicant is a resident of the state they have listed as their home state.", + "text": "I hereby attest and affirm that the address information I have provided herein and is my current address. I further consent to accept service of process at this address. I will notify the Commission of a change in my Home State address or email address via updating personal information records in this system. I understand that I am only eligible for a Compact Privilege if I am a licensee in my Home State as defined by the Compact. If I mislead the Compact Commission about my Home State, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years.*", + "required": true, + "locale": "en" + }, + { + "attestationId": "not-under-investigation-attestation", + "displayName": "Not Under Investigation Attestation", + "description": "For declaring that the applicant is not currently under investigation.", + "text": "I hereby attest and affirm that I am not currently under investigation by any board, agency, department, association, certifying body, or other body.", + "required": true, + "locale": "en" + }, + { + "attestationId": "under-investigation-attestation", + "displayName": "Under Investigation Attestation", + "description": "For declaring that the applicant is currently under investigation.", + "text": "I hereby attest and affirm that I am currently under investigation by any board, agency, department, association, certifying body, or other body. I understand that if any investigation results in a disciplinary action, my Compact Privileges may be revoked.", + "required": true, + "locale": "en" + }, + { + "attestationId": "discipline-no-current-encumbrance-attestation", + "displayName": "No Current Discipline Encumbrance Attestation", + "description": "For declaring that the applicant has no encumbrances on any state license.", + "text": "I hereby attest and affirm that I have no encumbrance (any discipline that restricts my full practice or any unmet condition before returning to a full and unrestricted license, including, but not limited, to probation, supervision, completion of a program, and/or completion of CEs) on ANY state license.", + "required": true, + "locale": "en" + }, + { + "attestationId": "discipline-no-prior-encumbrance-attestation", + "displayName": "No Discipline Encumbrance For Prior Two Yeats Attestation", + "description": "For declaring that the applicant has no encumbrances on any state license within the last two years.", + "text": "I hereby attest and affirm that I have not had any encumbrance on ANY state license within the previous two years from date of this application for a Compact Privilege.", + "required": true, + "locale": "en" + }, + { + "attestationId": "provision-of-true-information-attestation", + "displayName": "Provision of True Information Attestation", + "description": "For declaring that the applicant has provided true information.", + "text": "I hereby attest and affirm that all information contained in this privilege application is true to the best of my knowledge.", + "required": true, + "locale": "en" + }, + { + "attestationId": "military-affiliation-confirmation-attestation", + "displayName": "Military Affiliation Confirmation Attestation", + "description": "For declaring that the applicant's military affiliation documentation is accurate.", + "text": "I hereby attest and affirm that my current military status documentation as uploaded to CompactConnect is accurate.", + "required": true, + "locale": "en" + } ] }, { @@ -24,6 +106,88 @@ "compactSummaryReportNotificationEmails": [], "activeEnvironments": [ "test" + ], + "attestations": [ + { + "attestationId": "jurisprudence-confirmation", + "displayName": "Jurisprudence Confirmation.", + "description": "For displaying the jurisprudence confirmation.", + "text": "I understand that an attestation is a legally binding statement. I understand that providing false information on this application could result in a loss of my licenses and/or privileges. I acknowledge that the Commission may audit jurisprudence attestations at their discretion.", + "required": true, + "locale": "en" + }, + { + "attestationId": "scope-of-practice-attestation", + "displayName": "Scope of Practice Attestation", + "description": "For displaying the scope of practice attestation.", + "text": "I hereby attest and affirm that I have reviewed, understand, and will abide by this state's scope of practice and all applicable laws and rules when practicing in the state. I understand that the issuance of a Compact Privilege authorizes me to legally practice in the member jurisdiction in accordance with the laws and rules governing practice of my profession in that jurisdiction.\n\nIf I violate the practice act, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years.", + "required": true, + "locale": "en" + }, + { + "attestationId": "personal-information-home-state-attestation", + "displayName": "Personal Information Home State Attestation", + "description": "For declaring that the applicant is a resident of the state they have listed as their home state.", + "text": "I hereby attest and affirm that this is my personal and licensure information and that I am a resident of the state listed on this page.*", + "required": true, + "locale": "en" + }, + { + "attestationId": "personal-information-address-attestation", + "displayName": "Personal Information Address Attestation", + "description": "For declaring that the applicant is a resident of the state they have listed as their home state.", + "text": "I hereby attest and affirm that the address information I have provided herein and is my current address. I further consent to accept service of process at this address. I will notify the Commission of a change in my Home State address or email address via updating personal information records in this system. I understand that I am only eligible for a Compact Privilege if I am a licensee in my Home State as defined by the Compact. If I mislead the Compact Commission about my Home State, the appropriate board may take action against my Compact Privilege, which may result in the revocation of other Compact Privileges I may hold. I will also be prohibited from obtaining any other Compact Privileges for a period of at least two (2) years.*", + "required": true, + "locale": "en" + }, + { + "attestationId": "not-under-investigation-attestation", + "displayName": "Not Under Investigation Attestation", + "description": "For declaring that the applicant is not currently under investigation.", + "text": "I hereby attest and affirm that I am not currently under investigation by any board, agency, department, association, certifying body, or other body.", + "required": true, + "locale": "en" + }, + { + "attestationId": "under-investigation-attestation", + "displayName": "Under Investigation Attestation", + "description": "For declaring that the applicant is currently under investigation.", + "text": "I hereby attest and affirm that I am currently under investigation by any board, agency, department, association, certifying body, or other body. I understand that if any investigation results in a disciplinary action, my Compact Privileges may be revoked.", + "required": true, + "locale": "en" + }, + { + "attestationId": "discipline-no-current-encumbrance-attestation", + "displayName": "No Current Discipline Encumbrance Attestation", + "description": "For declaring that the applicant has no encumbrances on any state license.", + "text": "I hereby attest and affirm that I have no encumbrance (any discipline that restricts my full practice or any unmet condition before returning to a full and unrestricted license, including, but not limited, to probation, supervision, completion of a program, and/or completion of CEs) on ANY state license.", + "required": true, + "locale": "en" + }, + { + "attestationId": "discipline-no-prior-encumbrance-attestation", + "displayName": "No Discipline Encumbrance For Prior Two Yeats Attestation", + "description": "For declaring that the applicant has no encumbrances on any state license within the last two years.", + "text": "I hereby attest and affirm that I have not had any encumbrance on ANY state license within the previous two years from date of this application for a Compact Privilege.", + "required": true, + "locale": "en" + }, + { + "attestationId": "provision-of-true-information-attestation", + "displayName": "Provision of True Information Attestation", + "description": "For declaring that the applicant has provided true information.", + "text": "I hereby attest and affirm that all information contained in this privilege application is true to the best of my knowledge.", + "required": true, + "locale": "en" + }, + { + "attestationId": "military-affiliation-confirmation-attestation", + "displayName": "Military Affiliation Confirmation Attestation", + "description": "For declaring that the applicant's military affiliation documentation is accurate.", + "text": "I hereby attest and affirm that my current military status documentation as uploaded to CompactConnect is accurate.", + "required": true, + "locale": "en" + } ] } ], diff --git a/backend/compact-connect/tests/resources/snapshots/GET_ATTESTATION_BY_ID_RESPONSE_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/GET_ATTESTATION_BY_ID_RESPONSE_SCHEMA.json new file mode 100644 index 000000000..4f58dec9c --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/GET_ATTESTATION_BY_ID_RESPONSE_SCHEMA.json @@ -0,0 +1,36 @@ +{ + "properties": { + "type": { + "enum": [ + "attestation" + ], + "type": "string" + }, + "attestationType": { + "type": "string" + }, + "compact": { + "enum": [ + "aslp", + "octp", + "coun" + ], + "type": "string" + }, + "version": { + "type": "string" + }, + "dateCreated": { + "format": "date-time", + "type": "string" + }, + "text": { + "type": "string" + }, + "required": { + "type": "boolean" + } + }, + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json new file mode 100644 index 000000000..f0705dc35 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_REQUEST_SCHEMA.json @@ -0,0 +1,53 @@ +{ + "additionalProperties": false, + "properties": { + "permissions": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "actions": { + "properties": { + "readPrivate": { + "type": "boolean" + }, + "admin": { + "type": "boolean" + }, + "read": { + "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/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json new file mode 100644 index 000000000..31dd2a592 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/PATCH_STAFF_USERS_RESPONSE_SCHEMA.json @@ -0,0 +1,87 @@ +{ + "additionalProperties": false, + "properties": { + "userId": { + "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" + }, + "read": { + "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" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json new file mode 100644 index 000000000..ddbcdefd8 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_REQUEST_SCHEMA.json @@ -0,0 +1,83 @@ +{ + "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" + }, + "read": { + "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/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json new file mode 100644 index 000000000..31dd2a592 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/POST_STAFF_USERS_RESPONSE_SCHEMA.json @@ -0,0 +1,87 @@ +{ + "additionalProperties": false, + "properties": { + "userId": { + "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" + }, + "read": { + "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" + ], + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" +} diff --git a/backend/compact-connect/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json index 1c2454a8b..6094ad3b1 100644 --- a/backend/compact-connect/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json +++ b/backend/compact-connect/tests/resources/snapshots/PROVIDER_USER_RESPONSE_SCHEMA.json @@ -1,210 +1,24 @@ { - "$schema": "http://json-schema.org/draft-04/schema#", "properties": { - "birthMonthDay": { - "format": "date", - "pattern": "^[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "compact": { - "enum": [ - "aslp", - "octp", - "coun" - ], - "type": "string" - }, - "dateOfBirth": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "dateOfExpiration": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "dateOfUpdate": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "familyName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "givenName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "homeAddressCity": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "homeAddressPostalCode": { - "maxLength": 7, - "minLength": 5, - "type": "string" - }, - "homeAddressState": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "homeAddressStreet1": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "homeAddressStreet2": { - "maxLength": 100, - "minLength": 1, - "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" - }, - "licenseType": { - "enum": [ - "audiologist", - "speech-language pathologist", - "speech and language pathologist", - "occupational therapist", - "occupational therapy assistant", - "licensed professional counselor", - "licensed mental health counselor", - "licensed clinical mental health counselor", - "licensed professional clinical counselor" - ], - "type": "string" - }, "licenses": { "items": { "properties": { - "compact": { + "type": { "enum": [ - "aslp", - "octp", - "coun" + "license-home" ], "type": "string" }, - "dateOfBirth": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "dateOfExpiration": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "dateOfIssuance": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "dateOfRenewal": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "dateOfUpdate": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "familyName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "givenName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "homeAddressCity": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "homeAddressPostalCode": { - "maxLength": 7, - "minLength": 5, - "type": "string" - }, - "homeAddressState": { - "maxLength": 100, - "minLength": 2, - "type": "string" - }, - "homeAddressStreet1": { - "maxLength": 100, - "minLength": 2, + "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" }, - "homeAddressStreet2": { - "maxLength": 100, - "minLength": 1, + "compact": { + "enum": [ + "aslp", + "octp", + "coun" + ], "type": "string" }, "jurisdiction": { @@ -265,6 +79,358 @@ ], "type": "string" }, + "dateOfUpdate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "history": { + "items": { + "properties": { + "type": { + "enum": [ + "licenseUpdate" + ], + "type": "string" + }, + "updateType": { + "enum": [ + "renewal", + "deactivation", + "other" + ], + "type": "string" + }, + "compact": { + "enum": [ + "aslp", + "octp", + "coun" + ], + "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" + }, + "dateOfUpdate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "previous": { + "properties": { + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string" + }, + "npi": { + "pattern": "^[0-9]{10}$", + "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}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "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" + }, + "licenseType": { + "enum": [ + "audiologist", + "speech-language pathologist", + "speech and language pathologist", + "occupational therapist", + "occupational therapy assistant", + "licensed professional counselor", + "licensed mental health counselor", + "licensed clinical mental health counselor", + "licensed professional clinical counselor" + ], + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "militaryWaiver": { + "type": "boolean" + } + }, + "type": "object" + }, + "updatedValues": { + "properties": { + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string" + }, + "npi": { + "pattern": "^[0-9]{10}$", + "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}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "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" + }, + "licenseType": { + "enum": [ + "audiologist", + "speech-language pathologist", + "speech and language pathologist", + "occupational therapist", + "occupational therapy assistant", + "licensed professional counselor", + "licensed mental health counselor", + "licensed clinical mental health counselor", + "licensed professional clinical counselor" + ], + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "militaryWaiver": { + "type": "boolean" + } + }, + "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" + } + }, + "type": "object" + }, + "type": "array" + }, + "ssn": { + "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "type": "string" + }, + "npi": { + "pattern": "^[0-9]{10}$", + "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}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "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" + }, "licenseType": { "enum": [ "audiologist", @@ -279,24 +445,19 @@ ], "type": "string" }, - "middleName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "militaryWaiver": { - "type": "boolean" - }, - "npi": { - "pattern": "^[0-9]{10}$", + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", "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}", + "dateOfRenewal": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", "type": "string" }, - "ssn": { - "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", "type": "string" }, "status": { @@ -306,114 +467,339 @@ ], "type": "string" }, - "type": { - "enum": [ - "license-home" - ], - "type": "string" + "militaryWaiver": { + "type": "boolean" } }, "type": "object" }, "type": "array" }, - "middleName": { - "maxLength": 100, - "minLength": 1, - "type": "string" - }, - "militaryWaiver": { - "type": "boolean" - }, - "npi": { - "pattern": "^[0-9]{10}$", - "type": "string" - }, - "privilegeJurisdictions": { - "items": { - "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" - }, - "type": "array" - }, "privileges": { "items": { "properties": { - "compact": { + "history": { + "items": { + "properties": { + "type": { + "enum": [ + "privilegeUpdate" + ], + "type": "string" + }, + "updateType": { + "enum": [ + "renewal", + "deactivation", + "other" + ], + "type": "string" + }, + "compact": { + "enum": [ + "aslp", + "octp", + "coun" + ], + "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" + }, + "dateOfUpdate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "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": [ + "aslp", + "octp", + "coun" + ], + "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" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + } + }, + "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": [ + "aslp", + "octp", + "coun" + ], + "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" + }, + "status": { + "enum": [ + "active", + "inactive" + ], + "type": "string" + }, + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{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" + } + }, + "type": "object" + }, + "type": "array" + }, + "type": { "enum": [ - "aslp", - "octp", - "coun" + "privilege" ], "type": "string" }, - "dateOfExpiration": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", - "type": "string" - }, - "dateOfIssuance": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "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" }, - "dateOfUpdate": { - "format": "date", - "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "compact": { + "enum": [ + "aslp", + "octp", + "coun" + ], "type": "string" }, "licenseJurisdiction": { @@ -474,10 +860,6 @@ ], "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" - }, "status": { "enum": [ "active", @@ -485,10 +867,19 @@ ], "type": "string" }, - "type": { - "enum": [ - "privilege" - ], + "dateOfIssuance": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", "type": "string" } }, @@ -496,6 +887,12 @@ }, "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" @@ -504,6 +901,39 @@ "pattern": "^[0-9]{3}-[0-9]{2}-[0-9]{4}$", "type": "string" }, + "npi": { + "pattern": "^[0-9]{10}$", + "type": "string" + }, + "givenName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "middleName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "familyName": { + "maxLength": 100, + "minLength": 1, + "type": "string" + }, + "licenseType": { + "enum": [ + "audiologist", + "speech-language pathologist", + "speech and language pathologist", + "occupational therapist", + "occupational therapy assistant", + "licensed professional counselor", + "licensed mental health counselor", + "licensed clinical mental health counselor", + "licensed professional clinical counselor" + ], + "type": "string" + }, "status": { "enum": [ "active", @@ -511,11 +941,180 @@ ], "type": "string" }, - "type": { + "compact": { "enum": [ - "provider" + "aslp", + "octp", + "coun" + ], + "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" + }, + "privilegeJurisdictions": { + "items": { + "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" + }, + "type": "array" + }, + "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" + }, + "militaryWaiver": { + "type": "boolean" + }, + "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}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfUpdate": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" + }, + "dateOfExpiration": { + "format": "date", + "pattern": "^[12]{1}[0-9]{3}-[01]{1}[0-9]{1}-[0-3]{1}[0-9]{1}$", + "type": "string" } }, "required": [ @@ -540,5 +1139,6 @@ "licenses", "privileges" ], - "type": "object" + "type": "object", + "$schema": "http://json-schema.org/draft-04/schema#" } diff --git a/backend/compact-connect/tests/resources/snapshots/PURCHASE_PRIVILEGE_REQUEST_SCHEMA.json b/backend/compact-connect/tests/resources/snapshots/PURCHASE_PRIVILEGE_REQUEST_SCHEMA.json index f0f93a2c5..60fac81e8 100644 --- a/backend/compact-connect/tests/resources/snapshots/PURCHASE_PRIVILEGE_REQUEST_SCHEMA.json +++ b/backend/compact-connect/tests/resources/snapshots/PURCHASE_PRIVILEGE_REQUEST_SCHEMA.json @@ -146,12 +146,37 @@ "billing" ], "type": "object" + }, + "attestations": { + "description": "List of attestations that the user has agreed to", + "items": { + "properties": { + "attestationId": { + "description": "The ID of the attestation", + "maxLength": 100, + "type": "string" + }, + "version": { + "description": "The version of the attestation", + "maxLength": 10, + "pattern": "^\\d+$", + "type": "string" + } + }, + "required": [ + "attestationId", + "version" + ], + "type": "object" + }, + "type": "array" } }, "required": [ "selectedJurisdictions", - "orderInformation" + "orderInformation", + "attestations" ], "type": "object", "$schema": "http://json-schema.org/draft-04/schema#" -} \ No newline at end of file +} diff --git a/backend/compact-connect/tests/resources/snapshots/SSN_TABLE_RESOURCE_POLICY.json b/backend/compact-connect/tests/resources/snapshots/SSN_TABLE_RESOURCE_POLICY.json new file mode 100644 index 000000000..90e49f531 --- /dev/null +++ b/backend/compact-connect/tests/resources/snapshots/SSN_TABLE_RESOURCE_POLICY.json @@ -0,0 +1,22 @@ +{ + "Statement": [ + { + "Action": [ + "dynamodb:BatchGetItem", + "dynamodb:BatchWriteItem", + "dynamodb:PartiQL*", + "dynamodb:Query", + "dynamodb:Scan" + ], + "Condition": { + "StringNotEquals": { + "aws:PrincipalServiceName": "dynamodb.amazonaws.com" + } + }, + "Effect": "Deny", + "Principal": "*", + "Resource": "*" + } + ], + "Version": "2012-10-17" +} diff --git a/backend/compact-connect/tests/smoke/config.py b/backend/compact-connect/tests/smoke/config.py new file mode 100644 index 000000000..06d41fad7 --- /dev/null +++ b/backend/compact-connect/tests/smoke/config.py @@ -0,0 +1,67 @@ +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 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 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 cognito_provider_user_client_id(self): + return os.environ['CC_TEST_COGNITO_PROVIDER_USER_POOL_CLIENT_ID'] + + @property + def cognito_provider_user_pool_id(self): + return os.environ['CC_TEST_COGNITO_PROVIDER_USER_POOL_ID'] + + @property + def test_provider_user_username(self): + return os.environ['CC_TEST_PROVIDER_USER_USERNAME'] + + @property + def test_provider_user_password(self): + return os.environ['CC_TEST_PROVIDER_USER_PASSWORD'] + + @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/compact-connect/tests/smoke/license_upload_smoke_tests.py b/backend/compact-connect/tests/smoke/license_upload_smoke_tests.py index 7edaf54e3..10c1e637e 100644 --- a/backend/compact-connect/tests/smoke/license_upload_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/license_upload_smoke_tests.py @@ -6,6 +6,8 @@ import requests from smoke_common import ( SmokeTestFailureException, + create_test_staff_user, + delete_test_staff_user, get_api_base_url, get_data_events_dynamodb_table, get_provider_user_dynamodb_table, @@ -19,9 +21,12 @@ # This script can be run locally to test the license upload/ingest flow against a sandbox environment # of the Compact Connect API. +# Your sandbox account must be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json # 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 = 'testStaffUserLicenseUploader@smokeTestFakeEmail.com' + def upload_licenses_record(): """ @@ -33,7 +38,7 @@ def upload_licenses_record(): Step 3: Verify the license record is recorded in the data events table. """ - headers = get_staff_user_auth_headers() + headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) # Step 1: Upload a license record through the POST '/v1/compacts/aslp/jurisdictions/ne/licenses' endpoint. post_body = [ @@ -149,5 +154,18 @@ def upload_licenses_record(): if __name__ == '__main__': load_smoke_test_env() - upload_licenses_record() - print('License record upload smoke test passed') + # Create staff user with permission to upload licenses + test_user_sub = create_test_staff_user( + email=TEST_STAFF_USER_EMAIL, + compact=COMPACT, + jurisdiction=JURISDICTION, + permissions={'actions': {'admin'}, 'jurisdictions': {JURISDICTION: {'write', 'admin'}}}, + ) + try: + upload_licenses_record() + print('License record upload smoke test passed') + except SmokeTestFailureException as e: + print(f'License record upload smoke test failed: {str(e)}') + 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/compact-connect/tests/smoke/military_affiliation_smoke_tests.py b/backend/compact-connect/tests/smoke/military_affiliation_smoke_tests.py index e24991cdc..e10f8ec8e 100644 --- a/backend/compact-connect/tests/smoke/military_affiliation_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/military_affiliation_smoke_tests.py @@ -8,12 +8,13 @@ from smoke_common import ( SmokeTestFailureException, get_api_base_url, - get_provider_user_auth_headers, + get_provider_user_auth_headers_cached, load_smoke_test_env, ) # This script is used to test the military affiliations upload flow against a sandbox environment -# # of the Compact Connect API. +# of the Compact Connect API. It requires that you have a provider user set up in the sandbox environment. +# Your sandbox account must also be deployed with the "security_profile": "VULNERABLE" setting in your cdk.context.json # 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. @@ -26,7 +27,7 @@ def test_military_affiliation_upload(): # Step 3: Verify that the test pdf file was uploaded successfully by checking the response status code. # Step 4: Get the provider data from the GET '/v1/provider-users/me' endpoint and verify that the military # affiliation record is active. - headers = get_provider_user_auth_headers() + headers = get_provider_user_auth_headers_cached() post_body = { 'fileNames': ['military_affiliation.pdf'], @@ -111,7 +112,7 @@ def test_military_affiliation_patch_update(): # '/v1/provider-users/me/military-affiliation' endpoint. # Step 4: Get the provider data from the GET '/v1/provider-users/me' endpoint and verify that all the military # affiliation records are inactive. - headers = get_provider_user_auth_headers() + headers = get_provider_user_auth_headers_cached() patch_body = { 'status': 'inactive', diff --git a/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py b/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py index 534db37d2..f000909b7 100644 --- a/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py @@ -1,15 +1,13 @@ -# ruff: noqa: T201 we use print statements for smoke testing #!/usr/bin/env python3 +import time from datetime import UTC, datetime import requests +from config import config, logger from smoke_common import ( SmokeTestFailureException, call_provider_users_me_endpoint, - get_api_base_url, - get_provider_user_auth_headers, - get_provider_user_dynamodb_table, - load_smoke_test_env, + get_provider_user_auth_headers_cached, ) # This script can be run locally to test the privilege purchasing flow against a sandbox environment @@ -19,9 +17,10 @@ def test_purchasing_privilege(): - # Step 1: Purchase a privilege through the POST '/v1/purchases/privileges' endpoint. - # Step 2: Verify a transaction id is returned in the response body. - # Step 3: Load records for provider and verify that the privilege is added to the provider's record. + # Step 1: Get latest versions of required attestations - GET '/v1/compact/{compact}/attestations/{attestationId}'. + # Step 2: Purchase a privilege through the POST '/v1/purchases/privileges' endpoint. + # Step 3: Verify a transaction id is returned in the response body. + # Step 4: Load records for provider and verify that the privilege is added to the provider's record. # first cleaning up user's existing privileges to start in a clean state original_provider_data = call_provider_users_me_endpoint() @@ -29,16 +28,55 @@ def test_purchasing_privilege(): if original_privileges: provider_id = original_provider_data.get('providerId') compact = original_provider_data.get('compact') - dynamodb_table = get_provider_user_dynamodb_table() + dynamodb_table = config.provider_user_dynamodb_table for privilege in original_privileges: - print(f'Deleting privilege record: {privilege}') + privilege_pk = f'{compact}#PROVIDER#{provider_id}' + privilege_sk = ( + f'{compact}#PROVIDER#privilege/{privilege["jurisdiction"]}#' + f'{datetime.fromisoformat(privilege["dateOfRenewal"]).date().isoformat()}' + ) + logger.info(f'Deleting privilege record:\n{privilege_pk}\n{privilege_sk}') dynamodb_table.delete_item( Key={ - 'pk': f'{compact}#PROVIDER#{provider_id}', - 'sk': f'{compact}#PROVIDER#privilege/{privilege["jurisdiction"]}' - f'#{datetime.fromisoformat(privilege["dateOfRenewal"]).date().isoformat()}', + 'pk': privilege_pk, + 'sk': privilege_sk, } ) + # give dynamodb time to propagate + time.sleep(1) + + # Get the latest version of every attestation required for the privilege purchase + required_attestation_ids = [ + 'jurisprudence-confirmation', + 'scope-of-practice-attestation', + 'personal-information-home-state-attestation', + 'personal-information-address-attestation', + 'discipline-no-current-encumbrance-attestation', + 'discipline-no-prior-encumbrance-attestation', + 'provision-of-true-information-attestation', + 'not-under-investigation-attestation', + ] + military_records = [ + record for record in original_provider_data.get('militaryAffiliations', []) if record['status'] == 'active' + ] + if military_records: + required_attestation_ids.append('military-affiliation-confirmation-attestation') + compact = original_provider_data.get('compact') + attestations_from_system = [] + for attestation_id in required_attestation_ids: + get_attestation_response = requests.get( + url=f'{config.api_base_url}/v1/compacts/{compact}/attestations/{attestation_id}', + headers=get_provider_user_auth_headers_cached(), + params={'locale': 'en'}, + timeout=10, + ) + + if get_attestation_response.status_code != 200: + raise SmokeTestFailureException(f'Failed to get attestation. Response: {get_attestation_response.json()}') + + attestation = get_attestation_response.json() + logger.info(f'Received attestation response for {attestation_id}: {attestation}') + attestations_from_system.append({'attestationId': attestation_id, 'version': attestation['version']}) post_body = { 'orderInformation': { @@ -59,11 +97,12 @@ def test_purchasing_privilege(): }, }, 'selectedJurisdictions': ['ne'], + 'attestations': attestations_from_system, } - headers = get_provider_user_auth_headers() + headers = get_provider_user_auth_headers_cached() post_api_response = requests.post( - url=get_api_base_url() + '/v1/purchases/privileges', headers=headers, json=post_body, timeout=20 + url=config.api_base_url + '/v1/purchases/privileges', headers=headers, json=post_body, timeout=20 ) if post_api_response.status_code != 200: @@ -88,10 +127,29 @@ def test_purchasing_privilege(): raise SmokeTestFailureException(f'No privilege record found for today ({today})') if matching_privilege['compactTransactionId'] != transaction_id: raise SmokeTestFailureException('Privilege record does not match transaction id') + if not matching_privilege.get('attestations'): + raise SmokeTestFailureException('No attestations found in privilege record') + for attestation in matching_privilege['attestations']: + matching_attestation_from_system = next( + ( + attestation_from_system + for attestation_from_system in attestations_from_system + if attestation_from_system['attestationId'] == attestation['attestationId'] + ), + None, + ) + if not matching_attestation_from_system: + raise SmokeTestFailureException(f'No matching attestation found for {attestation["attestationId"]}') + if attestation['version'] != matching_attestation_from_system['version']: + raise SmokeTestFailureException('Attestation version in privilege record does not match') + if attestation['version'] != attestations_from_system[0]['version']: + raise SmokeTestFailureException( + f'Attestation {attestation['attestationId']} version in privilege record ' + f'does not match latest version in system' + ) - print(f'Successfully purchased privilege record: {matching_privilege}') + logger.info(f'Successfully purchased privilege record: {matching_privilege}') if __name__ == '__main__': - load_smoke_test_env() test_purchasing_privilege() diff --git a/backend/compact-connect/tests/smoke/query_provider_smoke_tests.py b/backend/compact-connect/tests/smoke/query_provider_smoke_tests.py new file mode 100644 index 000000000..ae8a1863b --- /dev/null +++ b/backend/compact-connect/tests/smoke/query_provider_smoke_tests.py @@ -0,0 +1,209 @@ +# ruff: noqa: S101 T201 we use asserts and print statements for smoke testing +import json + +import requests +from config import config, logger +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. + provider_object = get_provider_response.json() + + # verify the ssn is NOT in the response + if 'ssn' in 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('ssn', None) + test_user_profile.pop('dateOfBirth', None) + for provider_license in test_user_profile['licenses']: + provider_license.pop('ssn', None) + provider_license.pop('dateOfBirth', None) + for military_affiliation in test_user_profile['militaryAffiliations']: + military_affiliation.pop('documentKeys', None) + + if provider_object != test_user_profile: + raise SmokeTestFailureException( + f'Provider object does not match the profile.\n' + f'Profile response: {json.dumps(test_user_profile)}\n' + f'Get Provider response: {json.dumps(provider_object)}' + ) + 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('ssn', None) + test_user_profile.pop('dateOfBirth', None) + test_user_profile.pop('licenses') + test_user_profile.pop('militaryAffiliations') + test_user_profile.pop('privileges') + + if provider_object != test_user_profile: + raise SmokeTestFailureException( + f'Provider object does not match the profile.\n' + f'Profile response: {test_user_profile}\n' + f'Query Provider object: {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': {'aslp': {'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' + f'Profile response: {test_user_profile}\n' + f'Get Provider response: {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/compact-connect/tests/smoke/smoke_common.py b/backend/compact-connect/tests/smoke/smoke_common.py index 89974cbed..53a710383 100644 --- a/backend/compact-connect/tests/smoke/smoke_common.py +++ b/backend/compact-connect/tests/smoke/smoke_common.py @@ -1,8 +1,11 @@ import json import os +import sys import boto3 import requests +from botocore.exceptions import ClientError +from config import config, logger class SmokeTestFailureException(Exception): @@ -14,15 +17,154 @@ def __init__(self, message): super().__init__(message) -def get_provider_user_auth_headers(): +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) + +# We have to import this after we've added the common lib to our path and environment +from cc_common.data_model.schema.user 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, user_sub: str, compact: str): + """ + Deletes a test staff user from Cognito. + """ + 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. + """ + 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, + }, + ), + ) + 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_provider_user_auth_headers_cached(): + provider_token = os.environ.get('TEST_PROVIDER_USER_ID_TOKEN') + if not provider_token: + tokens = get_user_tokens(config.test_provider_user_username, config.test_provider_user_password, is_staff=False) + os.environ['TEST_PROVIDER_USER_ID_TOKEN'] = tokens['IdToken'] + return { 'Authorization': 'Bearer ' + os.environ['TEST_PROVIDER_USER_ID_TOKEN'], } -def get_staff_user_auth_headers(): +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 ' + os.environ['TEST_STAFF_USER_ACCESS_TOKEN'], + 'Authorization': 'Bearer ' + tokens['AccessToken'], } @@ -47,7 +189,7 @@ def load_smoke_test_env(): def call_provider_users_me_endpoint(): # Get the provider data from the GET '/v1/provider-users/me' endpoint. get_provider_data_response = requests.get( - url=get_api_base_url() + '/v1/provider-users/me', headers=get_provider_user_auth_headers(), timeout=10 + url=config.api_base_url + '/v1/provider-users/me', headers=get_provider_user_auth_headers_cached(), timeout=10 ) if get_provider_data_response.status_code != 200: raise SmokeTestFailureException(f'Failed to GET provider data. Response: {get_provider_data_response.json()}') diff --git a/backend/compact-connect/tests/smoke/smoke_tests_env_example.json b/backend/compact-connect/tests/smoke/smoke_tests_env_example.json index d40aabe4c..70448717c 100644 --- a/backend/compact-connect/tests/smoke/smoke_tests_env_example.json +++ b/backend/compact-connect/tests/smoke/smoke_tests_env_example.json @@ -2,6 +2,11 @@ "CC_TEST_API_BASE_URL": "https://api.test.compactconnect.org", "CC_TEST_PROVIDER_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-ProviderTable12345", "CC_TEST_DATA_EVENT_DYNAMO_TABLE_NAME": "Sandbox-PersistentStack-DataEventTable1234", - "TEST_PROVIDER_USER_ID_TOKEN": "This can be fetched by logging in as a licensee into the Compact Connect UI of your test environment and copying the value of 'id_token_licensee' from the browser's local storage.", - "TEST_STAFF_USER_ACCESS_TOKEN": "This can be fetched by logging in as a staff user into the Compact Connect UI of your test environment and copying the value of 'auth_token_staff' from the browser's local storage." + "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_COGNITO_PROVIDER_USER_POOL_ID": "us-east-1_12345", + "CC_TEST_COGNITO_PROVIDER_USER_POOL_CLIENT_ID": "72612345", + "CC_TEST_PROVIDER_USER_USERNAME": "example@example.com", + "CC_TEST_PROVIDER_USER_PASSWORD": "examplePassword" } diff --git a/backend/multi-account/requirements-dev.txt b/backend/multi-account/requirements-dev.txt index cc40e19bf..ca859d020 100644 --- a/backend/multi-account/requirements-dev.txt +++ b/backend/multi-account/requirements-dev.txt @@ -10,5 +10,5 @@ packaging==24.2 # via pytest pluggy==1.5.0 # via pytest -pytest==8.3.3 +pytest==8.3.4 # via -r multi-account/requirements-dev.in diff --git a/backend/multi-account/requirements.txt b/backend/multi-account/requirements.txt index 129b5370c..e14cecfc1 100644 --- a/backend/multi-account/requirements.txt +++ b/backend/multi-account/requirements.txt @@ -4,19 +4,19 @@ # # pip-compile --no-emit-index-url multi-account/requirements.in # -attrs==24.2.0 +attrs==24.3.0 # via # cattrs # jsii -aws-cdk-asset-awscli-v1==2.2.212 +aws-cdk-asset-awscli-v1==2.2.218 # via aws-cdk-lib aws-cdk-asset-kubectl-v20==2.1.3 # via aws-cdk-lib aws-cdk-asset-node-proxy-agent-v6==2.1.0 # via aws-cdk-lib -aws-cdk-cloud-assembly-schema==38.0.1 +aws-cdk-cloud-assembly-schema==39.1.38 # via aws-cdk-lib -aws-cdk-lib==2.169.0 +aws-cdk-lib==2.174.0 # via -r multi-account/requirements.in cattrs==24.1.2 # via jsii @@ -24,9 +24,9 @@ constructs==10.4.2 # via # -r multi-account/requirements.in # aws-cdk-lib -importlib-resources==6.4.5 +importlib-resources==6.5.2 # via jsii -jsii==1.105.0 +jsii==1.106.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-kubectl-v20 @@ -45,7 +45,7 @@ publication==0.0.3 # jsii python-dateutil==2.9.0.post0 # via jsii -six==1.16.0 +six==1.17.0 # via python-dateutil typeguard==2.13.3 # via diff --git a/webroot/README.md b/webroot/README.md index 07089c8ad..4e89d4955 100644 --- a/webroot/README.md +++ b/webroot/README.md @@ -8,6 +8,7 @@ - **[Local Development](#local-development)** - **[Tests](#tests)** - **[Build](#build)** +- **[Auth](#auth)** --- ## Key @@ -227,3 +228,26 @@ Note that testing the **built** app locally will require a running web server; f - _Otherwise the PWA will get cached and testing a clean state or volatile in-progress work will require lots of manual cache storage clearing._ --- +## Auth +- We use two cognito user pools with hosted login pages to authenticate users as Provider users and Staff users respectively +- Cognito's somewhat opinionated functionality combined with this set up introduces some complexity to make the app function in a secure and expected way + +- **Cognito's functionality:** + - When a user logs in to Cognito, it saves an http only cookie allowing the user to log back in without entering credentials for an hour + - Cognito has no way to totally log a user out except for visiting the hosted logout url in the browser + - Using the token revocation endpoint, the user can invalidate their reresh token, but the http only cookie is not removed so if + they logged in within the last hour they are not totally logged out + +- **Two user pools with their own hosted login pages:** + - If a user is logged in to one user pool, they can still log in to the second as they are not aware of each other + - The downstream effect of this point is that the app needs to handle users being logged in to both user pools in an expected and secure way: + - When a user logs out they must be logged out from all user pools they are logged into + - When a user logs in to the second user pool, the app must treat them as only logged into the second user pool + +- **How the app handles this situation:** + - Firstly, the user being logged into both pools is very unlikely as there is not natural way to do this within the app, they would need to manually visit the + second hosted login page after logging in normally to the first + - When the user logs in as to the second user pool we record that the app should treat them as the second user type and save the initial access token + - When the user logs out we check the existence of the access tokens to see which user pools we need to log out, and then chain logout redirects + to visit all necessary logout pages +--- diff --git a/webroot/package.json b/webroot/package.json index 2d4bb68a6..eed3011fd 100644 --- a/webroot/package.json +++ b/webroot/package.json @@ -32,7 +32,7 @@ "uuid": "^8.3.2", "vue": "^3.1.0", "vue-facing-decorator": "^3.0.4", - "vue-i18n": "^9.13.1", + "vue-i18n": "^10.0.5", "vue-responsiveness": "^0.2.0", "vue-router": "^4.3.0", "vue3-lazyload": "^0.3.8", diff --git a/webroot/src/app.config.ts b/webroot/src/app.config.ts index 5b9c358de..ced45d5d5 100644 --- a/webroot/src/app.config.ts +++ b/webroot/src/app.config.ts @@ -28,7 +28,6 @@ export enum FeeTypes { export const authStorage = sessionStorage; export const tokens = { staff: { - AUTH_TYPE: 'auth_type', AUTH_TOKEN: 'auth_token_staff', AUTH_TOKEN_TYPE: 'auth_token_type_staff', AUTH_TOKEN_EXPIRY: 'auth_token_expiry_staff', @@ -36,7 +35,6 @@ export const tokens = { REFRESH_TOKEN: 'refresh_token_staff', }, licensee: { - AUTH_TYPE: 'auth_type', AUTH_TOKEN: 'auth_token_licensee', AUTH_TOKEN_TYPE: 'auth_token_type_licensee', AUTH_TOKEN_EXPIRY: 'auth_token_expiry_licensee', @@ -45,6 +43,7 @@ export const tokens = { }, }; export const AUTH_LOGIN_GOTO_PATH = 'login_goto'; +export const AUTH_TYPE = 'auth_type'; // ==================== // = User Languages = diff --git a/webroot/src/assets/icons/ico-select-arrow-alt.svg b/webroot/src/assets/icons/ico-select-arrow-alt.svg new file mode 100644 index 000000000..8faa8c233 --- /dev/null +++ b/webroot/src/assets/icons/ico-select-arrow-alt.svg @@ -0,0 +1,3 @@ + + + diff --git a/webroot/src/assets/logos/compact-connect-logo-white.svg b/webroot/src/assets/logos/compact-connect-logo-white.svg new file mode 100644 index 000000000..02b31cdbe --- /dev/null +++ b/webroot/src/assets/logos/compact-connect-logo-white.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/webroot/src/components/App/App.ts b/webroot/src/components/App/App.ts index d9a92079b..47718cfda 100644 --- a/webroot/src/components/App/App.ts +++ b/webroot/src/components/App/App.ts @@ -16,7 +16,7 @@ import { authStorage, AuthTypes, relativeTimeFormats, - tokens + AUTH_TYPE } from '@/app.config'; import { CompactType } from '@models/Compact/Compact.model'; import PageContainer from '@components/Page/PageContainer/PageContainer.vue'; @@ -97,9 +97,9 @@ class App extends Vue { setAuthType() { let authType: AuthTypes; - if (authStorage.getItem(tokens?.staff?.AUTH_TOKEN)) { + if (authStorage.getItem(AUTH_TYPE) === AuthTypes.STAFF) { authType = AuthTypes.STAFF; - } else if (authStorage.getItem(tokens?.licensee?.AUTH_TOKEN)) { + } else if (authStorage.getItem(AUTH_TYPE) === AuthTypes.LICENSEE) { authType = AuthTypes.LICENSEE; } else { authType = AuthTypes.PUBLIC; @@ -171,10 +171,18 @@ class App extends Vue { this.body.style.overflow = (this.globalStore.isModalOpen) ? 'hidden' : 'visible'; } - @Watch('userStore.isLoggedIn') async loginState() { + @Watch('userStore.isLoggedInAsLicensee') async handleLicenseeLogin() { if (!this.userStore.isLoggedIn) { this.$router.push({ name: 'Logout' }); - } else { + } else if (this.userStore.isLoggedInAsLicensee) { + await this.handleAuth(); + } + } + + @Watch('userStore.isLoggedInAsStaff') async handleStaffLogin() { + if (!this.userStore.isLoggedIn) { + this.$router.push({ name: 'Logout' }); + } else if (this.userStore.isLoggedInAsStaff) { await this.handleAuth(); } } diff --git a/webroot/src/components/ChangePassword/ChangePassword.ts b/webroot/src/components/ChangePassword/ChangePassword.ts index b52d3ba8b..f71316c95 100644 --- a/webroot/src/components/ChangePassword/ChangePassword.ts +++ b/webroot/src/components/ChangePassword/ChangePassword.ts @@ -11,7 +11,6 @@ import { authStorage, AuthTypes, tokens } from '@/app.config'; import MixinForm from '@components/Forms/_mixins/form.mixin'; import InputPassword from '@components/Forms/InputPassword/InputPassword.vue'; import InputSubmit from '@components/Forms/InputSubmit/InputSubmit.vue'; -import { User } from '@models/User/User.model'; import { FormInput } from '@models/FormInput/FormInput.model'; import { dataApi } from '@network/data.api'; import Joi from 'joi'; @@ -42,10 +41,6 @@ class ChangePassword extends mixins(MixinForm) { return this.$store.state; } - get userStore() { - return this.$store.state.user; - } - get authType(): AuthTypes { return this.globalStore.authType; } @@ -70,10 +65,6 @@ class ChangePassword extends mixins(MixinForm) { return token; } - get user(): User { - return this.userStore.model || new User(); - } - get submitLabel(): string { return (this.isFormLoading) ? this.$t('common.saving') : this.$t('common.changePassword'); } @@ -100,16 +91,6 @@ class ChangePassword extends mixins(MixinForm) { validation: joiPassword .string() .min(12) - .minOfSpecialCharacters(1) - .minOfLowercase(1) - .minOfUppercase(1) - .minOfNumeric(1) - .doesNotInclude([ - this.user.email, - this.user.firstName, - this.user.lastName, - 'password', - ]) .messages({ ...this.joiMessages.string, ...this.joiMessages.password, diff --git a/webroot/src/components/Forms/InputButton/InputButton.ts b/webroot/src/components/Forms/InputButton/InputButton.ts index 46f2e3ca3..2a417a000 100644 --- a/webroot/src/components/Forms/InputButton/InputButton.ts +++ b/webroot/src/components/Forms/InputButton/InputButton.ts @@ -17,8 +17,9 @@ import { }) class InputButton extends Vue { @Prop({ required: true }) private label!: string; - @Prop({ default: true }) private isEnabled?: boolean; @Prop({ required: true }) private onClick?: () => void; + @Prop({ default: '' }) private id?: string; + @Prop({ default: true }) private isEnabled?: boolean; @Prop({ default: false }) private shouldTransformText?: boolean; @Prop({ default: false }) private shouldHideMargin?: boolean; @Prop({ default: false }) private isTransparent?: boolean; diff --git a/webroot/src/components/Forms/InputButton/InputButton.vue b/webroot/src/components/Forms/InputButton/InputButton.vue index 89905eb9b..70f01a2c9 100644 --- a/webroot/src/components/Forms/InputButton/InputButton.vue +++ b/webroot/src/components/Forms/InputButton/InputButton.vue @@ -9,6 +9,8 @@
{ it('should mount the component', async () => { @@ -51,4 +56,39 @@ describe('InputPassword component', async () => { expect(input.attributes().type).to.equal('password'); expect(wrapper.findComponent(HidePasswordEye).exists()).to.equal(true); }); + it('should show password requirements', async () => { + const { joiMessages } = (await mountShallow(MixinForm)).vm; + const formInput = new FormInput({ + validation: joiPassword + .string() + .min(12) + .minOfSpecialCharacters(1) + .minOfLowercase(1) + .minOfUppercase(1) + .minOfNumeric(1) + .doesNotInclude([ + 'password', + ]), + }); + const wrapper = await mountFull(InputPassword, { + props: { + formInput, + joiMessages, + }, + }); + const pwRequirements = wrapper.findAll('.password-requirement'); + + expect(pwRequirements[0].text()).to.equal('Must be at least 12 characters'); + expect(pwRequirements[0].classes('is-valid')).to.equal(false); + expect(pwRequirements[1].text()).to.equal('Must have at least 1 special character'); + expect(pwRequirements[1].classes('is-valid')).to.equal(false); + expect(pwRequirements[2].text()).to.equal('Must have at least 1 lowercase character'); + expect(pwRequirements[2].classes('is-valid')).to.equal(false); + expect(pwRequirements[3].text()).to.equal('Must have at least 1 uppercase character'); + expect(pwRequirements[3].classes('is-valid')).to.equal(false); + expect(pwRequirements[4].text()).to.equal('Must have at least 1 number'); + expect(pwRequirements[4].classes('is-valid')).to.equal(false); + expect(pwRequirements[5].text()).to.equal('Must not include your username or other common strings'); + expect(pwRequirements[5].classes('is-valid')).to.equal(false); + }); }); diff --git a/webroot/src/components/Forms/InputPassword/InputPassword.ts b/webroot/src/components/Forms/InputPassword/InputPassword.ts index e6fe956c3..669874684 100644 --- a/webroot/src/components/Forms/InputPassword/InputPassword.ts +++ b/webroot/src/components/Forms/InputPassword/InputPassword.ts @@ -58,12 +58,6 @@ class InputPassword extends mixins(MixinInput) { .replace(/{#limit}/g, limit) .replace(/{#min}/g, min); - if (limit === 1 || min === 1) { - ruleDesc = ruleDesc - .replace(/characters$/, 'character') - .replace(/numbers$/, 'number'); - } - if (inputValue) { const matchValidationError = validationErrors.find((error) => error.message === ruleDesc); @@ -72,6 +66,12 @@ class InputPassword extends mixins(MixinInput) { } } + if (limit === 1 || min === 1) { + ruleDesc = ruleDesc + .replace(/characters$/, 'character') + .replace(/numbers$/, 'number'); + } + requirements.push({ description: ruleDesc, isValid: ruleEval, diff --git a/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less b/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less index 6254d2aee..966788b00 100644 --- a/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less +++ b/webroot/src/components/Forms/InputRadioGroup/InputRadioGroup.less @@ -39,11 +39,14 @@ label { display: flex; align-items: center; + margin-left: 3rem; font-weight: @fontWeightLight; cursor: pointer; &::before { - display: inline-block; + position: absolute; + top: 0; + left: -3rem; width: 2.0rem; height: 2.0rem; margin: 0 0.3rem 0 0; @@ -60,7 +63,7 @@ input[type='radio']:checked + label::after { position: absolute; top: 0.4rem; - left: 0.4rem; + left: -2.6rem; display: block; width: 1.4rem; height: 1.4rem; diff --git a/webroot/src/components/Forms/InputSearch/InputSearch.less b/webroot/src/components/Forms/InputSearch/InputSearch.less index cc0d13a59..da371f64b 100644 --- a/webroot/src/components/Forms/InputSearch/InputSearch.less +++ b/webroot/src/components/Forms/InputSearch/InputSearch.less @@ -26,12 +26,12 @@ } input { - width: 100%; + width: 320px; height: 4.8rem; padding-left: 4.2rem; - border: 0; - border-radius: 8px; - background-color: @veryLightGrey; + border-color: @fontColor; + border-radius: 14px; + background-color: transparent; } } } diff --git a/webroot/src/components/HomeStateBlock/HomeStateBlock.less b/webroot/src/components/HomeStateBlock/HomeStateBlock.less index a1f76b9e7..43413ab8f 100644 --- a/webroot/src/components/HomeStateBlock/HomeStateBlock.less +++ b/webroot/src/components/HomeStateBlock/HomeStateBlock.less @@ -14,7 +14,7 @@ padding: 2rem; border-radius: 2rem; color: #fff; - background-color: @darkBlue; + background-color: @primaryColor; .home-state-img-container { width: 4rem; diff --git a/webroot/src/components/Icons/Account/Account.less b/webroot/src/components/Icons/Account/Account.less new file mode 100644 index 000000000..2e901e5e8 --- /dev/null +++ b/webroot/src/components/Icons/Account/Account.less @@ -0,0 +1,6 @@ +// +// Account.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Account/Account.spec.ts b/webroot/src/components/Icons/Account/Account.spec.ts new file mode 100644 index 000000000..5a2a5c73f --- /dev/null +++ b/webroot/src/components/Icons/Account/Account.spec.ts @@ -0,0 +1,19 @@ +// +// Account.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Account from '@components/Icons/Account/Account.vue'; + +describe('Account component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Account); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Account).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Account/Account.ts b/webroot/src/components/Icons/Account/Account.ts new file mode 100644 index 000000000..fa5e8b3fc --- /dev/null +++ b/webroot/src/components/Icons/Account/Account.ts @@ -0,0 +1,18 @@ +// +// Account.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Account', +}) +class Account extends Vue { +} + +export default toNative(Account); + +// export default Account; diff --git a/webroot/src/components/Icons/Account/Account.vue b/webroot/src/components/Icons/Account/Account.vue new file mode 100644 index 000000000..baa8f39f5 --- /dev/null +++ b/webroot/src/components/Icons/Account/Account.vue @@ -0,0 +1,36 @@ + + + + + + diff --git a/webroot/src/components/Icons/CheckCircle/CheckCircle.vue b/webroot/src/components/Icons/CheckCircle/CheckCircle.vue index 70a1eed76..bb90c8f33 100644 --- a/webroot/src/components/Icons/CheckCircle/CheckCircle.vue +++ b/webroot/src/components/Icons/CheckCircle/CheckCircle.vue @@ -8,7 +8,8 @@ + + + diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.less b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.less new file mode 100644 index 000000000..b9b91d273 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.less @@ -0,0 +1,6 @@ +// +// LicenseSearch.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.spec.ts b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.spec.ts new file mode 100644 index 000000000..6206d287e --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.spec.ts @@ -0,0 +1,19 @@ +// +// LicenseSearch.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import LicenseSearch from '@components/Icons/LicenseSearch/LicenseSearch.vue'; + +describe('LicenseSearch component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(LicenseSearch); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(LicenseSearch).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.ts b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.ts new file mode 100644 index 000000000..b990f9596 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.ts @@ -0,0 +1,18 @@ +// +// LicenseSearch.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'LicenseSearch', +}) +class LicenseSearch extends Vue { +} + +export default toNative(LicenseSearch); + +// export default LicenseSearch; diff --git a/webroot/src/components/Icons/LicenseSearch/LicenseSearch.vue b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.vue new file mode 100644 index 000000000..c0917a727 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearch/LicenseSearch.vue @@ -0,0 +1,28 @@ + + + + + + diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.less b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.less new file mode 100644 index 000000000..558f6abba --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.less @@ -0,0 +1,6 @@ +// +// LicenseSearchAlt.less +// CompactConnect +// +// Created by InspiringApps on 1/2/2025. +// diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.spec.ts b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.spec.ts new file mode 100644 index 000000000..87bdc4c7e --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.spec.ts @@ -0,0 +1,19 @@ +// +// LicenseSearchAlt.spec.ts +// CompactConnect +// +// Created by InspiringApps on 1/2/2025. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import LicenseSearchAlt from '@components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue'; + +describe('LicenseSearchAlt component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(LicenseSearchAlt); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(LicenseSearchAlt).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.ts b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.ts new file mode 100644 index 000000000..0825cff96 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.ts @@ -0,0 +1,18 @@ +// +// LicenseSearchAlt.ts +// CompactConnect +// +// Created by InspiringApps on 1/2/2025. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'LicenseSearchAlt', +}) +class LicenseSearchAlt extends Vue { +} + +export default toNative(LicenseSearchAlt); + +// export default LicenseSearchAlt; diff --git a/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue new file mode 100644 index 000000000..5bcc723a6 --- /dev/null +++ b/webroot/src/components/Icons/LicenseSearchAlt/LicenseSearchAlt.vue @@ -0,0 +1,17 @@ + + + + + + diff --git a/webroot/src/components/Icons/Logout/Logout.less b/webroot/src/components/Icons/Logout/Logout.less new file mode 100644 index 000000000..7c0a66079 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.less @@ -0,0 +1,6 @@ +// +// Logout.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Logout/Logout.spec.ts b/webroot/src/components/Icons/Logout/Logout.spec.ts new file mode 100644 index 000000000..600fa8e82 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.spec.ts @@ -0,0 +1,19 @@ +// +// Logout.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Logout from '@components/Icons/Logout/Logout.vue'; + +describe('Logout component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Logout); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Logout).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Logout/Logout.ts b/webroot/src/components/Icons/Logout/Logout.ts new file mode 100644 index 000000000..2a7f34863 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.ts @@ -0,0 +1,18 @@ +// +// Logout.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Logout', +}) +class Logout extends Vue { +} + +export default toNative(Logout); + +// export default Logout; diff --git a/webroot/src/components/Icons/Logout/Logout.vue b/webroot/src/components/Icons/Logout/Logout.vue new file mode 100644 index 000000000..ab6104b36 --- /dev/null +++ b/webroot/src/components/Icons/Logout/Logout.vue @@ -0,0 +1,22 @@ + + + + + + diff --git a/webroot/src/components/Icons/Purchase/Purchase.less b/webroot/src/components/Icons/Purchase/Purchase.less new file mode 100644 index 000000000..2a4839234 --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.less @@ -0,0 +1,6 @@ +// +// Purchase.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Purchase/Purchase.spec.ts b/webroot/src/components/Icons/Purchase/Purchase.spec.ts new file mode 100644 index 000000000..02cfdd33e --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.spec.ts @@ -0,0 +1,19 @@ +// +// Purchase.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Purchase from '@components/Icons/Purchase/Purchase.vue'; + +describe('Purchase component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Purchase); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Purchase).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Purchase/Purchase.ts b/webroot/src/components/Icons/Purchase/Purchase.ts new file mode 100644 index 000000000..e8b21f9ef --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.ts @@ -0,0 +1,18 @@ +// +// Purchase.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Purchase', +}) +class Purchase extends Vue { +} + +export default toNative(Purchase); + +// export default Purchase; diff --git a/webroot/src/components/Icons/Purchase/Purchase.vue b/webroot/src/components/Icons/Purchase/Purchase.vue new file mode 100644 index 000000000..9d5282835 --- /dev/null +++ b/webroot/src/components/Icons/Purchase/Purchase.vue @@ -0,0 +1,19 @@ + + + + + + diff --git a/webroot/src/components/Icons/Reports/Reports.less b/webroot/src/components/Icons/Reports/Reports.less new file mode 100644 index 000000000..e0c0721d1 --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.less @@ -0,0 +1,6 @@ +// +// Reports.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Reports/Reports.spec.ts b/webroot/src/components/Icons/Reports/Reports.spec.ts new file mode 100644 index 000000000..d0effdfb0 --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.spec.ts @@ -0,0 +1,19 @@ +// +// Reports.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Reports from '@components/Icons/Reports/Reports.vue'; + +describe('Reports component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Reports); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Reports).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Reports/Reports.ts b/webroot/src/components/Icons/Reports/Reports.ts new file mode 100644 index 000000000..c6ad0817f --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.ts @@ -0,0 +1,18 @@ +// +// Reports.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Reports', +}) +class Reports extends Vue { +} + +export default toNative(Reports); + +// export default Reports; diff --git a/webroot/src/components/Icons/Reports/Reports.vue b/webroot/src/components/Icons/Reports/Reports.vue new file mode 100644 index 000000000..9c8fe2375 --- /dev/null +++ b/webroot/src/components/Icons/Reports/Reports.vue @@ -0,0 +1,13 @@ + + + + + + diff --git a/webroot/src/components/Icons/Settings/Settings.less b/webroot/src/components/Icons/Settings/Settings.less new file mode 100644 index 000000000..061017a6c --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.less @@ -0,0 +1,6 @@ +// +// Settings.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Settings/Settings.spec.ts b/webroot/src/components/Icons/Settings/Settings.spec.ts new file mode 100644 index 000000000..e6fd29536 --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.spec.ts @@ -0,0 +1,19 @@ +// +// Settings.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Settings from '@components/Icons/Settings/Settings.vue'; + +describe('Settings component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Settings); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Settings).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Settings/Settings.ts b/webroot/src/components/Icons/Settings/Settings.ts new file mode 100644 index 000000000..e3e2b1a6d --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.ts @@ -0,0 +1,18 @@ +// +// Settings.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Settings', +}) +class Settings extends Vue { +} + +export default toNative(Settings); + +// export default Settings; diff --git a/webroot/src/components/Icons/Settings/Settings.vue b/webroot/src/components/Icons/Settings/Settings.vue new file mode 100644 index 000000000..b9509f9e2 --- /dev/null +++ b/webroot/src/components/Icons/Settings/Settings.vue @@ -0,0 +1,39 @@ + + + + + + diff --git a/webroot/src/components/Icons/Upload/Upload.less b/webroot/src/components/Icons/Upload/Upload.less new file mode 100644 index 000000000..9ec4d18f0 --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.less @@ -0,0 +1,6 @@ +// +// Upload.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Upload/Upload.spec.ts b/webroot/src/components/Icons/Upload/Upload.spec.ts new file mode 100644 index 000000000..53b265ff1 --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.spec.ts @@ -0,0 +1,19 @@ +// +// Upload.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Upload from '@components/Icons/Upload/Upload.vue'; + +describe('Upload component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Upload); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Upload).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Upload/Upload.ts b/webroot/src/components/Icons/Upload/Upload.ts new file mode 100644 index 000000000..a6052043b --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.ts @@ -0,0 +1,18 @@ +// +// Upload.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Upload', +}) +class Upload extends Vue { +} + +export default toNative(Upload); + +// export default Upload; diff --git a/webroot/src/components/Icons/Upload/Upload.vue b/webroot/src/components/Icons/Upload/Upload.vue new file mode 100644 index 000000000..5e18e1384 --- /dev/null +++ b/webroot/src/components/Icons/Upload/Upload.vue @@ -0,0 +1,21 @@ + + + + + + diff --git a/webroot/src/components/Icons/Users/Users.less b/webroot/src/components/Icons/Users/Users.less new file mode 100644 index 000000000..5d6b8759d --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.less @@ -0,0 +1,6 @@ +// +// Users.less +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// diff --git a/webroot/src/components/Icons/Users/Users.spec.ts b/webroot/src/components/Icons/Users/Users.spec.ts new file mode 100644 index 000000000..8abb18463 --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.spec.ts @@ -0,0 +1,19 @@ +// +// Users.spec.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { expect } from 'chai'; +import { mountShallow } from '@tests/helpers/setup'; +import Users from '@components/Icons/Users/Users.vue'; + +describe('Users component', async () => { + it('should mount the component', async () => { + const wrapper = await mountShallow(Users); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Users).exists()).to.equal(true); + }); +}); diff --git a/webroot/src/components/Icons/Users/Users.ts b/webroot/src/components/Icons/Users/Users.ts new file mode 100644 index 000000000..4a1284c2d --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.ts @@ -0,0 +1,18 @@ +// +// Users.ts +// CompactConnect +// +// Created by InspiringApps on 12/16/2024. +// + +import { Component, Vue, toNative } from 'vue-facing-decorator'; + +@Component({ + name: 'Users', +}) +class Users extends Vue { +} + +export default toNative(Users); + +// export default Users; diff --git a/webroot/src/components/Icons/Users/Users.vue b/webroot/src/components/Icons/Users/Users.vue new file mode 100644 index 000000000..741bdc3de --- /dev/null +++ b/webroot/src/components/Icons/Users/Users.vue @@ -0,0 +1,24 @@ + + + + + + diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less index f4acaccf5..ad348e5eb 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.less +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.less @@ -6,14 +6,13 @@ // .licensee-list-container { - background-color: @white; + background-color: transparent; .search-toggle-container { display: flex; flex-direction: column; flex-wrap: wrap; align-items: flex-end; - margin-bottom: 2.4rem; @media @desktopWidth { flex-direction: row; @@ -30,7 +29,7 @@ padding: 0.4rem 1rem; border-radius: @borderRadiusPillShape; color: @white; - background-color: @primaryColor; + background-color: @darkBlue; @media @desktopWidth { margin-right: 2.4rem; diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts index 0543c5c6f..3ccdc5df8 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.spec.ts @@ -113,7 +113,6 @@ describe('LicenseeList component', async () => { const requestConfig = await component.fetchListData(); expect(requestConfig).to.matchPattern({ - compact: undefined, jurisdiction: undefined, licenseeFirstName: undefined, licenseeLastName: undefined, @@ -132,8 +131,9 @@ describe('LicenseeList component', async () => { }; await component.$store.dispatch('user/setCurrentCompact', new Compact({ type: CompactType.ASLP })); + await component.$store.dispatch('license/setStoreSearch', testParams); - const requestConfig = await component.fetchListData(testParams); + const requestConfig = await component.fetchListData(); expect(requestConfig).to.matchPattern({ compact: CompactType.ASLP, diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts index e094909ee..7635e1fa3 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.ts @@ -38,7 +38,6 @@ class LicenseeList extends Vue { // hasSearched = false; shouldShowSearchModal = false; - searchParams: LicenseSearch = {}; isInitialFetchCompleted = false; prevKey = ''; nextKey = ''; @@ -78,6 +77,10 @@ class LicenseeList extends Vue { return this.$store.state.license; } + get searchParams(): LicenseSearch { + return this.licenseStore.search; + } + get searchDisplayFirstName(): string { return this.searchParams.firstName || ''; } @@ -164,8 +167,12 @@ class LicenseeList extends Vue { } handleSearch(params: LicenseSearch): void { - this.fetchListData(params); - this.searchParams = params; + this.$store.dispatch('license/setStoreSearch', params); + this.$store.dispatch('pagination/updatePaginationPage', { + paginationId: this.listId, + newPage: 1, + }); + this.fetchListData(); if (!this.hasSearched) { this.hasSearched = true; @@ -175,7 +182,7 @@ class LicenseeList extends Vue { } resetSearch(): void { - this.searchParams = {}; + this.$store.dispatch('license/resetStoreSearch'); this.toggleSearch(); } @@ -223,7 +230,8 @@ class LicenseeList extends Vue { } } - async fetchListData(searchParams?: LicenseSearch) { + async fetchListData() { + const { searchParams } = this; const sorting = this.sortingStore.sortingMap[this.listId]; const { option, direction } = sorting || {}; const pagination = this.paginationStore.paginationMap[this.listId]; diff --git a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue index 0e9ba2cef..6019c3c0b 100644 --- a/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue +++ b/webroot/src/components/Licensee/LicenseeList/LicenseeList.vue @@ -7,32 +7,32 @@