diff --git a/backend/cosmetology-app/lambdas/nodejs/email-notification-service/lambda.ts b/backend/cosmetology-app/lambdas/nodejs/email-notification-service/lambda.ts index 03efc991e..a10b98571 100644 --- a/backend/cosmetology-app/lambdas/nodejs/email-notification-service/lambda.ts +++ b/backend/cosmetology-app/lambdas/nodejs/email-notification-service/lambda.ts @@ -7,7 +7,7 @@ import { Context } from 'aws-lambda'; import { EnvironmentVariablesService } from '../lib/environment-variables-service'; import { CompactConfigurationClient } from '../lib/compact-configuration-client'; import { JurisdictionClient } from '../lib/jurisdiction-client'; -import { EncumbranceNotificationService, InvestigationNotificationService } from '../lib/email'; +import { EmailNotificationService, EncumbranceNotificationService, InvestigationNotificationService } from '../lib/email'; import { EmailNotificationEvent, EmailNotificationResponse } from '../lib/models/email-notification-service-event'; const environmentVariables = new EnvironmentVariablesService(); @@ -19,6 +19,7 @@ interface LambdaProperties { } export class Lambda implements LambdaInterface { + private readonly emailService: EmailNotificationService; private readonly encumbranceService: EncumbranceNotificationService; private readonly investigationService: InvestigationNotificationService; @@ -40,6 +41,13 @@ export class Lambda implements LambdaInterface { jurisdictionClient: jurisdictionClient }); + this.emailService = new EmailNotificationService({ + logger: logger, + sesClient: props.sesClient, + compactConfigurationClient: compactConfigurationClient, + jurisdictionClient: jurisdictionClient + }); + this.investigationService = new InvestigationNotificationService({ logger: logger, sesClient: props.sesClient, @@ -319,6 +327,27 @@ export class Lambda implements LambdaInterface { event.templateVariables.licenseType ); break; + case 'homeJurisdictionChangeNotification': + if (!event.jurisdiction) { + throw new Error('Missing required jurisdiction field for home jurisdiction change notification template.'); + } + if (!event.templateVariables?.providerFirstName + || !event.templateVariables?.providerLastName + || !event.templateVariables?.providerId + || !event.templateVariables?.previousJurisdiction + || !event.templateVariables?.newJurisdiction) { + throw new Error('Missing required template variables for home jurisdiction change notification template.'); + } + await this.emailService.sendHomeJurisdictionChangeStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.previousJurisdiction, + event.templateVariables.newJurisdiction + ); + break; default: logger.info('Unsupported email template provided', { template: event.template }); throw new Error(`Unsupported email template: ${event.template}`); diff --git a/backend/cosmetology-app/lambdas/nodejs/lib/email/base-email-service.ts b/backend/cosmetology-app/lambdas/nodejs/lib/email/base-email-service.ts index d4b7bf99e..7f7689658 100644 --- a/backend/cosmetology-app/lambdas/nodejs/lib/email/base-email-service.ts +++ b/backend/cosmetology-app/lambdas/nodejs/lib/email/base-email-service.ts @@ -367,201 +367,6 @@ export abstract class BaseEmailService { report['root']['data']['childrenIds'].push(blockId); } - protected insertTuple(report: TReaderDocument, keyText: string, valueText: string) { - const containerBlockId = `block-${crypto.randomUUID()}`; - const keyBlockId = `block-${crypto.randomUUID()}`; - const valueBlockId = `block-${crypto.randomUUID()}`; - - - report[keyBlockId] = { - 'type': 'Text', - 'data': { - 'style': { - 'fontWeight': 'bold', - 'padding': { - 'top': 16, - 'bottom': 0, - 'right': 12, - 'left': 24 - } - }, - 'props': { - 'text': keyText - } - } - }; - - report[valueBlockId] = { - 'type': 'Text', - 'data': { - 'style': { - 'color': '#525252', - 'fontSize': 14, - 'fontWeight': 'normal', - 'padding': { - 'top': 0, - 'bottom': 0, - 'right': 24, - 'left': 24 - } - }, - 'props': { - 'text': valueText - } - } - }; - - report[containerBlockId] = { - 'type': 'Container', - 'data': { - 'style': { - 'padding': { - 'top': 0, - 'bottom': 0, - 'right': 72, - 'left': 76 - } - }, - 'props': { - 'childrenIds': [ - keyBlockId, - valueBlockId - ] - } - } - }; - - report['root']['data']['childrenIds'].push(containerBlockId); - } - - protected insertTwoColumnTable(report: TReaderDocument, title: string, rows: { left: string, right: string }[]) { - const titleBlockId = `block-${crypto.randomUUID()}`; - - - report[titleBlockId] = { - 'type': 'Text', - 'data': { - 'style': { - 'fontWeight': 'bold', - 'padding': { - 'top': 24, - 'bottom': 16, - 'right': 24, - 'left': 68 - } - }, - 'props': { - 'text': title - } - } - }; - - report['root']['data']['childrenIds'].push(titleBlockId); - - rows.forEach((row) => { - this.insertTwoColumnRow(report, row.left, row.right, false, 6); - }); - } - - protected insertTwoColumnRow( - report: TReaderDocument, - leftContent: string, - rightContent: string, - isBold: boolean, - bottomPadding: number - ) { - const containerId = `block-${crypto.randomUUID()}`; - const leftCellId = `block-${crypto.randomUUID()}`; - const rightCellId = `block-${crypto.randomUUID()}`; - - report[leftCellId] = { - 'type': 'Text', - 'data': { - 'style': { - 'fontWeight': 'normal', - 'textAlign': 'left', - 'padding': { - 'top': 0, - 'bottom': 0, - 'right': 24, - 'left': 24 - } - }, - 'props': { - 'text': leftContent - } - } - }; - - report[rightCellId] = { - 'type': 'Text', - 'data': { - 'style': { - 'fontWeight': 'normal', - 'textAlign': 'right', - 'padding': { - 'top': 0, - 'bottom': 0, - 'right': 24, - 'left': 24 - } - }, - 'props': { - 'text': rightContent - } - } - }; - - report[containerId] = { - 'type': 'ColumnsContainer', - 'data': { - 'style': { - 'padding': { - 'top': 0, - 'bottom': bottomPadding || 6, - 'right': 44, - 'left': 44 - } - }, - 'props': { - 'fixedWidths': [ - null, - null, - null - ], - 'columnsCount': 2, - 'columnsGap': 10, - 'columns': [ - { - 'childrenIds': [ - leftCellId - ] - }, - { - 'childrenIds': [ - rightCellId - ] - }, - { - 'childrenIds': [] - } - ] - } - } - }; - - if ( - isBold - && report[leftCellId]['data']['style'] - && report[rightCellId]['data']['style'] - ) { - report[leftCellId]['data']['style']['fontWeight'] = 'bold'; - report[rightCellId]['data']['style']['fontWeight'] = 'bold'; - } - - report['root']['data']['childrenIds'].push(containerId); - } - protected insertFooter(report: TReaderDocument) { const blockId = `block-footer`; diff --git a/backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts new file mode 100644 index 000000000..ea1543e8e --- /dev/null +++ b/backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts @@ -0,0 +1,80 @@ +import { BaseEmailService } from './base-email-service'; +import { EnvironmentVariablesService } from '../environment-variables-service'; +import { RecipientType } from '../models/email-notification-service-event'; + +const environmentVariableService = new EnvironmentVariablesService(); + +/** + * Email service for handling email notifications + */ +export class EmailNotificationService extends BaseEmailService { + + private async getJurisdictionRecipients( + compact: string, + jurisdiction: string, + recipientType: RecipientType + ): Promise { + + const jurisdictionConfig = await this.jurisdictionClient.getJurisdictionConfiguration(compact, jurisdiction); + + switch (recipientType) { + case 'JURISDICTION_OPERATIONS_TEAM': + return jurisdictionConfig.jurisdictionOperationsTeamEmails; + default: + throw new Error(`Unsupported recipient type for compact configuration: ${recipientType}`); + } + } + + /** + * Sends a notification email to a jurisdiction operations team when a practitioner's home state license changes + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param previousJurisdiction - The previous home jurisdiction + * @param newJurisdiction - The new home jurisdiction + */ + public async sendHomeJurisdictionChangeStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + previousJurisdiction: string, + newJurisdiction: string + ): Promise { + this.logger.info('Sending home jurisdiction change state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const recipients = await this.getJurisdictionRecipients( + compact, + jurisdiction, + 'JURISDICTION_OPERATIONS_TEAM' + ); + + if (recipients.length === 0) { + throw new Error(`No recipients found for jurisdiction ${jurisdiction} in compact ${compact}`); + } + + const formattedPreviousJurisdiction = previousJurisdiction.toUpperCase(); + const formattedNewJurisdiction = newJurisdiction.toUpperCase(); + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `Practitioner Home State Change - ${compactConfig.compactName}`; + const bodyText = `This is to notify you that ${providerFirstName} ${providerLastName} has changed their home state from ${formattedPreviousJurisdiction} to ${formattedNewJurisdiction}.\n\n` + + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send home jurisdiction change state notification email' }); + } +} diff --git a/backend/cosmetology-app/lambdas/nodejs/lib/email/index.ts b/backend/cosmetology-app/lambdas/nodejs/lib/email/index.ts index 001ba8e05..6f9d5a2c5 100644 --- a/backend/cosmetology-app/lambdas/nodejs/lib/email/index.ts +++ b/backend/cosmetology-app/lambdas/nodejs/lib/email/index.ts @@ -3,3 +3,4 @@ export { EncumbranceNotificationService } from './encumbrance-notification-servi export { InvestigationNotificationService } from './investigation-notification-service'; export { IngestEventEmailService } from './ingest-event-email-service'; export { EnvironmentBannerService } from './environment-banner-service'; +export { EmailNotificationService } from './email-notification-service'; \ No newline at end of file diff --git a/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts b/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts index 73857a4a6..d06fc9774 100644 --- a/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts +++ b/backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts @@ -1077,4 +1077,95 @@ describe('EmailNotificationServiceLambda', () => { .toThrow('Missing required template variables for privilegeInvestigationClosedStateNotification template.'); }); }); + + describe('Home Jurisdiction Change New State Notification', () => { + const SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'homeJurisdictionChangeNotification', + recipientType: 'JURISDICTION_OPERATIONS_TEAM', + compact: 'cosm', + jurisdiction: 'tx', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + previousJurisdiction: 'tx', + newJurisdiction: 'oh' + } + }; + + it('should successfully send home jurisdiction change notification email', async () => { + const mockTxJurisdictionConfig = { + 'pk': { S: 'cosm#CONFIGURATION' }, + 'sk': { S: 'cosm#JURISDICTION#tx' }, + 'jurisdictionName': { S: 'Texas' }, + 'jurisdictionOperationsTeamEmails': { L: [{ S: 'tx-ops@example.com' }]}, + 'type': { S: 'jurisdiction' } + }; + + mockDynamoDBClient.on(GetItemCommand).callsFake((input) => { + const sk = input.Key.sk.S; + + if (sk === 'cosm#JURISDICTION#tx') { + return Promise.resolve({ Item: mockTxJurisdictionConfig }); + } + if (sk === 'cosm#CONFIGURATION') { + return Promise.resolve({ Item: SAMPLE_COMPACT_CONFIGURATION }); + } + return Promise.resolve({}); + }); + + const response = await lambda.handler( + SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT, + {} as any + ); + + expect(response).toEqual({ + message: 'Email message sent' + }); + + expect(mockDynamoDBClient).toHaveReceivedCommand(GetItemCommand); + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['tx-ops@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Practitioner Home State Change - Audiology and Speech Language Pathology' + } + } + }, + FromEmailAddress: 'CompactConnect ' + }); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain( + 'This is to notify you that John Doe has changed their home state from TX to OH.' + ); + expect(htmlContent).toContain( + 'https://app.test.compactconnect.org/cosm/Licensing/provider-123' + ); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for home jurisdiction change notification template.'); + }); + }); }); diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py index 73c287e58..25de08289 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py @@ -90,7 +90,9 @@ def _license_sort_key(cls, license_record: dict | LicenseData) -> tuple: return (effective_date, date_of_issuance) @classmethod - def find_best_license(cls, license_records: Iterable[dict], home_jurisdiction: str | None = None) -> dict: + def find_best_license( + cls, license_records: Iterable[dict], home_jurisdiction: str | None = None, license_type: str | None = None + ) -> dict: """ Find the best license from a collection of licenses. @@ -99,21 +101,24 @@ def find_best_license(cls, license_records: Iterable[dict], home_jurisdiction: s eligibility and active status are not considered. 1. If home jurisdiction is selected, only consider licenses from that jurisdiction + 2. If license type is specified, only consider licenses of that type. 2. Return the single license with the latest (renewal date or issuance date) :param license_records: An iterable of license records - :param home_jurisdiction: The home jurisdiction selection + :param home_jurisdiction: The home jurisdiction filter + :param license_type: License type filter :return: The best license record """ # If the provider's home jurisdiction was selected, we only consider licenses from that jurisdiction # Unless the provider does not have any licenses in that jurisdiction - # (ie they moved to a non-member jurisdiction) if home_jurisdiction is not None: license_records_in_jurisdiction = cls.get_records_of_type( license_records, ProviderRecordType.LICENSE, _filter=lambda x: x['jurisdiction'] == home_jurisdiction ) - if license_records_in_jurisdiction: - license_records = license_records_in_jurisdiction + license_records = license_records_in_jurisdiction + + if license_type is not None: + license_records = [lic for lic in license_records if lic['licenseType'] == license_type] latest_licenses = sorted(license_records, key=cls._license_sort_key, reverse=True) if not latest_licenses: diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py index 006edb86b..4df727eae 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py @@ -42,3 +42,9 @@ class LicenseRevertDetailSchema(DataEventDetailBaseSchema): startTime = AwareDateTime(required=True, allow_none=False) endTime = AwareDateTime(required=True, allow_none=False) rollbackExecutionName = String(required=True, allow_none=False) + + +class HomeJurisdictionChangeEventDetailSchema(DataEventDetailBaseSchema): + providerId = UUID(required=True, allow_none=False) + licenseType = String(required=True, allow_none=False) + formerHomeJurisdiction = Jurisdiction(required=True, allow_none=False) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py b/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py index 755a130b9..10cc4feea 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py @@ -37,6 +37,20 @@ class InvestigationNotificationTemplateVariables: provider_id: UUID +@dataclass +class HomeJurisdictionChangeNotificationTemplateVariables: + """ + Template variables for license home state change notification emails. + """ + + provider_first_name: str + provider_last_name: str + former_jurisdiction: str + current_jurisdiction: str + license_type: str + provider_id: UUID + + class JurisdictionNotificationMethod(Protocol): """Protocol for Jurisdiction encumbrance notification methods.""" @@ -358,3 +372,37 @@ def send_privilege_investigation_closed_state_notification_email( }, } return self._invoke_lambda(payload) + + def send_provider_home_state_change_email( + self, + *, + compact: str, + jurisdiction: str, + template_variables: HomeJurisdictionChangeNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a license home state change notification email to a state. + + :param compact: Compact name + :param jurisdiction: Jurisdiction to notify + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + if template_variables.provider_id is None: + raise ValueError('provider_id must be provided for state notifications') + + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'homeJurisdictionChangeNotification', + 'recipientType': 'JURISDICTION_OPERATIONS_TEAM', + 'templateVariables': { + 'providerFirstName': template_variables.provider_first_name, + 'providerLastName': template_variables.provider_last_name, + 'providerId': str(template_variables.provider_id), + 'previousJurisdiction': template_variables.former_jurisdiction, + 'newJurisdiction': template_variables.current_jurisdiction, + 'licenseType': template_variables.license_type, + }, + } + return self._invoke_lambda(payload) diff --git a/backend/cosmetology-app/lambdas/python/common/cc_common/event_bus_client.py b/backend/cosmetology-app/lambdas/python/common/cc_common/event_bus_client.py index 03349c3ad..f9f19cd36 100644 --- a/backend/cosmetology-app/lambdas/python/common/cc_common/event_bus_client.py +++ b/backend/cosmetology-app/lambdas/python/common/cc_common/event_bus_client.py @@ -8,6 +8,7 @@ from cc_common.data_model.schema.common import InvestigationAgainstEnum from cc_common.data_model.schema.data_event.api import ( EncumbranceEventDetailSchema, + HomeJurisdictionChangeEventDetailSchema, InvestigationEventDetailSchema, LicenseDeactivationDetailSchema, LicenseRevertDetailSchema, @@ -82,6 +83,45 @@ def generate_license_deactivation_event( 'EventBusName': config.event_bus_name, } + def generate_home_jurisdiction_change_event( + self, + source: str, + compact: str, + jurisdiction: str, + provider_id: UUID, + license_type: str, + former_home_jurisdiction: str, + ) -> dict: + """ + Generate a home jurisdiction change event entry for use with batch writers. + + :param source: The source of the event + :param compact: The compact abbreviation + :param jurisdiction: The new jurisdiction that uploaded the license + :param provider_id: The provider's unique identifier + :param license_type: The type of license + :param former_home_jurisdiction: The former home jurisdiction of the provider + :returns: Event entry dict that can be used with EventBatchWriter + """ + event_detail = { + 'eventTime': config.current_standard_datetime.isoformat(), + 'compact': compact, + 'jurisdiction': jurisdiction, + 'providerId': str(provider_id), + 'licenseType': license_type, + 'formerHomeJurisdiction': former_home_jurisdiction, + } + + home_jurisdiction_change_schema = HomeJurisdictionChangeEventDetailSchema() + loaded_detail = home_jurisdiction_change_schema.load(event_detail) + + return { + 'Source': source, + 'DetailType': 'provider.homeStateChange', + 'Detail': json.dumps(loaded_detail, cls=ResponseEncoder), + 'EventBusName': config.event_bus_name, + } + def publish_license_encumbrance_event( self, source: str, diff --git a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py index 520b1868a..d52888daf 100644 --- a/backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py +++ b/backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py @@ -1,7 +1,16 @@ +import json +from unittest.mock import MagicMock +from uuid import UUID + from cc_common.config import logger from tests import TstLambdas +TEST_COMPACT = 'cosm' +TEST_FORMER_JURISDICTION = 'tn' +TEST_NEW_JURISDICTION = 'oh' +TEST_PROVIDER_ID = UUID('12345678-1234-5678-1234-567812345678') + class TestEmailServiceClient(TstLambdas): def _generate_test_model(self, mock_lambda_client): @@ -17,3 +26,43 @@ def _generate_test_model(self, mock_lambda_client): return EmailServiceClient( lambda_client=mock_lambda_client, email_notification_service_lambda_name='test-lambda-name', logger=logger ) + + def test_send_provider_home_state_change_email_should_invoke_lambda_client_with_expected_parameters(self): + from cc_common.email_service_client import HomeJurisdictionChangeNotificationTemplateVariables + + mock_lambda_client = MagicMock() + test_model = self._generate_test_model(mock_lambda_client) + + test_model.send_provider_home_state_change_email( + compact=TEST_COMPACT, + jurisdiction=TEST_FORMER_JURISDICTION, + template_variables=HomeJurisdictionChangeNotificationTemplateVariables( + provider_first_name='Jane', + provider_last_name='Smith', + former_jurisdiction=TEST_FORMER_JURISDICTION, + current_jurisdiction=TEST_NEW_JURISDICTION, + license_type='Cosmetologist', + provider_id=TEST_PROVIDER_ID, + ), + ) + + mock_lambda_client.invoke.assert_called_once_with( + FunctionName='test-lambda-name', + InvocationType='RequestResponse', + Payload=json.dumps( + { + 'compact': TEST_COMPACT, + 'jurisdiction': TEST_FORMER_JURISDICTION, + 'template': 'homeJurisdictionChangeNotification', + 'recipientType': 'JURISDICTION_OPERATIONS_TEAM', + 'templateVariables': { + 'providerFirstName': 'Jane', + 'providerLastName': 'Smith', + 'providerId': str(TEST_PROVIDER_ID), + 'previousJurisdiction': TEST_FORMER_JURISDICTION, + 'newJurisdiction': TEST_NEW_JURISDICTION, + 'licenseType': 'Cosmetologist', + }, + } + ), + ) diff --git a/backend/cosmetology-app/lambdas/python/data-events/handlers/encumbrance_events.py b/backend/cosmetology-app/lambdas/python/data-events/handlers/encumbrance_events.py index e81dd8ed8..30d836cdb 100644 --- a/backend/cosmetology-app/lambdas/python/data-events/handlers/encumbrance_events.py +++ b/backend/cosmetology-app/lambdas/python/data-events/handlers/encumbrance_events.py @@ -242,8 +242,8 @@ def privilege_encumbrance_notification_listener(message: dict, tracker: Notifica # Get license type name from abbreviation (lookup once at the top) license_type_name = _get_license_type_name(compact, license_type_abbreviation) - # Get provider records to gather notification targets and provider information - provider_records, provider_record = _get_provider_records(compact, provider_id) + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) # State Notifications # Send notification to the state where the privilege is encumbered @@ -432,8 +432,8 @@ def license_encumbrance_notification_listener(message: dict, tracker: Notificati # Get license type name from abbreviation (lookup once at the top) license_type_name = _get_license_type_name(compact, license_type_abbreviation) - # Get provider records to gather notification targets and provider information - provider_records, provider_record = _get_provider_records(compact, provider_id) + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) # State Notifications # Send notification to the state where the license is encumbered diff --git a/backend/cosmetology-app/lambdas/python/data-events/handlers/home_state_change_events.py b/backend/cosmetology-app/lambdas/python/data-events/handlers/home_state_change_events.py new file mode 100644 index 000000000..35d9065b6 --- /dev/null +++ b/backend/cosmetology-app/lambdas/python/data-events/handlers/home_state_change_events.py @@ -0,0 +1,55 @@ +from cc_common.config import config, logger +from cc_common.data_model.schema.data_event.api import HomeJurisdictionChangeEventDetailSchema +from cc_common.email_service_client import HomeJurisdictionChangeNotificationTemplateVariables +from cc_common.utils import sqs_handler + + +@sqs_handler +def home_state_change_notification_listener(message: dict): + """ + Handle home state change events by sending notifications. + + For the Cosmetology compact, the home state for a practitioner is determined by + which license was issued or renewed most recently. If another home state uploads + or renews a license record for that same practitioner with a more recent date, + that state becomes the new home state for that practitioner, and this notification + listener is triggered. + """ + detail_schema = HomeJurisdictionChangeEventDetailSchema() + detail = detail_schema.load(message['detail']) + + compact = detail['compact'] + provider_id = detail['providerId'] + jurisdiction = detail['jurisdiction'] + former_home_jurisdiction = detail['formerHomeJurisdiction'] + license_type = detail['licenseType'] + event_time = detail['eventTime'] + + with logger.append_context_keys( + compact=compact, + provider_id=provider_id, + jurisdiction=jurisdiction, + license_type=license_type, + event_time=event_time, + ): + logger.info('Processing provider home state change event') + + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) + + # Send notification to former state + config.email_service_client.send_provider_home_state_change_email( + compact=compact, + # in the case of cosmetology, we only send the email notification to the former state. + jurisdiction=former_home_jurisdiction, + template_variables=HomeJurisdictionChangeNotificationTemplateVariables( + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + former_jurisdiction=former_home_jurisdiction, + current_jurisdiction=jurisdiction, + license_type=license_type, + provider_id=provider_id, + ), + ) + + logger.info('Successfully processed home state change event') diff --git a/backend/cosmetology-app/lambdas/python/data-events/handlers/investigation_events.py b/backend/cosmetology-app/lambdas/python/data-events/handlers/investigation_events.py index 59ff56f22..579011e6e 100644 --- a/backend/cosmetology-app/lambdas/python/data-events/handlers/investigation_events.py +++ b/backend/cosmetology-app/lambdas/python/data-events/handlers/investigation_events.py @@ -2,7 +2,6 @@ from uuid import UUID from cc_common.config import config, logger -from cc_common.data_model.provider_record_util import ProviderUserRecords from cc_common.data_model.schema.data_event.api import InvestigationEventDetailSchema from cc_common.data_model.schema.provider import ProviderData from cc_common.email_service_client import InvestigationNotificationTemplateVariables @@ -19,27 +18,6 @@ def __call__( ) -> dict[str, Any]: ... -def _get_provider_records(compact: str, provider_id: str) -> tuple[ProviderUserRecords, ProviderData]: - """ - Retrieve and validate provider records for notification processing. - - :param compact: The compact identifier - :param provider_id: The provider ID - :return: Tuple of (provider_records, provider_record) - :raises Exception: If provider records cannot be retrieved - """ - try: - provider_records = config.data_client.get_provider_user_records( - compact=compact, - provider_id=provider_id, - ) - provider_record = provider_records.get_provider_record() - return provider_records, provider_record - except Exception as e: - logger.error('Failed to retrieve provider records for notification', exception=str(e)) - raise - - def _send_primary_state_notification( notification_method: JurisdictionNotificationMethod, notification_type: str, @@ -164,8 +142,8 @@ def license_investigation_notification_listener(message: dict): # Get license type name from abbreviation (lookup once at the top) license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name - # Get provider records to gather notification targets and provider information - provider_records, provider_record = _get_provider_records(compact, provider_id) + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) # State Notifications # Note: We do NOT send notifications to providers for investigations @@ -231,8 +209,8 @@ def license_investigation_closed_notification_listener(message: dict): # Get license type name from abbreviation (lookup once at the top) license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name - # Get provider records to gather notification targets and provider information - provider_records, provider_record = _get_provider_records(compact, provider_id) + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) # State Notifications # Note: We do NOT send notifications to providers for investigations @@ -292,8 +270,8 @@ def privilege_investigation_notification_listener(message: dict): # Get license type name from abbreviation (lookup once at the top) license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name - # Get provider records to gather notification targets and provider information - provider_records, provider_record = _get_provider_records(compact, provider_id) + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) # State Notifications # Note: We do NOT send notifications to providers for investigations @@ -359,11 +337,10 @@ def privilege_investigation_closed_notification_listener(message: dict): # Get license type name from abbreviation (lookup once at the top) license_type_name = LicenseUtility.get_license_type_by_abbreviation(compact, license_type_abbreviation).name - # Get provider records to gather notification targets and provider information - provider_records, provider_record = _get_provider_records(compact, provider_id) + # Get top level provider record to gather provider information + provider_record = config.data_client.get_provider_top_level_record(compact=compact, provider_id=provider_id) # State Notifications - # Note: We do NOT send notifications to providers for investigations # Send notification to the state where the privilege investigation was closed _send_primary_state_notification( config.email_service_client.send_privilege_investigation_closed_state_notification_email, diff --git a/backend/cosmetology-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py b/backend/cosmetology-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py new file mode 100644 index 000000000..fe190d48e --- /dev/null +++ b/backend/cosmetology-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py @@ -0,0 +1,91 @@ +import json +from datetime import datetime +from unittest.mock import patch +from uuid import UUID + +from common_test.test_constants import ( + DEFAULT_COMPACT, + DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + DEFAULT_LICENSE_JURISDICTION, + DEFAULT_LICENSE_TYPE, + DEFAULT_PROVIDER_ID, +) +from moto import mock_aws + +from . import TstFunction + +TEST_FORMER_LICENSE_JURISDICTION = 'az' + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestHomeStateChangeEvents(TstFunction): + """Test suite for investigation event handlers.""" + + def _generate_license_home_state_change_message(self, message_overrides=None): + """Generate a test SQS message for license home state change events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': DEFAULT_LICENSE_JURISDICTION, + 'licenseType': DEFAULT_LICENSE_TYPE, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + 'formerHomeJurisdiction': TEST_FORMER_LICENSE_JURISDICTION, + } + } + if message_overrides: + message['detail'].update(message_overrides) + return message + + def _create_sqs_event(self, message): + """Create a proper SQS event structure with the message in the body.""" + return {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} + + @patch('cc_common.email_service_client.EmailServiceClient.send_provider_home_state_change_email') + def test_license_homes_state_change_listener_sends_notification_to_former_state(self, mock_state_email): + """Test that license home state change listener sends an email notification to the former state.""" + from cc_common.email_service_client import HomeJurisdictionChangeNotificationTemplateVariables + from handlers.home_state_change_events import home_state_change_notification_listener + + # Set up test data with registered provider + self.test_data_generator.put_default_provider_record_in_provider_table() + + # Add the license for the former home state + self.test_data_generator.put_default_license_record_in_provider_table( + value_overrides={'jurisdiction': TEST_FORMER_LICENSE_JURISDICTION} + ) + # Add license for the current home state + self.test_data_generator.put_default_license_record_in_provider_table() + + message = self._generate_license_home_state_change_message() + event = self._create_sqs_event(message) + + # Execute the handler + result = home_state_change_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + expected_template_variables = HomeJurisdictionChangeNotificationTemplateVariables( + provider_first_name='Björk', + provider_last_name='Guðmundsdóttir', + former_jurisdiction=TEST_FORMER_LICENSE_JURISDICTION, + current_jurisdiction=DEFAULT_LICENSE_JURISDICTION, + license_type=DEFAULT_LICENSE_TYPE, + provider_id=UUID(DEFAULT_PROVIDER_ID), + ) + expected_state_call = [ + { + 'compact': DEFAULT_COMPACT, + # we only send to the former home state + 'jurisdiction': TEST_FORMER_LICENSE_JURISDICTION, + 'template_variables': expected_template_variables, + }, + ] + + # Verify state notification was sent + self.assertEqual(1, mock_state_email.call_count) + actual_state_calls = [call.kwargs for call in mock_state_email.call_args_list] + + self.assertEqual(expected_state_call, actual_state_calls) diff --git a/backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py b/backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py index 30207e018..75bb79612 100644 --- a/backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py +++ b/backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py @@ -94,8 +94,8 @@ def _create_sqs_event(self, message): return {'Records': [{'messageId': '123', 'body': json.dumps(message)}]} @patch('cc_common.email_service_client.EmailServiceClient.send_license_investigation_state_notification_email') - def test_license_investigation_listener_processes_event_with_registered_provider(self, mock_state_email): - """Test that license investigation listener processes events for registered providers.""" + def test_license_investigation_listener_processes_event_with_provider(self, mock_state_email): + """Test that license investigation listener processes events for provider.""" from cc_common.email_service_client import InvestigationNotificationTemplateVariables from handlers.investigation_events import license_investigation_notification_listener @@ -158,8 +158,8 @@ def test_license_investigation_listener_processes_event_with_registered_provider @patch( 'cc_common.email_service_client.EmailServiceClient.send_license_investigation_closed_state_notification_email' ) - def test_license_investigation_closed_listener_processes_event_with_registered_provider(self, mock_state_email): - """Test that license investigation closed listener processes events for registered providers.""" + def test_license_investigation_closed_listener_processes_event_with_provider(self, mock_state_email): + """Test that license investigation closed listener processes events for provider.""" from cc_common.email_service_client import InvestigationNotificationTemplateVariables from handlers.investigation_events import license_investigation_closed_notification_listener @@ -219,8 +219,8 @@ def test_license_investigation_closed_listener_processes_event_with_registered_p self.assertEqual(expected_state_calls_sorted, actual_state_calls_sorted) @patch('cc_common.email_service_client.EmailServiceClient.send_privilege_investigation_state_notification_email') - def test_privilege_investigation_listener_processes_event_with_registered_provider(self, mock_state_email): - """Test that privilege investigation listener processes events for registered providers.""" + def test_privilege_investigation_listener_processes_event_with_provider(self, mock_state_email): + """Test that privilege investigation listener processes events for provider.""" from cc_common.email_service_client import InvestigationNotificationTemplateVariables from handlers.investigation_events import privilege_investigation_notification_listener @@ -280,8 +280,8 @@ def test_privilege_investigation_listener_processes_event_with_registered_provid @patch( 'cc_common.email_service_client.EmailServiceClient.send_privilege_investigation_closed_state_notification_email' ) - def test_privilege_investigation_closed_listener_processes_event_with_registered_provider(self, mock_state_email): - """Test that privilege investigation closed listener processes events for registered providers.""" + def test_privilege_investigation_closed_listener_processes_event_with_provider(self, mock_state_email): + """Test that privilege investigation closed listener processes events for provider.""" from cc_common.email_service_client import InvestigationNotificationTemplateVariables from handlers.investigation_events import privilege_investigation_closed_notification_listener diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/ingest.py b/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/ingest.py index dbd30e470..f9337ea27 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/ingest.py +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/ingest.py @@ -11,7 +11,7 @@ from cc_common.data_model.schema.license.record import LicenseUpdateRecordSchema from cc_common.data_model.schema.provider import ProviderData from cc_common.event_batch_writer import EventBatchWriter -from cc_common.exceptions import CCNotFoundException +from cc_common.exceptions import CCInternalException, CCNotFoundException from cc_common.utils import sqs_handler license_schema = LicenseIngestSchema() @@ -117,6 +117,7 @@ def ingest_license_message(message: dict): dynamo_transactions = [] + current_best_license_for_posted_license_type = None try: provider_data = config.data_client.get_provider( compact=compact, @@ -125,10 +126,18 @@ def ingest_license_message(message: dict): consistent_read=True, ) provider_records = provider_data['items'] + license_records = ProviderRecordUtility.get_records_of_type( provider_records, ProviderRecordType.LICENSE, ) + # Best existing license for this license type (none yet if this is the first upload of this type) + try: + current_best_license_for_posted_license_type = ProviderRecordUtility.find_best_license( + license_records=license_records, license_type=posted_license_record.get('licenseType') + ) + except CCInternalException: + current_best_license_for_posted_license_type = None licenses_organized = {} for record in license_records: licenses_organized.setdefault(record['jurisdiction'], {}) @@ -206,6 +215,34 @@ def ingest_license_message(message: dict): # Write the records together as a transaction that succeeds or fails as one, to ensure consistency config.dynamodb_client.transact_write_items(TransactItems=dynamo_transactions) + + # If this posted license is the new best license for the provider for the posted license type, + # and it's from a different jurisdiction, send a home jurisdiction change notification event + # to notify the former home jurisdiction. + best_license_after_upload_for_posted_license_type = ProviderRecordUtility.find_best_license( + license_records=licenses_flattened, license_type=posted_license_record.get('licenseType') + ) + + if ( + current_best_license_for_posted_license_type is not None + and best_license_after_upload_for_posted_license_type.get('jurisdiction') + != current_best_license_for_posted_license_type.get('jurisdiction') + ): + logger.info( + 'New home state license detected. Sending home state change notification.', + previous_home_jurisdiction=current_best_license_for_posted_license_type.get('jurisdiction'), + new_home_jurisdiction=best_license_after_upload_for_posted_license_type.get('jurisdiction'), + ) + home_jurisdiction_change_event = config.event_bus_client.generate_home_jurisdiction_change_event( + source='org.compactconnect.provider-data', + compact=best_license_after_upload_for_posted_license_type['compact'], + jurisdiction=best_license_after_upload_for_posted_license_type['jurisdiction'], + provider_id=best_license_after_upload_for_posted_license_type['providerId'], + license_type=best_license_after_upload_for_posted_license_type['licenseType'], + former_home_jurisdiction=current_best_license_for_posted_license_type.get('jurisdiction'), + ) + data_events.append(home_jurisdiction_change_event) + # We'll save our events until after the transaction is written, to ensure consistency with EventBatchWriter(config.events_client) as event_writer: for event in data_events: diff --git a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py index 6cba9cbb3..f4dba5e72 100644 --- a/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py +++ b/backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py @@ -1,6 +1,6 @@ import json from datetime import date, datetime -from unittest.mock import patch +from unittest.mock import MagicMock, patch from moto import mock_aws @@ -657,3 +657,90 @@ def test_multiple_license_types_different_jurisdictions(self): self.assertEqual('ky', provider_data['licenseJurisdiction']) self.assertEqual('Audrey', provider_data['givenName']) self.assertEqual('Guðmundsdóttir', provider_data['familyName']) + + def test_same_license_types_different_jurisdictions_triggers_home_jurisdiction_change_event_bridge_notification( + self, + ): + """ + Same license type (cosmetologist) in two jurisdictions: a newer issuance from KY replaces OH as the best + cosmetologist license and ingest emits ``provider.homeStateChange`` with former OH and new KY. + """ + import handlers.ingest as ingest_handler + from handlers.ingest import ingest_license_message + + provider_id = self._with_ingested_license() + provider_data_after_first_license = self._get_provider_via_api(provider_id) + + # Verify the first license was ingested correctly + self.assertEqual(1, len(provider_data_after_first_license['licenses'])) + self.assertEqual('cosmetologist', provider_data_after_first_license['licenses'][0]['licenseType']) + self.assertEqual('oh', provider_data_after_first_license['licenseJurisdiction']) + self.assertEqual('Björk', provider_data_after_first_license['givenName']) + + with open('../common/tests/resources/ingest/event-bridge-message.json') as f: + message = json.load(f) + + # Same license type as OH, but KY upload with a newer issuance date → new “home” license jurisdiction for type + message['detail'].update( + { + 'licenseType': 'cosmetologist', + 'jurisdiction': 'ky', + 'dateOfIssuance': '2020-06-06', + 'licenseNumber': 'B0608337260', + 'givenName': 'Audrey', + } + ) + + mock_put_events = MagicMock(return_value={'FailedEntryCount': 0, 'Entries': [{'EventId': 'evt-1'}]}) + # Patch the EventBridge client bound on this lambda's config (setUp replaces the global singleton each test). + with patch.object(ingest_handler.config.events_client, 'put_events', mock_put_events): + event = {'Records': [{'messageId': '456', 'body': json.dumps(message)}]} + resp = ingest_license_message(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, resp) + + mock_put_events.assert_called_once() + entries = mock_put_events.call_args.kwargs['Entries'] + self.assertEqual(1, len(entries)) + home_change_entry = entries[0] + self.assertEqual( + { + 'Detail': json.dumps( + { + 'compact': 'cosm', + 'jurisdiction': 'ky', + 'eventTime': '2024-11-08T23:59:59+00:00', + 'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570', + 'licenseType': 'cosmetologist', + 'formerHomeJurisdiction': 'oh', + } + ), + 'DetailType': 'provider.homeStateChange', + 'EventBusName': 'license-data-events', + 'Source': 'org.compactconnect.provider-data', + }, + home_change_entry, + ) + + provider_data = self._get_provider_via_api(provider_id) + + self.assertEqual(2, len(provider_data['licenses'])) + oh_license = next((lic for lic in provider_data['licenses'] if lic['jurisdiction'] == 'oh'), None) + ky_license = next((lic for lic in provider_data['licenses'] if lic['jurisdiction'] == 'ky'), None) + + # Verify both licenses exist + self.assertIsNotNone(oh_license, 'Ohio license not found') + self.assertIsNotNone(ky_license, 'Kentucky license not found') + + # Verify license details + self.assertEqual('cosmetologist', oh_license['licenseType']) + self.assertEqual('A0608337260', oh_license['licenseNumber']) + self.assertEqual('2010-06-06', oh_license['dateOfIssuance']) + self.assertEqual('Björk', oh_license['givenName']) + + self.assertEqual('cosmetologist', ky_license['licenseType']) + self.assertEqual('B0608337260', ky_license['licenseNumber']) + self.assertEqual('2020-06-06', ky_license['dateOfIssuance']) + self.assertEqual('Audrey', ky_license['givenName']) + + self.assertEqual('ky', provider_data['licenseJurisdiction']) + self.assertEqual('Audrey', provider_data['givenName']) diff --git a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py index 655104374..bf2da9a82 100644 --- a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py +++ b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py @@ -52,13 +52,7 @@ def test_board_ed_user(self): user_data = UserData(self._user_sub) self.assertEqual( - { - 'profile', - 'cosm/readGeneral', - 'al/cosm.admin', - 'al/cosm.write', - 'al/cosm.readPrivate' - }, + {'profile', 'cosm/readGeneral', 'al/cosm.admin', 'al/cosm.write', 'al/cosm.readPrivate'}, user_data.scopes, ) diff --git a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py index 46a3b4b72..fd3a1eff4 100644 --- a/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py +++ b/backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py @@ -53,7 +53,7 @@ def _process_compact_permissions(self, compact_abbr, compact_permissions): disallowed_actions = compact_actions - { CCPermissionsAction.READ, CCPermissionsAction.ADMIN, - CCPermissionsAction.READ_PRIVATE + CCPermissionsAction.READ_PRIVATE, } if disallowed_actions: raise ValueError(f'User {compact_abbr} permissions include disallowed actions: {disallowed_actions}') @@ -83,7 +83,7 @@ def _process_jurisdiction_permissions(self, compact_abbr, jurisdiction_name, jur disallowed_actions = jurisdiction_actions - { CCPermissionsAction.WRITE, CCPermissionsAction.ADMIN, - CCPermissionsAction.READ_PRIVATE + CCPermissionsAction.READ_PRIVATE, } if disallowed_actions: raise ValueError( diff --git a/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py b/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py index c43fe6e32..778629e4d 100644 --- a/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py +++ b/backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py @@ -328,7 +328,7 @@ def _staff_user_permissions_schema(self): properties={ 'write': JsonSchema(type=JsonSchemaType.BOOLEAN), 'admin': JsonSchema(type=JsonSchemaType.BOOLEAN), - 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN) + 'readPrivate': JsonSchema(type=JsonSchemaType.BOOLEAN), }, ), }, diff --git a/backend/cosmetology-app/stacks/notification_stack.py b/backend/cosmetology-app/stacks/notification_stack.py index 4fd2f6fb7..7a8b7fdab 100644 --- a/backend/cosmetology-app/stacks/notification_stack.py +++ b/backend/cosmetology-app/stacks/notification_stack.py @@ -62,6 +62,9 @@ def __init__( self._add_privilege_investigation_closed_notification_listener( persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack ) + self._add_provider_home_state_change_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) def _add_emailer_event_listener( self, @@ -239,3 +242,17 @@ def _add_privilege_investigation_closed_notification_listener( data_event_bus=data_event_bus, event_state_stack=event_state_stack, ) + + def _add_provider_home_state_change_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the provider home state change listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='ProviderHomeJurisdictionChangeNotificationListener', + index='home_state_change_events.py', + handler='home_state_change_notification_listener', + listener_detail_type='provider.homeStateChange', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) diff --git a/backend/cosmetology-app/tests/app/test_notification_stack.py b/backend/cosmetology-app/tests/app/test_notification_stack.py index 9e2a48bd0..305262baa 100644 --- a/backend/cosmetology-app/tests/app/test_notification_stack.py +++ b/backend/cosmetology-app/tests/app/test_notification_stack.py @@ -795,3 +795,112 @@ def test_privilege_investigation_closed_notification_resources_created(self): }, privilege_investigation_closed_rule, ) + + def test_provider_home_jurisdiction_change_notification_listener_resources_created(self): + """ + Test that the provider home jurisdiction change notification listener lambda is added with a SQS queue + and an event bridge event rule that listens for 'provider.homeStateChange' detail types. + """ + notification_stack = self.app.sandbox_backend_stage.notification_stack + notification_template = Template.from_stack(notification_stack) + + # Verify the lambda function is created + provider_home_jurisdiction_change_handler_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.process_function.node.default_child + ) + provider_home_jurisdiction_change_handler = TestNotificationStack.get_resource_properties_by_logical_id( + provider_home_jurisdiction_change_handler_logical_id, + resources=notification_template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + 'handlers.home_state_change_events.home_state_change_notification_listener', + provider_home_jurisdiction_change_handler['Handler'], + ) + + # Verify SQS queue is created + listener_queue_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.queue.node.default_child + ) + listener_queue = TestNotificationStack.get_resource_properties_by_logical_id( + listener_queue_logical_id, + resources=notification_template.find_resources(CfnQueue.CFN_RESOURCE_TYPE_NAME), + ) + + dlq_logical_id = notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.dlq.node.default_child + ) + + # remove dynamic field + del listener_queue['KmsMasterKeyId'] + + self.assertEqual( + { + 'MessageRetentionPeriod': 43200, + 'RedrivePolicy': {'deadLetterTargetArn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}, 'maxReceiveCount': 3}, + 'VisibilityTimeout': 300, + }, + listener_queue, + ) + + # Verify EventBridge rule is created with correct detail type + event_bridge_rule = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].event_rule.node.default_child + ), + resources=notification_template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual( + { + 'EventBusName': { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + {'Fn::Select': [5, {'Fn::Split': [':', {'Ref': 'DataEventBusArnParameterParameter'}]}]}, + ] + }, + ] + }, + 'EventPattern': {'detail-type': ['provider.homeStateChange']}, + 'State': 'ENABLED', + 'Targets': [ + { + 'Arn': {'Fn::GetAtt': [listener_queue_logical_id, 'Arn']}, + 'DeadLetterConfig': {'Arn': {'Fn::GetAtt': [dlq_logical_id, 'Arn']}}, + 'Id': 'Target0', + } + ], + }, + event_bridge_rule, + ) + + # Verify event source mapping is created + event_source_mapping = TestNotificationStack.get_resource_properties_by_logical_id( + notification_stack.get_logical_id( + notification_stack.event_processors[ + 'ProviderHomeJurisdictionChangeNotificationListener' + ].queue_processor.event_source_mapping.node.default_child + ), + resources=notification_template.find_resources(CfnEventSourceMapping.CFN_RESOURCE_TYPE_NAME), + ) + self.assertEqual( + { + 'BatchSize': 10, + 'EventSourceArn': {'Fn::GetAtt': [listener_queue_logical_id, 'Arn']}, + 'FunctionName': {'Ref': provider_home_jurisdiction_change_handler_logical_id}, + 'FunctionResponseTypes': ['ReportBatchItemFailures'], + 'MaximumBatchingWindowInSeconds': 15, + }, + event_source_mapping, + ) diff --git a/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py b/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py index 41f6fe1e8..a71d82288 100644 --- a/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py +++ b/backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py @@ -4,37 +4,47 @@ from datetime import UTC, datetime, timedelta import requests +from boto3.dynamodb.conditions import Key +from compact_configuration_smoke_tests import test_jurisdiction_configuration from config import config, logger from smoke_common import ( SmokeTestFailureException, + call_provider_details_endpoint, create_test_app_client, create_test_staff_user, delete_test_app_client, delete_test_staff_user, - get_api_base_url, get_client_auth_headers, get_data_events_dynamodb_table, get_provider_user_dynamodb_table, get_staff_user_auth_headers, load_smoke_test_env, + wait_for_provider_creation, ) -MOCK_SSN = '999-99-9999' COMPACT = 'cosm' -JURISDICTION = 'az' -TEST_PROVIDER_GIVEN_NAME = 'Joe' -TEST_PROVIDER_FAMILY_NAME = 'Dokes' # This script can be run locally to test the license upload/ingest flow against a sandbox environment. # License POST uses the state API (CC_TEST_STATE_API_BASE_URL) with a short-lived Cognito app client # (CC_TEST_STATE_AUTH_URL, CC_TEST_COGNITO_STATE_AUTH_USER_POOL_ID); provider query/GET use the internal API # (CC_TEST_API_BASE_URL) with a staff user. Configure smoke_tests_env.json from smoke_tests_env_example.json. +# Developer note: this smoke test intentionally polls up to 12 minutes (60s interval) because the +# preprocess and ingest SQS event source mappings currently use 5-minute max batching windows. +# If faster runtime is needed, manually lower those event source mapping batching windows in the +# target environment before running this test. + # Note that by design, developers do not have the ability to delete records from the SSN DynamoDB table, # so this script does not delete the created SSN records as part of cleanup. TEST_STAFF_USER_EMAIL = 'testStaffUserLicenseUploader@smokeTestFakeEmail.com' TEST_APP_CLIENT_NAME = 'test-license-upload-smoke-client' +HOME_STATE_CHANGE_MOCK_SSN = '999-88-8888' +HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME = 'Jane' +HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME = 'TestSmith' +HOME_STATE_CHANGE_LICENSE_TYPE = 'cosmetologist' +HOME_STATE_CHANGE_FORMER_JURISDICTION = 'az' +HOME_STATE_CHANGE_NEW_JURISDICTION = 'oh' def _cleanup_test_generated_records(provider_id: str, license_ingest_record_response: dict): @@ -60,150 +70,255 @@ def _cleanup_test_generated_records(provider_id: str, license_ingest_record_resp logger.info('Successfully deleted license ingest record from data events table') -def upload_licenses_record(license_upload_auth_headers: dict): - """ - Verifies that a license record can be uploaded to the Compact Connect API and the appropriate - records are created in the provider table as well as the data events table. - - Step 1: Upload a license via the state API (POST .../licenses) using state app client credentials. - Step 2: Verify the provider records are added by querying the internal staff API. - Step 3: Verify the license record is recorded in the data events table. - """ - staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) - - # Step 1: State-authenticated license upload (see stacks/state_api_stack). - post_body = [ +def _build_home_state_change_license_post_body(jurisdiction: str, date_of_issuance: str): + return [ { - 'licenseNumber': 'A0608337260', + 'licenseNumber': f'{jurisdiction.upper()}-HOME-STATE-TEST', 'homeAddressPostalCode': '68001', - 'givenName': TEST_PROVIDER_GIVEN_NAME, - 'familyName': TEST_PROVIDER_FAMILY_NAME, - 'homeAddressStreet1': '123 Fake Street', + 'givenName': HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME, + 'familyName': HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME, + 'homeAddressStreet1': '123 Home State Test Street', 'dateOfBirth': '1991-12-10', - 'dateOfIssuance': '2024-12-10', - 'ssn': MOCK_SSN, - 'licenseType': 'cosmetologist', + 'dateOfIssuance': date_of_issuance, + 'ssn': HOME_STATE_CHANGE_MOCK_SSN, + 'licenseType': HOME_STATE_CHANGE_LICENSE_TYPE, 'dateOfExpiration': '2050-12-10', - 'homeAddressState': 'AZ', + 'homeAddressState': jurisdiction.upper(), 'homeAddressCity': 'Omaha', 'compactEligibility': 'eligible', 'licenseStatus': 'active', } ] + +def _post_license_to_state_api(client_id: str, client_secret: str, jurisdiction: str, post_body: list[dict]): + # Access tokens are short lived, so regenerate before each upload call. + license_upload_auth_headers = get_client_auth_headers(client_id, client_secret, COMPACT, jurisdiction) post_response = requests.post( - url=f'{config.state_api_base_url}/v1/compacts/{COMPACT}/jurisdictions/{JURISDICTION}/licenses', + url=f'{config.state_api_base_url}/v1/compacts/{COMPACT}/jurisdictions/{jurisdiction}/licenses', headers=license_upload_auth_headers, json=post_body, timeout=60, ) if post_response.status_code != 200: - raise SmokeTestFailureException(f'Failed to POST license record. Response: {post_response.json()}') - - logger.info(f'License record successfully uploaded {post_response.json()}') - - # Step 2: Verify the provider records are added by querying the API - provider_id = None - - # The preprocessing and ingest SQS queues have a visibility timeout of 5 minutes each - # so we will need to poll until the record is available - for _ in range(30): - # Query the provider API to find the provider by name - query_body = {'query': {'familyName': TEST_PROVIDER_FAMILY_NAME, 'givenName': TEST_PROVIDER_GIVEN_NAME}} - - query_response = requests.post( - url=get_api_base_url() + f'/v1/compacts/{COMPACT}/providers/query', - headers=staff_headers, - json=query_body, - timeout=10, + raise SmokeTestFailureException( + f'Failed to POST home state change license record for {jurisdiction}. Response: {post_response.json()}' ) - if query_response.status_code != 200: - logger.info(f'Query failed with status {query_response.status_code}. Retrying...') - time.sleep(30) - continue - - providers = query_response.json().get('providers', []) - if providers: - # Find our test provider in the results - for provider in providers: - if ( - provider.get('givenName') == TEST_PROVIDER_GIVEN_NAME - and provider.get('familyName') == TEST_PROVIDER_FAMILY_NAME - ): - provider_id = provider.get('providerId') - break - - if provider_id: - break + logger.info(f'Home state change license record successfully uploaded for {jurisdiction}: {post_response.json()}') - logger.info('Provider record not found via API query. Retrying...') - time.sleep(30) - if not provider_id: - raise SmokeTestFailureException('Failed to find provider record via API query.') - - logger.info(f'Provider record successfully found via API query. Provider ID: {provider_id}') - - # Now get the provider details to verify the license record - provider_details_response = requests.get( - url=get_api_base_url() + f'/v1/compacts/{COMPACT}/providers/{provider_id}', - headers=staff_headers, - timeout=10, - ) - - if provider_details_response.status_code != 200: - raise SmokeTestFailureException(f'Failed to get provider details. Response: {provider_details_response.json()}') - - provider_details = provider_details_response.json() - licenses = provider_details.get('licenses', []) - - if not licenses: - raise SmokeTestFailureException('Failed to find license record in provider details.') +def _wait_for_home_state_change_event(provider_id: str, max_wait_seconds: int = 720, poll_interval_seconds: int = 60): + data_events_table = get_data_events_dynamodb_table() + max_attempts = max_wait_seconds // poll_interval_seconds + event_pk = f'COMPACT#{COMPACT}#JURISDICTION#{HOME_STATE_CHANGE_NEW_JURISDICTION}' + + for attempt in range(1, max_attempts + 1): + response = data_events_table.query( + KeyConditionExpression=Key('pk').eq(event_pk) + & Key('sk').begins_with('TYPE#provider.homeStateChange#TIME#'), + FilterExpression='providerId = :provider_id', + ExpressionAttributeValues={':provider_id': provider_id}, + ConsistentRead=True, + ) + matching_event = next(iter(response.get('Items', [])), None) + if matching_event: + logger.info(f'Found provider.homeStateChange data event for provider {provider_id}') + return matching_event - license_record = next( - (license_record for license_record in licenses if license_record.get('licenseType') == 'cosmetologist'), None - ) + if attempt < max_attempts: + logger.info( + f'provider.homeStateChange event not found yet for provider {provider_id}. ' + f'Attempt {attempt}/{max_attempts}. Retrying in {poll_interval_seconds} seconds.' + ) + time.sleep(poll_interval_seconds) - if not license_record: - raise SmokeTestFailureException('Failed to find cosmetologist license record in provider details.') + return None - logger.info(f'License record successfully found in provider details: {license_record}') - # Step 3: Verify the license record is recorded in the data events table. - # we don't loop here because the record should be available in the data events table by the time the - # provider table record is available. We use a consistent read to ensure that we get the latest record. +def _query_license_ingest_events_for_jurisdiction( + jurisdiction: str, provider_id: str, start_time: datetime, end_time: datetime +): data_events_table = get_data_events_dynamodb_table() - event_time = datetime.now(tz=UTC) - start_time = event_time - timedelta(minutes=15) - logger.info('searching for license in data event') - license_ingest_record_response = data_events_table.query( + return data_events_table.query( KeyConditionExpression='pk = :pk AND sk BETWEEN :start_time AND :end_time', + FilterExpression='providerId = :provider_id', ExpressionAttributeValues={ - ':pk': f'COMPACT#{COMPACT}#JURISDICTION#{JURISDICTION}', + ':pk': f'COMPACT#{COMPACT}#JURISDICTION#{jurisdiction}', ':start_time': f'TYPE#license.ingest#TIME#{int(start_time.timestamp())}', - ':end_time': f'TYPE#license.ingest#TIME#{int(event_time.timestamp())}', + ':end_time': f'TYPE#license.ingest#TIME#{int(end_time.timestamp())}', + ':provider_id': provider_id, }, ConsistentRead=True, ) - if not license_ingest_record_response.get('Items'): - logger.error( - f'Failed to find license ingest record in data events table. Response: {license_ingest_record_response}' + +def test_home_state_change_notification(staff_headers: dict, client_id: str, client_secret: str): + start_time = datetime.now(tz=UTC) - timedelta(minutes=2) + provider_id = None + try: + _post_license_to_state_api( + client_id=client_id, + client_secret=client_secret, + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, + post_body=_build_home_state_change_license_post_body( + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, date_of_issuance='2024-01-15' + ), + ) + + provider_id = wait_for_provider_creation( + staff_headers=staff_headers, + compact=COMPACT, + given_name=HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME, + family_name=HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME, + max_wait_time=750, + staff_user_email=TEST_STAFF_USER_EMAIL, + poll_interval_seconds=60, + ) + logger.info(f'Found home state change test provider id {provider_id}') + + refreshed_staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + az_provider_details = call_provider_details_endpoint( + headers=refreshed_staff_headers, compact=COMPACT, provider_id=provider_id + ) + az_cosmetology_licenses = [ + license_record + for license_record in az_provider_details.get('licenses', []) + if license_record.get('licenseType') == HOME_STATE_CHANGE_LICENSE_TYPE + ] + + if len(az_cosmetology_licenses) != 1: + raise SmokeTestFailureException( + f'Expected one {HOME_STATE_CHANGE_LICENSE_TYPE} license after AZ upload, ' + f'found {len(az_cosmetology_licenses)}' + ) + + if az_cosmetology_licenses[0].get('jurisdiction') != HOME_STATE_CHANGE_FORMER_JURISDICTION: + raise SmokeTestFailureException( + 'Expected first home state license jurisdiction to be ' + f'{HOME_STATE_CHANGE_FORMER_JURISDICTION}, found {az_cosmetology_licenses[0].get("jurisdiction")}' + ) + + if az_provider_details.get('licenseJurisdiction') != HOME_STATE_CHANGE_FORMER_JURISDICTION: + raise SmokeTestFailureException( + 'Expected licenseJurisdiction to be ' + f'{HOME_STATE_CHANGE_FORMER_JURISDICTION} after first upload, ' + f'found {az_provider_details.get("licenseJurisdiction")}' + ) + + _post_license_to_state_api( + client_id=client_id, + client_secret=client_secret, + jurisdiction=HOME_STATE_CHANGE_NEW_JURISDICTION, + post_body=_build_home_state_change_license_post_body( + # upload license that was issued at a later date to trigger home state change + jurisdiction=HOME_STATE_CHANGE_NEW_JURISDICTION, + date_of_issuance='2025-06-15', + ), + ) + + home_state_change_event = _wait_for_home_state_change_event( + provider_id=provider_id, max_wait_seconds=750, poll_interval_seconds=60 + ) + if not home_state_change_event: + raise SmokeTestFailureException( + 'Failed to find provider.homeStateChange data event for the home state change smoke test.' + ) + + refreshed_staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + updated_provider_details = call_provider_details_endpoint( + headers=refreshed_staff_headers, compact=COMPACT, provider_id=provider_id + ) + updated_cosmetology_licenses = [ + license_record + for license_record in updated_provider_details.get('licenses', []) + if license_record.get('licenseType') == HOME_STATE_CHANGE_LICENSE_TYPE + ] + updated_jurisdictions = {license_record.get('jurisdiction') for license_record in updated_cosmetology_licenses} + if updated_jurisdictions != {HOME_STATE_CHANGE_FORMER_JURISDICTION, HOME_STATE_CHANGE_NEW_JURISDICTION}: + raise SmokeTestFailureException( + f'Expected cosmetology licenses for both {HOME_STATE_CHANGE_FORMER_JURISDICTION} and ' + f'{HOME_STATE_CHANGE_NEW_JURISDICTION}, found {sorted(updated_jurisdictions)}' + ) + + if updated_provider_details.get('licenseJurisdiction') != HOME_STATE_CHANGE_NEW_JURISDICTION: + raise SmokeTestFailureException( + 'Expected licenseJurisdiction to change to ' + f'{HOME_STATE_CHANGE_NEW_JURISDICTION}, found {updated_provider_details.get("licenseJurisdiction")}' + ) + + logger.info( + 'MANUAL VERIFICATION REQUIRED: check inbox for ' + f'{config.smoke_test_notification_email}. Verify a provider home state change email was sent to ' + f'the former home jurisdiction {HOME_STATE_CHANGE_FORMER_JURISDICTION.upper()} after upload from ' + f'{HOME_STATE_CHANGE_NEW_JURISDICTION.upper()} for provider ' + f'{HOME_STATE_CHANGE_PROVIDER_GIVEN_NAME} {HOME_STATE_CHANGE_PROVIDER_FAMILY_NAME} ({provider_id}).' ) - _cleanup_test_generated_records(provider_id, license_ingest_record_response) - raise SmokeTestFailureException('Failed to find license ingest records in data event table.') + finally: + if provider_id: + logger.info('cleaning up test provider records', provider_id=provider_id) + end_time = datetime.now(tz=UTC) + az_license_ingest_events = _query_license_ingest_events_for_jurisdiction( + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, + provider_id=provider_id, + start_time=start_time, + end_time=end_time, + ) + oh_license_ingest_events = _query_license_ingest_events_for_jurisdiction( + jurisdiction=HOME_STATE_CHANGE_NEW_JURISDICTION, + provider_id=provider_id, + start_time=start_time, + end_time=end_time, + ) + home_state_change_events = _query_home_state_change_events_for_provider(provider_id) + _cleanup_home_state_change_generated_records( + provider_id=provider_id, + az_license_ingest_events=az_license_ingest_events, + oh_license_ingest_events=oh_license_ingest_events, + home_state_change_events=home_state_change_events, + ) + else: + logger.info('Skipping provider cleanup because provider id was never discovered.') + + +def _cleanup_home_state_change_generated_records( + provider_id: str, + az_license_ingest_events: dict, + oh_license_ingest_events: dict, + home_state_change_events: list[dict] | None = None, +): + merged_items = [ + *az_license_ingest_events.get('Items', []), + *oh_license_ingest_events.get('Items', []), + ] + _cleanup_test_generated_records(provider_id, {'Items': merged_items}) - logger.info( - f'License ingest data event successfully added to data events table {license_ingest_record_response["Items"]}' + if home_state_change_events: + data_events_table = get_data_events_dynamodb_table() + for home_state_change_event in home_state_change_events: + data_events_table.delete_item( + Key={'pk': home_state_change_event['pk'], 'sk': home_state_change_event['sk']} + ) + logger.info('Successfully deleted provider.homeStateChange event(s) from data events table') + + +def _query_home_state_change_events_for_provider(provider_id: str): + data_events_table = get_data_events_dynamodb_table() + event_pk = f'COMPACT#{COMPACT}#JURISDICTION#{HOME_STATE_CHANGE_NEW_JURISDICTION}' + response = data_events_table.query( + KeyConditionExpression=Key('pk').eq(event_pk) & Key('sk').begins_with('TYPE#provider.homeStateChange#TIME#'), + ConsistentRead=True, ) - _cleanup_test_generated_records(provider_id, license_ingest_record_response) + return [item for item in response.get('Items', []) if item.get('providerId') == provider_id] if __name__ == '__main__': load_smoke_test_env() + test_jurisdiction_configuration(HOME_STATE_CHANGE_FORMER_JURISDICTION, recreate_compact_config=True) + test_jurisdiction_configuration(HOME_STATE_CHANGE_NEW_JURISDICTION) + test_user_sub = None client_id = None try: @@ -211,15 +326,30 @@ def upload_licenses_record(license_upload_auth_headers: dict): test_user_sub = create_test_staff_user( email=TEST_STAFF_USER_EMAIL, compact=COMPACT, - jurisdiction=JURISDICTION, - permissions={'actions': {'admin'}, 'jurisdictions': {JURISDICTION: {'write', 'admin'}}}, + jurisdiction=HOME_STATE_CHANGE_FORMER_JURISDICTION, + permissions={ + 'actions': {'admin'}, + 'jurisdictions': { + HOME_STATE_CHANGE_FORMER_JURISDICTION: {'write', 'admin'}, + HOME_STATE_CHANGE_NEW_JURISDICTION: {'write', 'admin'}, + }, + }, + ) + + client_credentials = create_test_app_client( + TEST_APP_CLIENT_NAME, + COMPACT, + jurisdictions=[HOME_STATE_CHANGE_FORMER_JURISDICTION, HOME_STATE_CHANGE_NEW_JURISDICTION], ) - client_credentials = create_test_app_client(TEST_APP_CLIENT_NAME, COMPACT, JURISDICTION) client_id = client_credentials['client_id'] client_secret = client_credentials['client_secret'] - license_upload_headers = get_client_auth_headers(client_id, client_secret, COMPACT, JURISDICTION) - upload_licenses_record(license_upload_headers) - logger.info('License record upload smoke test passed') + home_state_change_staff_headers = get_staff_user_auth_headers(TEST_STAFF_USER_EMAIL) + test_home_state_change_notification( + staff_headers=home_state_change_staff_headers, + client_id=client_id, + client_secret=client_secret, + ) + logger.info('Home state change notification smoke test passed') except SmokeTestFailureException as e: logger.error(f'License record upload smoke test failed: {str(e)}') finally: diff --git a/backend/cosmetology-app/tests/smoke/smoke_common.py b/backend/cosmetology-app/tests/smoke/smoke_common.py index 7d54fc0a8..3b65a7513 100644 --- a/backend/cosmetology-app/tests/smoke/smoke_common.py +++ b/backend/cosmetology-app/tests/smoke/smoke_common.py @@ -360,7 +360,13 @@ def query_provider_by_name(staff_headers: dict, compact: str, given_name: str, f def wait_for_provider_creation( - staff_headers: dict, compact: str, given_name: str, family_name: str, max_wait_time: int = 300 + staff_headers: dict, + compact: str, + given_name: str, + family_name: str, + max_wait_time: int = 300, + staff_user_email: str | None = None, + poll_interval_seconds: int = 30, ): """Poll for provider creation after license upload. @@ -369,6 +375,8 @@ def wait_for_provider_creation( :param given_name: Provider's given name :param family_name: Provider's family name :param max_wait_time: Maximum time to wait in seconds (default: 300 = 5 minutes) + :param staff_user_email: Optional staff email; if provided, refresh auth headers on every poll attempt + :param poll_interval_seconds: Poll interval in seconds (default: 30) :return: The provider ID when found :raises SmokeTestFailureException: If provider not found within max_wait_time """ @@ -377,14 +385,14 @@ def wait_for_provider_creation( logger.info(f'Waiting for provider creation for {given_name} {family_name}...') start_time = time.time() - check_interval = 30 # Check every 30 seconds attempts = 0 - max_attempts = max_wait_time // check_interval + max_attempts = max_wait_time // poll_interval_seconds while attempts < max_attempts: attempts += 1 - provider_id = query_provider_by_name(staff_headers, compact, given_name, family_name) + headers = get_staff_user_auth_headers(staff_user_email) if staff_user_email else staff_headers + provider_id = query_provider_by_name(headers, compact, given_name, family_name) if provider_id: elapsed_time = time.time() - start_time logger.info(f'✅ Provider found after {elapsed_time:.1f} seconds. Provider ID: {provider_id}') @@ -392,9 +400,9 @@ def wait_for_provider_creation( if attempts < max_attempts: logger.info( - f'Attempt {attempts}/{max_attempts}: Provider not found yet. Waiting {check_interval} seconds...' + f'Attempt {attempts}/{max_attempts}: Provider not found yet. Waiting {poll_interval_seconds} seconds...' ) - time.sleep(check_interval) + time.sleep(poll_interval_seconds) elapsed_time = time.time() - start_time raise SmokeTestFailureException( @@ -427,19 +435,33 @@ def cleanup_test_provider_records(provider_id: str, compact: str): logger.warning(f'Error during cleanup: {str(e)}') -def create_test_app_client(client_name: str, compact: str, jurisdiction: str): +def create_test_app_client( + client_name: str, + compact: str, + jurisdiction: str | None = None, + jurisdictions: list[str] | None = None, +): """ Create a test app client in Cognito for authentication testing. :param client_name: Name for the test app client :param compact: Compact abbreviation - :param jurisdiction: Jurisdiction abbreviation + :param jurisdiction: Jurisdiction abbreviation (backward-compatible single value) + :param jurisdictions: Optional list of jurisdiction abbreviations for write scopes :return: Dictionary containing client_id and client_secret """ logger.info(f'Creating test app client: {client_name}') try: cognito_client = boto3.client('cognito-idp') + jurisdiction_list = jurisdictions if jurisdictions else ([jurisdiction] if jurisdiction else []) + if not jurisdiction_list: + raise SmokeTestFailureException('At least one jurisdiction is required to create a test app client') + + allowed_scopes = [ + f'{compact}/readGeneral', + *[f'{jurisdiction}/{compact}.write' for jurisdiction in jurisdiction_list], + ] # Create the user pool client response = cognito_client.create_user_pool_client( @@ -451,7 +473,7 @@ def create_test_app_client(client_name: str, compact: str, jurisdiction: str): AccessTokenValidity=15, AllowedOAuthFlowsUserPoolClient=True, AllowedOAuthFlows=['client_credentials'], - AllowedOAuthScopes=[f'{compact}/readGeneral', f'{jurisdiction}/{compact}.write'], + AllowedOAuthScopes=allowed_scopes, ) user_pool_client = response.get('UserPoolClient', {}) diff --git a/backend/multi-account/README.md b/backend/multi-account/README.md index d84b012ff..7c9aae255 100644 --- a/backend/multi-account/README.md +++ b/backend/multi-account/README.md @@ -124,6 +124,7 @@ We do not want users updating runtime code or deleting critical resources outsid "Sid": "DenyComputeAndBackupUpdates", "Effect": "Deny", "Action": [ + "ec2:RunInstances", "lambda:Delete*", "lambda:Create*", "lambda:Update*", @@ -155,6 +156,7 @@ We do not want users updating runtime code or deleting critical resources outsid "Sid": "DenyResourceModification", "Effect": "Deny", "Action": [ + "dynamodb:BatchWriteItem", "dynamodb:Delete*", "s3:Delete*", "s3:Create*",