From 164f8613841c2460d3a7f4a52ce7d8a964174731 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Mon, 10 Aug 2026 12:43:05 -0500 Subject: [PATCH 01/23] Add 'lastLoginAt' field to staff user schemas --- .../cc_common/data_model/schema/user/api.py | 2 + .../data_model/schema/user/record.py | 5 +- .../function/test_handlers/test_get_users.py | 50 ++++++++++++++++++ .../test_data_model/test_schema/test_user.py | 52 +++++++++++++++++++ 4 files changed, 108 insertions(+), 1 deletion(-) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py index 010a8bb5a2..8d17a7a4e3 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/api.py @@ -45,6 +45,8 @@ class UserAPISchema(Schema): userId = Raw(required=True, allow_none=False) status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus])) dateOfUpdate = Raw(required=True, allow_none=False) + # Absent for users who have not signed in + lastLoginAt = Raw(required=False, allow_none=False) attributes = Nested(UserAttributesAPISchema(), required=True, allow_none=False) permissions = Dict( keys=String(validate=OneOf(config.compacts)), # Key is one compact diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py index 715fccf877..4622ceeded 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/record.py @@ -1,6 +1,6 @@ # ruff: noqa: N801, N815 invalid-name from marshmallow import Schema, post_dump, post_load, pre_dump -from marshmallow.fields import UUID, Dict, Nested, String +from marshmallow.fields import UUID, AwareDateTime, Dict, Nested, String from marshmallow.validate import Length, OneOf from cc_common.config import config @@ -49,6 +49,9 @@ class UserRecordSchema(BaseRecordSchema): compact = String(required=True, allow_none=False, validate=OneOf(config.compacts)) permissions = Nested(CompactPermissionsRecordSchema(), required=True, allow_none=False) status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus])) + # Set by the pre-token-generation hook on every successful access token generation. Absent for users who + # have not signed in + lastLoginAt = AwareDateTime(required=False, allow_none=False) # Generated fields famGiv = String(required=True, allow_none=False) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py index 54397e4fa6..58de2f4a03 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_get_users.py @@ -87,3 +87,53 @@ def test_get_users_paginated(self): second_users = body['users'] self.assertEqual(5, len(second_users)) self.assertIsNone(body['pagination']['lastKey']) + + def test_get_users_returns_last_login_at(self): + """A stored lastLoginAt must survive the record -> API schema transform. + + UserAPISchema raises on unknown fields, so a field present on the record but undeclared + there would fail validation for every staff user this endpoint returns. + """ + last_login_at = '2024-09-12T12:34:56+00:00' + user_id = self._create_compact_staff_user(compacts=['socw']) + self._table.update_item( + Key={'pk': f'USER#{user_id}', 'sk': 'COMPACT#socw'}, + UpdateExpression='SET lastLoginAt = :lastLoginAt', + ExpressionAttributeValues={':lastLoginAt': last_login_at}, + ) + + from handlers.users import get_users + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = None + + resp = get_users(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + users = json.loads(resp['body'])['users'] + self.assertEqual(1, len(users)) + self.assertEqual(last_login_at, users[0]['lastLoginAt']) + + def test_get_users_omits_absent_last_login_at(self): + """Users who have never signed in simply have no lastLoginAt -- not a null.""" + self._create_compact_staff_user(compacts=['socw']) + + from handlers.users import get_users + + with open('tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email socw/admin' + event['pathParameters'] = {'compact': 'socw'} + event['body'] = None + + resp = get_users(event, self.mock_context) + + self.assertEqual(200, resp['statusCode']) + users = json.loads(resp['body'])['users'] + self.assertEqual(1, len(users)) + self.assertNotIn('lastLoginAt', users[0]) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py index 875bd2a9a4..9cfe21b541 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_data_model/test_schema/test_user.py @@ -1,10 +1,13 @@ import json +from datetime import datetime from aws_lambda_powertools.utilities.data_classes.dynamo_db_stream_event import TypeDeserializer from marshmallow import ValidationError from tests import TstLambdas +TEST_LAST_LOGIN_AT = '2024-09-12T12:34:56+00:00' + class TestUserRecordSchema(TstLambdas): def test_transform_api_to_dynamo_permissions(self): @@ -67,3 +70,52 @@ def test_invalid_record(self): with self.assertRaises(ValidationError): UserRecordSchema().load(user_data) + + def test_record_loads_last_login_at(self): + from cc_common.data_model.schema.user.record import UserRecordSchema + + user_data = self._load_dynamo_user() + user_data['lastLoginAt'] = TEST_LAST_LOGIN_AT + + loaded_user = UserRecordSchema().load(user_data) + + self.assertEqual(datetime.fromisoformat(TEST_LAST_LOGIN_AT), loaded_user['lastLoginAt']) + + def test_record_loads_without_last_login_at(self): + """lastLoginAt is absent for users who have not signed in since login tracking was introduced.""" + from cc_common.data_model.schema.user.record import UserRecordSchema + + loaded_user = UserRecordSchema().load(self._load_dynamo_user()) + + self.assertNotIn('lastLoginAt', loaded_user) + + def test_record_round_trips_last_login_at(self): + from cc_common.data_model.schema.user.record import UserRecordSchema + + user_data = self._load_dynamo_user() + user_data['lastLoginAt'] = TEST_LAST_LOGIN_AT + + schema = UserRecordSchema() + dumped_user = schema.dump(schema.load(user_data)) + + self.assertEqual(TEST_LAST_LOGIN_AT, dumped_user['lastLoginAt']) + + def test_api_schema_accepts_last_login_at(self): + """UserAPISchema is a strict Schema, so every field the record schema loads must be declared there. + + Without this, adding a field to the record schema breaks the staff user endpoints for every user. + """ + from cc_common.data_model.schema.user.api import UserAPISchema + from cc_common.data_model.schema.user.record import UserRecordSchema + + user_data = self._load_dynamo_user() + user_data['lastLoginAt'] = TEST_LAST_LOGIN_AT + + loaded_user = UserAPISchema().load(UserRecordSchema().load(user_data)) + + self.assertEqual(datetime.fromisoformat(TEST_LAST_LOGIN_AT), loaded_user['lastLoginAt']) + + @staticmethod + def _load_dynamo_user() -> dict: + with open('../common/tests/resources/dynamo/user.json') as f: + return TypeDeserializer().deserialize({'M': json.load(f)}) From 5ed7915c2b399a07a8c337c7a6eaa6072cdc063a Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Mon, 10 Aug 2026 12:59:13 -0500 Subject: [PATCH 02/23] Add data class for staff user data --- .../data_model/schema/user/__init__.py | 70 +++++++++++++++++++ .../common/common_test/test_constants.py | 4 ++ .../common/common_test/test_data_generator.py | 24 +++++++ 3 files changed, 98 insertions(+) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py index e69de29bb2..b3926bc48b 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/schema/user/__init__.py @@ -0,0 +1,70 @@ +# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition + +from datetime import datetime +from uuid import UUID + +from cc_common.data_model.schema.common import CCDataClass, CCPermissionsAction +from cc_common.data_model.schema.user.record import UserRecordSchema + + +class StaffUserData(CCDataClass): + """ + Class representing a Staff User with getters for all properties. + """ + + # Define record schema at the class level + _record_schema = UserRecordSchema() + + # Require valid data when creating instances + _requires_data_at_construction = True + + @property + def userId(self) -> UUID: + return self._data['userId'] + + @property + def compact(self) -> str: + return self._data['compact'] + + @property + def status(self) -> str: + return self._data['status'] + + @property + def lastLoginAt(self) -> datetime | None: + """Absent for users who have not signed in since login tracking was introduced.""" + return self._data.get('lastLoginAt') + + # The attributes field is flattened here, since its nesting is a storage detail + @property + def email(self) -> str: + return self._data['attributes']['email'] + + @property + def givenName(self) -> str: + return self._data['attributes']['givenName'] + + @property + def familyName(self) -> str: + return self._data['attributes']['familyName'] + + @property + def compactActions(self) -> set[str]: + """Compact-level actions. Empty if the user only has jurisdiction permissions.""" + return self._data['permissions'].get('actions', set()) + + @property + def jurisdictions(self) -> set[str]: + """Jurisdiction codes where this user holds any permission.""" + return set(self._data['permissions']['jurisdictions'].keys()) + + def jurisdictionActions(self, jurisdiction: str) -> set[str]: + """The user's actions in one jurisdiction. Empty if they hold no permissions there.""" + return self._data['permissions']['jurisdictions'].get(jurisdiction, set()) + + @property + def isCompactAdmin(self) -> bool: + return CCPermissionsAction.ADMIN in self.compactActions + + def isJurisdictionAdmin(self, jurisdiction: str) -> bool: + return CCPermissionsAction.ADMIN in self.jurisdictionActions(jurisdiction) diff --git a/backend/social-work-app/lambdas/python/common/common_test/test_constants.py b/backend/social-work-app/lambdas/python/common/common_test/test_constants.py index d343d88163..f620bd3bd1 100644 --- a/backend/social-work-app/lambdas/python/common/common_test/test_constants.py +++ b/backend/social-work-app/lambdas/python/common/common_test/test_constants.py @@ -63,6 +63,10 @@ DEFAULT_EMAIL_ADDRESS = 'björk@example.com' DEFAULT_PHONE_NUMBER = '+13213214321' +# Staff user defaults +DEFAULT_STAFF_USER_ID = 'a4182428-d061-701c-82e5-a3d1d547d797' +DEFAULT_STAFF_USER_LAST_LOGIN_TIMESTAMP = '2024-11-08T23:59:59+00:00' + # record type constants ADVERSE_ACTION_RECORD_TYPE = 'adverseAction' LICENSE_RECORD_TYPE = 'license' diff --git a/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py b/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py index 693ee2739c..bc099555a8 100644 --- a/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py +++ b/backend/social-work-app/lambdas/python/common/common_test/test_data_generator.py @@ -10,6 +10,7 @@ from cc_common.data_model.schema.jurisdiction import JurisdictionConfigurationData from cc_common.data_model.schema.license import LicenseData, LicenseUpdateData from cc_common.data_model.schema.provider import ProviderData, ProviderUpdateData +from cc_common.data_model.schema.user import StaffUserData from cc_common.utils import ResponseEncoder from common_test.test_constants import * @@ -153,6 +154,29 @@ def generate_default_adverse_action(value_overrides: dict | None = None) -> Adve return AdverseActionData.create_new(default_adverse_actions) + @staticmethod + def generate_default_staff_user(value_overrides: dict | None = None) -> StaffUserData: + """Generate a default staff user with no permissions""" + from cc_common.data_model.schema.common import StaffUserStatus + + default_staff_user = { + 'userId': DEFAULT_STAFF_USER_ID, + 'compact': DEFAULT_COMPACT, + 'type': 'user', + 'status': StaffUserStatus.ACTIVE.value, + 'attributes': { + 'email': DEFAULT_EMAIL_ADDRESS, + 'givenName': DEFAULT_GIVEN_NAME, + 'familyName': DEFAULT_FAMILY_NAME, + }, + 'permissions': {'actions': set(), 'jurisdictions': {}}, + 'lastLoginAt': datetime.fromisoformat(DEFAULT_STAFF_USER_LAST_LOGIN_TIMESTAMP), + } + if value_overrides: + default_staff_user.update(value_overrides) + + return StaffUserData.create_new(default_staff_user) + @staticmethod def generate_default_investigation(value_overrides: dict | None = None) -> InvestigationData: """Generate a default investigation""" From 43f0dec3acc56bee67702182facdc9635c0caa8f Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Mon, 10 Aug 2026 13:41:02 -0500 Subject: [PATCH 03/23] Add client method to record login timestamp --- .../cc_common/data_model/user_client.py | 30 ++++++ .../test_data_model/test_user_client.py | 101 ++++++++++++++++++ 2 files changed, 131 insertions(+) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index eea8686687..58f86651e5 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -94,6 +94,36 @@ def get_users_sorted_by_family_name( **dynamo_pagination, ) + def record_user_login(self, *, user_id: str, compacts: Iterable[str]) -> None: + """Mark the user active and stamp lastLoginAt on each of the user's compact records. + + Called from the pre-token-generation hook on every successful access token generation. + + :param str user_id: The user that just signed in + :param Iterable[str] compacts: The compacts the user has records in + """ + logger.info('Recording staff user login', user_id=user_id) + + last_login_at = self.config.current_standard_datetime.isoformat() + for compact in compacts: + try: + self.config.users_table.update_item( + Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, + UpdateExpression='SET #status = :status, lastLoginAt = :lastLoginAt', + # Without this, an update against a missing record would create a stub record that has + # none of the fields a user record requires + ConditionExpression=Attr('pk').exists(), + ExpressionAttributeNames={'#status': 'status'}, + ExpressionAttributeValues={ + ':status': StaffUserStatus.ACTIVE.value, + ':lastLoginAt': last_login_at, + }, + ) + except ClientError as e: + if e.response['Error']['Code'] == 'ConditionalCheckFailedException': + raise CCNotFoundException('User not found for compact') from e + raise + def update_user_permissions( self, *, diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py index e2f501d68a..66880f2183 100644 --- a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -31,6 +31,107 @@ def test_get_user_in_compact(self): ) self.assertEqual(UUID(user_id), user['userId']) + def _get_user_record(self, user_id: str, compact: str = 'socw') -> dict: + return self.config.users_table.get_item(Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'})['Item'] + + def test_record_user_login_sets_last_login_at_and_status(self): + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + # The fixture user has never signed in + self.assertEqual(StaffUserStatus.INACTIVE.value, self._get_user_record(user_id)['status']) + + login_time = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + with patch('cc_common.config._Config.current_standard_datetime', login_time): + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) + + user_record = self._get_user_record(user_id) + self.assertEqual(login_time.isoformat(), user_record['lastLoginAt']) + self.assertEqual(StaffUserStatus.ACTIVE.value, user_record['status']) + + def test_record_user_login_refreshes_last_login_at_for_active_user(self): + """An already-active user still gets a fresh lastLoginAt on every sign-in.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + client = UserClient(self.config) + + first_login = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + with patch('cc_common.config._Config.current_standard_datetime', first_login): + client.record_user_login(user_id=user_id, compacts=['socw']) + + second_login = datetime.fromisoformat('2024-12-25T08:00:00+00:00') + with patch('cc_common.config._Config.current_standard_datetime', second_login): + client.record_user_login(user_id=user_id, compacts=['socw']) + + self.assertEqual(second_login.isoformat(), self._get_user_record(user_id)['lastLoginAt']) + + def test_record_user_login_leaves_other_fields_untouched(self): + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + original_record = self._get_user_record(user_id) + + with patch( + 'cc_common.config._Config.current_standard_datetime', + datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + ): + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) + + updated_record = self._get_user_record(user_id) + for field in ('attributes', 'permissions', 'famGiv', 'compact', 'type', 'userId'): + self.assertEqual(original_record[field], updated_record[field], f'{field} should not have changed') + + def test_record_user_login_updates_every_compact_record(self): + """A user with records in several compacts gets every one of them stamped.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + # Seed a second compact record for the same user. Written directly, since the record schema only + # permits the compacts this app is configured for. + self.config.users_table.put_item( + Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} + ) + + login_time = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + with patch('cc_common.config._Config.current_standard_datetime', login_time): + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + + for compact in ('socw', 'some-other-compact'): + self.assertEqual( + login_time.isoformat(), + self._get_user_record(user_id, compact)['lastLoginAt'], + f'the {compact} record should have been stamped', + ) + + def test_record_user_login_raises_when_record_does_not_exist(self): + """An update against a missing record must not silently create a stub user record. + + The condition is evaluated per item, so a user existing in one compact does not cover a + compact they have no record in. This stamps socw before it raises for the missing compact - + a partial update is acceptable here, since the next successful sign-in re-stamps everything. + """ + from cc_common.data_model.user_client import UserClient + from cc_common.exceptions import CCNotFoundException + + # This user only has a socw record + user_id = self._load_user_data() + + with ( + patch( + 'cc_common.config._Config.current_standard_datetime', + datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + ), + self.assertRaises(CCNotFoundException), + ): + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + + stub_record = self.config.users_table.get_item( + Key={'pk': f'USER#{user_id}', 'sk': 'COMPACT#some-other-compact'} + ) + self.assertNotIn('Item', stub_record) + def test_get_user_in_compact_not_found(self): """User ID not found should raise an exception""" from cc_common.data_model.user_client import UserClient From 047e5ab95ea576091c9f7f1fe5cfc89d5556de17 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Mon, 10 Aug 2026 13:50:51 -0500 Subject: [PATCH 04/23] Add hook to update login timestamp to token generation lambda --- .../python/staff-user-pre-token/main.py | 23 +++--- .../staff-user-pre-token/tests/test_main.py | 78 +++++++++++++++++++ 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py index 8908b12ef6..45d43a36f6 100644 --- a/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/main.py @@ -4,7 +4,6 @@ from aws_lambda_powertools import Logger from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config -from cc_common.data_model.schema.common import StaffUserStatus from user_data import UserData logger = Logger() @@ -34,23 +33,23 @@ def customize_scopes(event: dict, context: LambdaContext): # noqa: ARG001 unuse user_data = UserData(sub) logger.debug('Adding scopes', scopes=user_data.scopes) - # Get all the user's records and set their status to active - for record in user_data.records: - # Only update the status if it's not already active - if record['status'] != StaffUserStatus.ACTIVE.value: - config.users_table.update_item( - Key={'pk': record['pk'], 'sk': record['sk']}, - UpdateExpression='SET #status = :status', - ExpressionAttributeNames={'#status': 'status'}, - ExpressionAttributeValues={':status': StaffUserStatus.ACTIVE.value}, - ) - # We want to catch almost any exception here, so we can gracefully return execution back to AWS except Exception as e: # noqa: BLE001 broad-exception-caught logger.error('Error while getting user scopes!', exc_info=e) event['response']['claimsAndScopeOverrideDetails'] = None return event + # Mark the user active and stamp lastLoginAt on each of their records. This login timestamp update + # is best attempt, so a failure here is logged and swallowed rather than costing the user the + # scopes we just calculated. + try: + config.user_client.record_user_login( + user_id=sub, + compacts={record['compact'] for record in user_data.records}, + ) + except Exception as e: # noqa: BLE001 broad-exception-caught + logger.error('Error while recording user login!', exc_info=e) + event['response']['claimsAndScopeOverrideDetails'] = { 'accessTokenGeneration': { 'scopesToAdd': list(user_data.scopes), diff --git a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py index 5fcfe02e6a..159f5f3f61 100644 --- a/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py +++ b/backend/social-work-app/lambdas/python/staff-user-pre-token/tests/test_main.py @@ -1,13 +1,91 @@ import json +from datetime import datetime from unittest.mock import patch from moto import mock_aws from tests import TstLambdas +TEST_LOGIN_TIME = '2024-11-08T23:59:59+00:00' + @mock_aws class TestCustomizeScopes(TstLambdas): + def _put_user_record(self, sub: str, *, status: str, last_login_at: str | None = None): + item = { + 'pk': f'USER#{sub}', + 'sk': 'COMPACT#socw', + 'compact': 'socw', + 'status': status, + 'permissions': { + 'jurisdictions': { + # should correspond to the 'al/socw.write' scope + 'al': {'write'} + }, + }, + } + if last_login_at is not None: + item['lastLoginAt'] = last_login_at + self._table.put_item(Item=item) + + def _get_user_record(self, sub: str) -> dict: + return self._table.get_item(Key={'pk': f'USER#{sub}', 'sk': 'COMPACT#socw'})['Item'] + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(TEST_LOGIN_TIME)) + def test_records_last_login(self): + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + from cc_common.data_model.schema.common import StaffUserStatus + + self._put_user_record(sub, status=StaffUserStatus.INACTIVE.value) + + customize_scopes(event, self.mock_context) + + self.assertEqual(TEST_LOGIN_TIME, self._get_user_record(sub)['lastLoginAt']) + + @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(TEST_LOGIN_TIME)) + def test_refreshes_last_login_for_already_active_user(self): + """The previous implementation skipped the write entirely for active users, which would have + frozen lastLoginAt at the user's first ever sign-in.""" + from cc_common.data_model.schema.common import StaffUserStatus + from main import customize_scopes + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + self._put_user_record(sub, status=StaffUserStatus.ACTIVE.value, last_login_at='2024-01-01T00:00:00+00:00') + + customize_scopes(event, self.mock_context) + + self.assertEqual(TEST_LOGIN_TIME, self._get_user_record(sub)['lastLoginAt']) + + @patch('cc_common.data_model.user_client.UserClient.record_user_login') + def test_login_recording_failure_does_not_block_authentication(self, mock_record_user_login): + """Recording the login is bookkeeping - a failure there must never cost the user their scopes.""" + from cc_common.data_model.schema.common import StaffUserStatus + from main import customize_scopes + + mock_record_user_login.side_effect = RuntimeError('Oh noes!') + + with open('tests/resources/pre-token-event.json') as f: + event = json.load(f) + sub = event['request']['userAttributes']['sub'] + + self._put_user_record(sub, status=StaffUserStatus.INACTIVE.value) + + resp = customize_scopes(event, self.mock_context) + + mock_record_user_login.assert_called_once() + self.assertEqual( + sorted(['profile', 'socw/readGeneral', 'al/socw.write']), + sorted(resp['response']['claimsAndScopeOverrideDetails']['accessTokenGeneration']['scopesToAdd']), + ) + def test_happy_path(self): from cc_common.data_model.schema.common import StaffUserStatus from main import customize_scopes From a4b026c2da38f853a587e0eb99ef096336f66ac5 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Mon, 10 Aug 2026 15:39:41 -0500 Subject: [PATCH 05/23] Add method to list all staff users for a compact --- .../cc_common/data_model/user_client.py | 29 ++++++- .../test_data_model/test_user_client.py | 81 +++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index 58f86651e5..98354ab82b 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -1,4 +1,4 @@ -from collections.abc import Iterable +from collections.abc import Generator, Iterable from enum import StrEnum from secrets import token_hex @@ -8,6 +8,7 @@ from cc_common.config import _Config, logger from cc_common.data_model.query_paginator import paginated_query from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.user import StaffUserData from cc_common.data_model.schema.user.record import ( CompactPermissionsRecordSchema, UserAttributesRecordSchema, @@ -94,6 +95,32 @@ def get_users_sorted_by_family_name( **dynamo_pagination, ) + def iterate_all_users_in_compact(self, *, compact: str) -> Generator[StaffUserData, None, None]: + """Yield every staff user in a compact, transparently following LastEvaluatedKey. + + Distinct from get_users_sorted_by_family_name, which returns a single API-facing page. This is + for batch work that has to consider every user in the compact. + + :param str compact: The compact to iterate over + """ + logger.info('Iterating over all staff users in compact', compact=compact) + + pagination = {} + while True: + response = self.config.users_table.query( + IndexName=self.config.fam_giv_index_name, + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('sk').eq(f'COMPACT#{compact}'), + **pagination, + ) + for item in response.get('Items', []): + yield StaffUserData.from_database_record(item) + + last_key = response.get('LastEvaluatedKey') + if last_key is None: + break + pagination = {'ExclusiveStartKey': last_key} + def record_user_login(self, *, user_id: str, compacts: Iterable[str]) -> None: """Mark the user active and stamp lastLoginAt on each of the user's compact records. diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py index 66880f2183..100390a3f2 100644 --- a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -132,6 +132,87 @@ def test_record_user_login_raises_when_record_does_not_exist(self): ) self.assertNotIn('Item', stub_record) + def _put_oversized_users(self, count: int, *, compact: str = 'socw') -> set[str]: + """Write `count` deliberately oversized user records, so a GSI query has to paginate. + + DynamoDB caps a query page at 1MB, so ~100KB of padding per record forces LastEvaluatedKey + after a handful of items. The padding is an unknown field, dropped when the record loads. + """ + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + schema = UserRecordSchema() + user_ids = set() + with self.config.users_table.batch_writer() as batch: + for i in range(count): + user_id = str(uuid4()) + user_ids.add(user_id) + record = schema.dump( + { + 'userId': user_id, + 'compact': compact, + 'status': StaffUserStatus.ACTIVE.value, + 'attributes': { + 'email': f'user{i}@example.com', + 'givenName': f'Given{i:04d}', + 'familyName': f'Family{i:04d}', + }, + 'permissions': {'actions': set(), 'jurisdictions': {}}, + } + ) + record['padding'] = 'x' * 100_000 + batch.put_item(Item=record) + return user_ids + + def test_iterate_all_users_in_compact_yields_every_user_across_pages(self): + from boto3.dynamodb.conditions import Key + from cc_common.data_model.user_client import UserClient + + expected_user_ids = self._put_oversized_users(12) + + # Guard the premise of this test: one query page must not be able to hold all of these + # records, or we would not be exercising the pagination loop at all. + single_page = self.config.users_table.query( + IndexName=self.config.fam_giv_index_name, + Select='ALL_ATTRIBUTES', + KeyConditionExpression=Key('sk').eq('COMPACT#socw'), + ) + self.assertIn('LastEvaluatedKey', single_page) + + users = list(UserClient(self.config).iterate_all_users_in_compact(compact='socw')) + + self.assertEqual(expected_user_ids, {str(user.userId) for user in users}) + + def test_iterate_all_users_in_compact_yields_staff_user_data(self): + from cc_common.data_model.schema.user import StaffUserData + from cc_common.data_model.user_client import UserClient + + self._load_user_data() + + users = list(UserClient(self.config).iterate_all_users_in_compact(compact='socw')) + + self.assertEqual(1, len(users)) + self.assertIsInstance(users[0], StaffUserData) + self.assertEqual('justin@example.org', users[0].email) + + def test_iterate_all_users_in_compact_excludes_other_compacts(self): + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + # A record for the same user in another compact must not come back on a socw query + self.config.users_table.put_item( + Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} + ) + + users = list(UserClient(self.config).iterate_all_users_in_compact(compact='socw')) + + self.assertEqual(['socw'], [user.compact for user in users]) + + def test_iterate_all_users_in_compact_yields_nothing_when_empty(self): + from cc_common.data_model.user_client import UserClient + + self.assertEqual([], list(UserClient(self.config).iterate_all_users_in_compact(compact='socw'))) + def test_get_user_in_compact_not_found(self): """User ID not found should raise an exception""" from cc_common.data_model.user_client import UserClient From 4dae80a90ec7c70729335140b835a7df815300fe Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Mon, 10 Aug 2026 16:01:49 -0500 Subject: [PATCH 06/23] Add method to deactivate staff user --- .../cc_common/data_model/user_client.py | 29 +++++++ .../test_data_model/test_user_client.py | 81 +++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index 98354ab82b..1527c4d36e 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -95,6 +95,35 @@ def get_users_sorted_by_family_name( **dynamo_pagination, ) + def deactivate_user(self, *, user_id: str) -> None: + """Deactivate a staff user for inactivity. + + Disables the Cognito user so they can no longer obtain a token, and marks every one of their + compact records inactive so the rest of the system reflects that. + + :param str user_id: The user to deactivate + """ + logger.info('Deactivating staff user', user_id=user_id) + + # Disable in Cognito first. If this succeeds but the record updates below do not, the user is + # locked out while still showing active, and the next sweep finishes the job. The reverse order + # would leave a user marked inactive who can still sign in - and the pre-token hook would then + # flip them straight back to active. + self.config.cognito_client.admin_disable_user(UserPoolId=self.config.user_pool_id, Username=user_id) + + # A user only ever has a handful of compact records, all in one partition, so a single query + # is enough. We update each record by its own keys rather than rebuilding them. + user_records = self.config.users_table.query(KeyConditionExpression=Key('pk').eq(f'USER#{user_id}')).get( + 'Items', [] + ) + for record in user_records: + self.config.users_table.update_item( + Key={'pk': record['pk'], 'sk': record['sk']}, + UpdateExpression='SET #status = :status', + ExpressionAttributeNames={'#status': 'status'}, + ExpressionAttributeValues={':status': StaffUserStatus.INACTIVE.value}, + ) + def iterate_all_users_in_compact(self, *, compact: str) -> Generator[StaffUserData, None, None]: """Yield every staff user in a compact, transparently following LastEvaluatedKey. diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py index 100390a3f2..7397611862 100644 --- a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -132,6 +132,87 @@ def test_record_user_login_raises_when_record_does_not_exist(self): ) self.assertNotIn('Item', stub_record) + def _is_cognito_user_enabled(self, user_id: str) -> bool: + return self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=user_id)[ + 'Enabled' + ] + + def test_deactivate_user_disables_the_cognito_user(self): + """A deactivated user must not be able to obtain a token, so Cognito is the real lock.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + UserClient(self.config).deactivate_user(user_id=user_id) + + self.assertFalse(self._is_cognito_user_enabled(user_id)) + + def test_deactivate_user_marks_every_compact_record_inactive(self): + """The Cognito disable is global, so leaving another compact's record active would be a lie.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + # Seed a second compact record for the same user, written directly since the record schema + # only permits the compacts this app is configured for. + self.config.users_table.put_item( + Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} + ) + client = UserClient(self.config) + with patch( + 'cc_common.config._Config.current_standard_datetime', + datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + ): + client.record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + + client.deactivate_user(user_id=user_id) + + for compact in ('socw', 'some-other-compact'): + self.assertEqual( + StaffUserStatus.INACTIVE.value, + self._get_user_record(user_id, compact)['status'], + f'the {compact} record should have been marked inactive', + ) + + def test_deactivate_user_is_idempotent(self): + """The day-of sweep can retry, so deactivating an already-deactivated user must not raise.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + client = UserClient(self.config) + + client.deactivate_user(user_id=user_id) + client.deactivate_user(user_id=user_id) + + self.assertFalse(self._is_cognito_user_enabled(user_id)) + self.assertEqual(StaffUserStatus.INACTIVE.value, self._get_user_record(user_id)['status']) + + def test_deactivate_user_disables_cognito_before_marking_records_inactive(self): + """Order matters: a record marked inactive while the user can still sign in would be flipped + straight back to active by the pre-token hook. Locking Cognito first cannot go wrong that way - + a failure in between just leaves the next sweep to finish the job.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + client = UserClient(self.config) + with patch( + 'cc_common.config._Config.current_standard_datetime', + datetime.fromisoformat('2024-11-08T23:59:59+00:00'), + ): + client.record_user_login(user_id=user_id, compacts=['socw']) + + statuses_when_disabled = [] + with patch('cc_common.config._Config.cognito_client') as mock_cognito_client: + mock_cognito_client.admin_disable_user.side_effect = lambda **_kwargs: statuses_when_disabled.append( + self._get_user_record(user_id)['status'] + ) + client.deactivate_user(user_id=user_id) + + self.assertEqual([StaffUserStatus.ACTIVE.value], statuses_when_disabled) + def _put_oversized_users(self, count: int, *, compact: str = 'socw') -> set[str]: """Write `count` deliberately oversized user records, so a GSI query has to paginate. From 47ad46f7a07d19b5568dc70cb703b73f67b22154 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Mon, 10 Aug 2026 16:48:45 -0500 Subject: [PATCH 07/23] Add logic/permission to re-enable user in re-invite flow --- .../cc_common/data_model/user_client.py | 9 +++++ .../test_data_model/test_user_client.py | 39 +++++++++++++++++++ .../stacks/api_lambda_stack/staff_users.py | 3 ++ .../app/test_api/test_staff_users_api.py | 25 ++++++++++++ 4 files changed, 76 insertions(+) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index 1527c4d36e..4c4c8a9ff6 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -488,6 +488,15 @@ def reinvite_user(self, *, email: str) -> None: Username=email, ) + # A user deactivated for inactivity is disabled in Cognito. Re-enable them before resending + # the invite, or the invitation arrives but they still cannot sign in. + if not user_data.get('Enabled', True): + logger.info('Re-enabling disabled user before reinvite') + self.config.cognito_client.admin_enable_user( + UserPoolId=self.config.user_pool_id, + Username=email, + ) + # If they're in CONFIRMED state, we need to reset their password first if user_data['UserStatus'] in (UserStatus.CONFIRMED, UserStatus.RESET_REQUIRED): self.config.cognito_client.admin_set_user_password( diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py index 7397611862..bdb6cdd89a 100644 --- a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -627,6 +627,45 @@ def test_reinvite_existing_user_unexpected_status(self, mock_cognito_client): with self.assertRaises(CCInternalException): client.reinvite_user(email='new_user@example.org') + def test_reinvite_re_enables_a_deactivated_user(self): + """The recovery path for inactivity deactivation: an admin re-invites the user. + + Without the re-enable, the invitation lands but the user still cannot sign in. + """ + from cc_common.data_model.user_client import UserClient + + user_id = self._create_compact_staff_user(compacts=['socw']) + client = UserClient(self.config) + client.deactivate_user(user_id=user_id) + self.assertFalse(self._is_cognito_user_enabled(user_id)) + + user_data = self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=user_id) + client.reinvite_user(email=self._get_email_from_user_attributes(user_data)) + + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + @patch('cc_common.config._Config.cognito_client') + def test_reinvite_does_not_re_enable_an_enabled_user(self, mock_cognito_client): + from cc_common.data_model.user_client import UserClient + + user_id = str(uuid4()) + mock_cognito_client.admin_get_user.return_value = { + 'Username': user_id, + 'UserAttributes': [ + {'Name': 'email', 'Value': 'new_user@example.org'}, + {'Name': 'email_verified', 'Value': 'True'}, + {'Name': 'sub', 'Value': user_id}, + ], + 'UserCreateDate': datetime(2015, 1, 1, tzinfo=UTC), + 'UserLastModifiedDate': datetime(2015, 1, 1, tzinfo=UTC), + 'Enabled': True, + 'UserStatus': 'FORCE_CHANGE_PASSWORD', + } + + UserClient(self.config).reinvite_user(email='new_user@example.org') + + mock_cognito_client.admin_enable_user.assert_not_called() + def test_reinvite_user_not_found(self): from cc_common.data_model.user_client import UserClient from cc_common.exceptions import CCNotFoundException diff --git a/backend/social-work-app/stacks/api_lambda_stack/staff_users.py b/backend/social-work-app/stacks/api_lambda_stack/staff_users.py index 609390c7f4..8cb9b225f1 100644 --- a/backend/social-work-app/stacks/api_lambda_stack/staff_users.py +++ b/backend/social-work-app/stacks/api_lambda_stack/staff_users.py @@ -447,6 +447,9 @@ def _reinvite_user_handler( 'cognito-idp:AdminResetUserPassword', 'cognito-idp:AdminSetUserPassword', 'cognito-idp:AdminCreateUser', + # A user deactivated for inactivity is disabled in Cognito, so re-inviting them has to + # re-enable them or the invitation is useless + 'cognito-idp:AdminEnableUser', ) NagSuppressions.add_resource_suppressions_by_path( diff --git a/backend/social-work-app/tests/app/test_api/test_staff_users_api.py b/backend/social-work-app/tests/app/test_api/test_staff_users_api.py index f0511a311d..fd141b1b3f 100644 --- a/backend/social-work-app/tests/app/test_api/test_staff_users_api.py +++ b/backend/social-work-app/tests/app/test_api/test_staff_users_api.py @@ -1,6 +1,7 @@ from aws_cdk.assertions import Capture, Match, Template from aws_cdk.aws_apigateway import CfnMethod, CfnModel, CfnResource from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_iam import CfnPolicy from aws_cdk.aws_lambda import CfnFunction from tests.app.test_api import TestApi @@ -313,3 +314,27 @@ def test_synth_generates_reinvite_user_endpoint_resource(self): ], }, ) + + def test_synth_grants_reinvite_user_handler_permission_to_re_enable_cognito_users(self): + """A user deactivated for inactivity is disabled in Cognito, so the reinvite handler has to be able + to re-enable them. Without this grant the invitation is sent but the user still cannot sign in. + """ + api_lambda_stack = self.app.sandbox_backend_stage.api_lambda_stack + api_lambda_stack_template = Template.from_stack(api_lambda_stack) + + role_logical_id = api_lambda_stack.get_logical_id( + api_lambda_stack.staff_users_lambdas.reinvite_user_handler.role.node.default_child + ) + role_policies = api_lambda_stack_template.find_resources( + type=CfnPolicy.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'Roles': [{'Ref': role_logical_id}]}}, + ) + self.assertTrue(role_policies, 'No IAM policy found for the reinvite user handler role') + + granted_actions = set() + for policy in role_policies.values(): + for statement in policy['Properties']['PolicyDocument']['Statement']: + actions = statement['Action'] + granted_actions.update(actions if isinstance(actions, list) else [actions]) + + self.assertIn('cognito-idp:AdminEnableUser', granted_actions) From bbafb662a0bdca4ad50187a3da183c83c71b0135 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 11 Aug 2026 09:21:06 -0500 Subject: [PATCH 08/23] Add staff user directory class for finding compact/jurisdiction admins --- .../staff-users/staff_user_directory.py | 66 +++++++++ .../tests/unit/test_staff_user_directory.py | 134 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py create mode 100644 backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py new file mode 100644 index 0000000000..1724a32d99 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py @@ -0,0 +1,66 @@ +from collections import defaultdict +from collections.abc import Generator +from datetime import UTC, date + +from cc_common.config import config, logger +from cc_common.data_model.schema.common import StaffUserStatus +from cc_common.data_model.schema.user import StaffUserData + + +class CompactStaffUserDirectory: + """All staff users in a compact, loaded in a single pass over the famGiv GSI. + + Built once and queried repeatedly. The admin buckets are classified eagerly at construction; + cohort selection is a query against the loaded set, so this class is usable by anything that + needs to reach a compact's staff users or their admins. + """ + + def __init__(self, *, compact: str): + self.compact = compact + self._users: list[StaffUserData] = [] + self._compact_admins: list[StaffUserData] = [] + self._jurisdiction_admins: dict[str, list[StaffUserData]] = defaultdict(list) + self._load() + + def _load(self) -> None: + for user in config.user_client.iterate_all_users_in_compact(compact=self.compact): + self._users.append(user) + if user.isCompactAdmin: + self._compact_admins.append(user) + for jurisdiction in user.jurisdictions: + if user.isJurisdictionAdmin(jurisdiction): + self._jurisdiction_admins[jurisdiction].append(user) + + logger.info('Loaded staff user directory', compact=self.compact, user_count=len(self._users)) + + def users_last_seen_on(self, last_login_date: date) -> list[StaffUserData]: + """Active users whose last sign-in was on this date.""" + return [user for user in self._candidates() if self._last_login_date(user) == last_login_date] + + def users_last_seen_on_or_before(self, last_login_date: date) -> list[StaffUserData]: + """Active users whose last sign-in was on or before this date.""" + return [user for user in self._candidates() if self._last_login_date(user) <= last_login_date] + + def jurisdiction_admins(self, jurisdiction: str) -> list[StaffUserData]: + """Users holding the admin action in this jurisdiction.""" + return list(self._jurisdiction_admins[jurisdiction]) + + @property + def compact_admins(self) -> list[StaffUserData]: + """Users holding the admin action at the compact level.""" + return list(self._compact_admins) + + def _candidates(self) -> Generator[StaffUserData, None, None]: + """Users eligible for an inactivity cohort. + + Users who are already inactive have been deactivated, and users with no lastLoginAt have not + signed in since login tracking was introduced, so neither has an inactivity clock running. + """ + return ( + user for user in self._users if user.status == StaffUserStatus.ACTIVE.value and user.lastLoginAt is not None + ) + + @staticmethod + def _last_login_date(user: StaffUserData) -> date: + # Timestamps are written in UTC, but normalize so the comparison cannot drift on an offset + return user.lastLoginAt.astimezone(UTC).date() diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py new file mode 100644 index 0000000000..79edd2dbb0 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py @@ -0,0 +1,134 @@ +from datetime import date, datetime +from unittest.mock import patch +from uuid import uuid4 + +from tests import TstLambdas + +# The directory's own query and pagination are covered by the UserClient function tests. These tests +# feed it users directly, so they are only about how it classifies them. +ITERATE_USERS = 'cc_common.data_model.user_client.UserClient.iterate_all_users_in_compact' + + +class TestCompactStaffUserDirectory(TstLambdas): + @staticmethod + def _staff_user( + *, + last_login_at: str | None = '2024-11-08T12:00:00+00:00', + status: str | None = None, + compact_actions: set | None = None, + jurisdictions: dict | None = None, + ): + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user import StaffUserData + from common_test.test_data_generator import TestDataGenerator + + staff_user = TestDataGenerator.generate_default_staff_user( + { + 'userId': str(uuid4()), + 'status': status or StaffUserStatus.ACTIVE.value, + 'lastLoginAt': datetime.fromisoformat(last_login_at or '2024-11-08T12:00:00+00:00'), + 'permissions': { + 'actions': compact_actions or set(), + 'jurisdictions': jurisdictions or {}, + }, + } + ) + if last_login_at is not None: + return staff_user + + # A user who has not signed in since login tracking was introduced has no lastLoginAt at all + record = staff_user.serialize_to_database_record() + del record['lastLoginAt'] + return StaffUserData.from_database_record(record) + + @staticmethod + def _build_directory(users): + from staff_user_directory import CompactStaffUserDirectory + + with patch(ITERATE_USERS, return_value=iter(users)): + return CompactStaffUserDirectory(compact='socw') + + def test_users_last_seen_on_matches_only_that_date(self): + day_before = self._staff_user(last_login_at='2024-11-07T23:59:59+00:00') + target_day = self._staff_user(last_login_at='2024-11-08T00:00:00+00:00') + day_after = self._staff_user(last_login_at='2024-11-09T00:00:00+00:00') + + directory = self._build_directory([day_before, target_day, day_after]) + + self.assertEqual( + [target_day.userId], + [user.userId for user in directory.users_last_seen_on(date(2024, 11, 8))], + ) + + def test_users_last_seen_on_or_before_includes_earlier_dates(self): + day_before = self._staff_user(last_login_at='2024-11-07T23:59:59+00:00') + target_day = self._staff_user(last_login_at='2024-11-08T00:00:00+00:00') + day_after = self._staff_user(last_login_at='2024-11-09T00:00:00+00:00') + + directory = self._build_directory([day_before, target_day, day_after]) + + self.assertEqual( + {day_before.userId, target_day.userId}, + {user.userId for user in directory.users_last_seen_on_or_before(date(2024, 11, 8))}, + ) + + def test_cohorts_exclude_inactive_users(self): + """Already-deactivated users must not be swept up again.""" + from cc_common.data_model.schema.common import StaffUserStatus + + active = self._staff_user(last_login_at='2024-11-08T12:00:00+00:00') + inactive = self._staff_user(last_login_at='2024-11-08T12:00:00+00:00', status=StaffUserStatus.INACTIVE.value) + + directory = self._build_directory([active, inactive]) + + self.assertEqual([active.userId], [user.userId for user in directory.users_last_seen_on(date(2024, 11, 8))]) + self.assertEqual( + [active.userId], + [user.userId for user in directory.users_last_seen_on_or_before(date(2024, 11, 8))], + ) + + def test_cohorts_exclude_users_who_have_never_signed_in(self): + never_signed_in = self._staff_user(last_login_at=None) + + directory = self._build_directory([never_signed_in]) + + self.assertEqual([], directory.users_last_seen_on(date(2024, 11, 8))) + # Even a wide sweep must not pick up a user with no lastLoginAt at all + self.assertEqual([], directory.users_last_seen_on_or_before(date(2099, 1, 1))) + + def test_jurisdiction_admins(self): + from cc_common.data_model.schema.common import CCPermissionsAction + + oh_admin = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.ADMIN.value}}) + oh_writer = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.WRITE.value}}) + ne_admin = self._staff_user(jurisdictions={'ne': {CCPermissionsAction.ADMIN.value}}) + + directory = self._build_directory([oh_admin, oh_writer, ne_admin]) + + self.assertEqual([oh_admin.userId], [user.userId for user in directory.jurisdiction_admins('oh')]) + self.assertEqual([ne_admin.userId], [user.userId for user in directory.jurisdiction_admins('ne')]) + # A jurisdiction with no admins is empty, not a KeyError + self.assertEqual([], directory.jurisdiction_admins('ky')) + + def test_compact_admins(self): + from cc_common.data_model.schema.common import CCPermissionsAction + + compact_admin = self._staff_user(compact_actions={CCPermissionsAction.ADMIN.value}) + compact_reader = self._staff_user(compact_actions={CCPermissionsAction.READ_PRIVATE.value}) + jurisdiction_admin = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.ADMIN.value}}) + + directory = self._build_directory([compact_admin, compact_reader, jurisdiction_admin]) + + self.assertEqual([compact_admin.userId], [user.userId for user in directory.compact_admins]) + + def test_admin_lookups_do_not_require_cohort_selection(self): + """The directory is usable purely as an admin lookup, without picking a cohort first.""" + from cc_common.data_model.schema.common import CCPermissionsAction + + compact_admin = self._staff_user(compact_actions={CCPermissionsAction.ADMIN.value}) + oh_admin = self._staff_user(jurisdictions={'oh': {CCPermissionsAction.ADMIN.value}}) + + directory = self._build_directory([compact_admin, oh_admin]) + + self.assertEqual([compact_admin.userId], [user.userId for user in directory.compact_admins]) + self.assertEqual([oh_admin.userId], [user.userId for user in directory.jurisdiction_admins('oh')]) From 80ae602b2e5ac121e8b11f75c6c3f39be926d013 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 11 Aug 2026 14:43:46 -0500 Subject: [PATCH 09/23] Add handler for checking inactive staff users --- .../common/cc_common/email_service_client.py | 45 +++ .../tests/unit/test_email_service_client.py | 40 +++ .../handlers/staff_user_inactivity.py | 309 ++++++++++++++++++ .../staff_user_inactivity_tracker.py | 125 +++++++ .../python/staff-users/tests/__init__.py | 3 + .../staff-users/tests/function/__init__.py | 14 + .../test_staff_user_inactivity.py | 309 ++++++++++++++++++ .../test_staff_user_inactivity_tracker.py | 134 ++++++++ .../tests/unit/staff_user_test_data.py | 61 ++++ .../unit/test_resolve_admin_recipients.py | 97 ++++++ .../tests/unit/test_staff_user_directory.py | 48 +-- 11 files changed, 1142 insertions(+), 43 deletions(-) create mode 100644 backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py create mode 100644 backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py create mode 100644 backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py create mode 100644 backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py create mode 100644 backend/social-work-app/lambdas/python/staff-users/tests/unit/staff_user_test_data.py create mode 100644 backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py diff --git a/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py b/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py index cfbb344a23..c82f0bc3ae 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/email_service_client.py @@ -51,6 +51,19 @@ class HomeJurisdictionChangeNotificationTemplateVariables: provider_id: UUID +@dataclass +class StaffUserInactivityNotificationTemplateVariables: + """ + Template variables for staff user inactivity deactivation notification emails. + """ + + staff_user_first_name: str + staff_user_last_name: str + staff_user_email: str + deactivation_date: date + inactivity_period_days: int + + class JurisdictionNotificationMethod(Protocol): """Protocol for Jurisdiction encumbrance notification methods.""" @@ -415,3 +428,35 @@ def send_provider_home_state_change_email( }, } return self._invoke_lambda(payload) + + def send_staff_user_inactivity_notification_email( + self, + *, + compact: str, + recipient_emails: list[str], + template_variables: StaffUserInactivityNotificationTemplateVariables, + ) -> dict[str, str]: + """ + Send a notification that a staff user's account is scheduled for inactivity deactivation. + + The body is worded in the third person so the same email serves the staff user and their administrators. + + :param compact: Compact name + :param recipient_emails: The addresses to send this notification to + :param template_variables: Template variables for the email + :return: Response from the email notification service + """ + payload = { + 'compact': compact, + 'template': 'staffUserInactivityNotification', + 'recipientType': 'SPECIFIC', + 'specificEmails': recipient_emails, + 'templateVariables': { + 'staffUserFirstName': template_variables.staff_user_first_name, + 'staffUserLastName': template_variables.staff_user_last_name, + 'staffUserEmail': template_variables.staff_user_email, + 'deactivationDate': template_variables.deactivation_date.isoformat(), + 'inactivityPeriodDays': template_variables.inactivity_period_days, + }, + } + return self._invoke_lambda(payload) diff --git a/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py b/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py index bfb0fc46a3..962f858585 100644 --- a/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/unit/test_email_service_client.py @@ -66,3 +66,43 @@ def test_send_provider_home_state_change_email_should_invoke_lambda_client_with_ } ), ) + + def test_send_staff_user_inactivity_notification_email_invokes_lambda_with_expected_parameters(self): + from datetime import date + + from cc_common.email_service_client import StaffUserInactivityNotificationTemplateVariables + + mock_lambda_client = MagicMock() + test_model = self._generate_test_model(mock_lambda_client) + + test_model.send_staff_user_inactivity_notification_email( + compact=TEST_COMPACT, + recipient_emails=['jane@example.com', 'admin@example.com'], + template_variables=StaffUserInactivityNotificationTemplateVariables( + staff_user_first_name='Jane', + staff_user_last_name='Smith', + staff_user_email='jane@example.com', + deactivation_date=date(2026, 9, 14), + inactivity_period_days=60, + ), + ) + + mock_lambda_client.invoke.assert_called_once_with( + FunctionName='test-lambda-name', + InvocationType='RequestResponse', + Payload=json.dumps( + { + 'compact': TEST_COMPACT, + 'template': 'staffUserInactivityNotification', + 'recipientType': 'SPECIFIC', + 'specificEmails': ['jane@example.com', 'admin@example.com'], + 'templateVariables': { + 'staffUserFirstName': 'Jane', + 'staffUserLastName': 'Smith', + 'staffUserEmail': 'jane@example.com', + 'deactivationDate': '2026-09-14', + 'inactivityPeriodDays': 60, + }, + } + ), + ) diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py new file mode 100644 index 0000000000..267f0fd9c6 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py @@ -0,0 +1,309 @@ +import os +from dataclasses import dataclass +from datetime import UTC, date, timedelta +from enum import StrEnum + +from aws_lambda_powertools.utilities.typing import LambdaContext +from cc_common.config import config, logger +from cc_common.data_model.schema.user import StaffUserData +from cc_common.email_service_client import StaffUserInactivityNotificationTemplateVariables +from cc_common.exceptions import CCInternalException, CCInvalidRequestException +from staff_user_directory import CompactStaffUserDirectory +from staff_user_inactivity_tracker import InactivityEventType, InactivityStep, StaffUserInactivityTracker + +DAYS_BEFORE_TO_EVENT_TYPE = { + 10: InactivityEventType.TEN_DAY, + 3: InactivityEventType.THREE_DAY, + 0: InactivityEventType.DAY_OF, +} + +# Stop and alarm rather than dying mid-user. The next run picks up whatever the tracker shows as unfinished. +TIMEOUT_BUFFER_MS = 60_000 + + +class EmailOutcome(StrEnum): + SENT = 'sent' + ALREADY_DONE = 'alreadyDone' + FAILED = 'failed' + + +@dataclass +class Metrics: + """Counts for one run, returned and logged so a run can be reconciled after the fact.""" + + matched_user_count: int = 0 + user_emails_sent: int = 0 + admin_emails_sent: int = 0 + already_done: int = 0 + emails_failed: int = 0 + no_admin_recipients: int = 0 + deactivated: int = 0 + deactivations_failed: int = 0 + + def record_email_outcome(self, outcome: EmailOutcome, *, step: InactivityStep) -> None: + if outcome == EmailOutcome.ALREADY_DONE: + self.already_done += 1 + elif outcome == EmailOutcome.FAILED: + self.emails_failed += 1 + elif step == InactivityStep.USER_EMAIL: + self.user_emails_sent += 1 + else: + self.admin_emails_sent += 1 + + def as_dict(self) -> dict[str, int]: + return { + 'matchedUsers': self.matched_user_count, + 'userEmailsSent': self.user_emails_sent, + 'adminEmailsSent': self.admin_emails_sent, + 'alreadyDone': self.already_done, + 'emailsFailed': self.emails_failed, + 'noAdminRecipients': self.no_admin_recipients, + 'deactivated': self.deactivated, + 'deactivationsFailed': self.deactivations_failed, + } + + +def process_staff_user_inactivity(event: dict, context: LambdaContext) -> dict: + """Notify a staff user and their administrators that the account is nearing inactivity deactivation, + and on the day-of run, deactivate. + + Event format: + { + "compact": "socw", # required + "daysBeforeDeactivation": 10, # required - 10, 3, or 0 + "targetLastLoginDate": "2026-06-21" # optional - replay a specific day's matched_users + } + """ + try: + compact = event['compact'] + days_before = event['daysBeforeDeactivation'] + except KeyError as e: + raise CCInvalidRequestException(f'Missing required field: {e.args[0]}') from None + + if days_before not in DAYS_BEFORE_TO_EVENT_TYPE: + raise CCInvalidRequestException( + f'Invalid daysBeforeDeactivation: {days_before}. Must be one of {sorted(DAYS_BEFORE_TO_EVENT_TYPE)}.' + ) + if compact not in config.compacts: + raise CCInvalidRequestException(f'Invalid compact: {compact}. Must be one of {config.compacts}.') + + inactivity_period_days = int(os.environ['STAFF_USER_INACTIVITY_PERIOD_DAYS']) + # The inactivity period elapses at the end of its final day, so deactivation lands on the next one. + # That way every user keeps their whole final day, in whichever timezone they are in. + days_to_deactivation = inactivity_period_days + 1 + + today = config.current_standard_datetime.astimezone(UTC).date() + target_last_login_date = ( + _parse_iso_date(event['targetLastLoginDate']) + if 'targetLastLoginDate' in event + else today - timedelta(days=days_to_deactivation - days_before) + ) + event_type = DAYS_BEFORE_TO_EVENT_TYPE[days_before] + is_deactivation_run = days_before == 0 + + logger.info( + 'Processing staff user inactivity', + compact=compact, + days_before=days_before, + event_type=event_type, + target_last_login_date=target_last_login_date.isoformat(), + ) + + directory = CompactStaffUserDirectory(compact=compact) + # The deactivation run sweeps everyone at or past the cutoff, so a user missed by a failed run is + # still caught. The reminder runs match one exact day, or they would re-warn every day after it. + matched_users = ( + directory.users_last_seen_on_or_before(target_last_login_date) + if is_deactivation_run + else directory.users_last_seen_on(target_last_login_date) + ) + + metrics = Metrics(matched_user_count=len(matched_users)) + for processed_count, user in enumerate(matched_users): + if context.get_remaining_time_in_millis() < TIMEOUT_BUFFER_MS: + logger.error( + 'Ran out of time processing staff user inactivity', + compact=compact, + event_type=event_type, + processed=processed_count, + remaining=len(matched_users) - processed_count, + metrics=metrics.as_dict(), + ) + raise CCInternalException('Ran out of time processing staff user inactivity') + + _process_user( + user=user, + directory=directory, + event_type=event_type, + today=today, + days_to_deactivation=days_to_deactivation, + inactivity_period_days=inactivity_period_days, + is_deactivation_run=is_deactivation_run, + metrics=metrics, + ) + + logger.info('Completed staff user inactivity run', compact=compact, metrics=metrics.as_dict()) + return { + 'compact': compact, + 'daysBeforeDeactivation': days_before, + 'targetLastLoginDate': target_last_login_date.isoformat(), + 'metrics': metrics.as_dict(), + } + + +def _process_user( + *, + user: StaffUserData, + directory: CompactStaffUserDirectory, + event_type: InactivityEventType, + today: date, + days_to_deactivation: int, + inactivity_period_days: int, + is_deactivation_run: bool, + metrics: Metrics, +) -> None: + """Notify one user and their admins, then deactivate if this is the deactivation run.""" + last_login_date = user.lastLoginAt.astimezone(UTC).date() + # Clamped, so a straggler swept up late is not told about a date in the past + deactivation_date = max(last_login_date + timedelta(days=days_to_deactivation), today) + + tracker = StaffUserInactivityTracker( + compact=directory.compact, + user_id=str(user.userId), + last_login_date=last_login_date, + event_type=event_type, + ) + template_variables = StaffUserInactivityNotificationTemplateVariables( + staff_user_first_name=user.givenName, + staff_user_last_name=user.familyName, + staff_user_email=user.email, + deactivation_date=deactivation_date, + inactivity_period_days=inactivity_period_days, + ) + + # The user and their admins get separate sends, so one admin cannot see another's address and a + # partial failure only retries the half that failed + metrics.record_email_outcome( + _send_tracked_email( + tracker=tracker, + step=InactivityStep.USER_EMAIL, + compact=directory.compact, + recipient_emails=[user.email], + template_variables=template_variables, + ), + step=InactivityStep.USER_EMAIL, + ) + + admin_emails = resolve_admin_recipients(user=user, directory=directory) + if not admin_emails: + metrics.no_admin_recipients += 1 + else: + metrics.record_email_outcome( + _send_tracked_email( + tracker=tracker, + step=InactivityStep.ADMIN_EMAIL, + compact=directory.compact, + recipient_emails=sorted(admin_emails), + template_variables=template_variables, + ), + step=InactivityStep.ADMIN_EMAIL, + ) + + # Deactivate only after the notifications have been attempted, so nobody is locked out silently + if is_deactivation_run: + _deactivate_user(user=user, tracker=tracker, metrics=metrics) + + +def _send_tracked_email( + *, + tracker: StaffUserInactivityTracker, + step: InactivityStep, + compact: str, + recipient_emails: list[str], + template_variables: StaffUserInactivityNotificationTemplateVariables, +) -> EmailOutcome: + """Send one notification, honouring and updating the idempotency tracker.""" + if tracker.was_already_done(step): + return EmailOutcome.ALREADY_DONE + + try: + config.email_service_client.send_staff_user_inactivity_notification_email( + compact=compact, + recipient_emails=recipient_emails, + template_variables=template_variables, + ) + except Exception as e: # noqa: BLE001 one failed send must not impact the remaining users + tracker.record_failure(step, error_message=str(e)) + logger.error( + 'Failed to send staff user inactivity notification', + compact=compact, + step=step, + error=str(e), + ) + return EmailOutcome.FAILED + + tracker.record_success(step) + return EmailOutcome.SENT + + +def _deactivate_user(*, user: StaffUserData, tracker: StaffUserInactivityTracker, metrics: Metrics) -> None: + if tracker.was_already_done(InactivityStep.DEACTIVATION): + metrics.already_done += 1 + return + + try: + config.user_client.deactivate_user(user_id=str(user.userId)) + except Exception as e: # noqa: BLE001 one failed deactivation must not impact the remaining users + tracker.record_failure(InactivityStep.DEACTIVATION, error_message=str(e)) + logger.error('Failed to deactivate staff user', user_id=str(user.userId), error=str(e)) + metrics.deactivations_failed += 1 + return + + tracker.record_success(InactivityStep.DEACTIVATION) + metrics.deactivated += 1 + + +def _parse_iso_date(value: str) -> date: + try: + return date.fromisoformat(value) + except ValueError as e: + raise CCInvalidRequestException(f'Invalid ISO date for targetLastLoginDate: {value}') from e + + +def resolve_admin_recipients(*, user: StaffUserData, directory: CompactStaffUserDirectory) -> set[str]: + """Resolve the admin email addresses to notify about this user's pending deactivation. + + - Users with permissions in a specific state -> that state's admins. + - Compact-level permissions, OR the user is the state's only admin, OR the state has no + admins -> compact admins. + + The user being deactivated is always excluded; they receive their own copy. + """ + # Any compact-level action (admin, readPrivate) escalates to the compact admins + notify_compact_admins = bool(user.compactActions) + + admin_emails: set[str] = set() + for jurisdiction in user.jurisdictions: + others = [admin for admin in directory.jurisdiction_admins(jurisdiction) if admin.userId != user.userId] + if not others: + # Covers both "the state has no admins" and "the user is the state's only admin" - once the + # user is excluded, those are the same condition + notify_compact_admins = True + else: + admin_emails.update(admin.email for admin in others) + + if not user.jurisdictions: + # A user with no jurisdiction permissions at all is a compact-only user + notify_compact_admins = True + + if notify_compact_admins: + admin_emails.update(admin.email for admin in directory.compact_admins if admin.userId != user.userId) + + if not admin_emails: + logger.error( + 'No admins found to notify about staff user deactivation', + compact=directory.compact, + user_id=str(user.userId), + ) + + return admin_emails diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py new file mode 100644 index 0000000000..71416cb3ca --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py @@ -0,0 +1,125 @@ +"""Idempotency tracking for staff user inactivity notifications and deactivations.""" + +import time +from datetime import date, timedelta +from enum import StrEnum + +from cc_common.config import config, logger + + +class InactivityEventType(StrEnum): + """Which of the three scheduled runs an attempt belongs to.""" + + TEN_DAY = 'staffUser.inactivity.10day' + THREE_DAY = 'staffUser.inactivity.3day' + DAY_OF = 'staffUser.inactivity.dayOf' + + +class InactivityStep(StrEnum): + """The independently tracked steps of one user's inactivity event. + + Each is recorded separately so a partial failure only retries the part that failed. + """ + + USER_EMAIL = 'USER' + ADMIN_EMAIL = 'ADMINS' + DEACTIVATION = 'DEACTIVATION' + + +class StaffUserInactivityTracker: + """Tracks which inactivity steps have already been completed for one staff user. + + Keyed on the user's own lastLoginAt date rather than the run's target date, so a user's key is + stable across consecutive day-of sweeps. + + Key pattern: + pk: {compact}#STAFF_USER_INACTIVITY#{user_id} + sk: {event_type}#{last_login_date}#{step} + ttl: 90 days after the record is written + """ + + _TTL_DAYS = 90 + _SUCCESS_STATUS = 'SUCCESS' + _FAILED_STATUS = 'FAILED' + + def __init__( + self, + *, + compact: str, + user_id: str, + last_login_date: date, + event_type: InactivityEventType, + ): + self.compact = compact + self.user_id = user_id + self.last_login_date = last_login_date + self.event_type = event_type + # One query up front, rather than a read per step + self._attempts = self._load_attempts() + + def was_already_done(self, step: InactivityStep) -> bool: + """Whether this step already completed successfully.""" + return self._attempts.get(self._build_sk(step), {}).get('status') == self._SUCCESS_STATUS + + def record_success(self, step: InactivityStep) -> None: + """Record that this step completed.""" + self._write_record(step, status=self._SUCCESS_STATUS) + + def record_failure(self, step: InactivityStep, *, error_message: str) -> None: + """Record that this step failed, so it will be retried.""" + self._write_record(step, status=self._FAILED_STATUS, error_message=error_message) + + def _build_pk(self) -> str: + return f'{self.compact}#STAFF_USER_INACTIVITY#{self.user_id}' + + def _build_sk(self, step: InactivityStep) -> str: + return f'{self.event_type}#{self.last_login_date.isoformat()}#{step}' + + def _load_attempts(self) -> dict[str, dict]: + try: + response = config.event_state_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': self._build_pk()}, + ConsistentRead=True, + ) + return {item['sk']: item for item in response.get('Items', [])} + except Exception as e: # noqa: BLE001 any read failure should fail open + # Fail open: a duplicate email is a better outcome than a missed deactivation + logger.warning('Failed to read staff user inactivity state', **self._log_context(), error=str(e)) + return {} + + def _write_record(self, step: InactivityStep, *, status: str, error_message: str | None = None) -> None: + item = { + 'pk': self._build_pk(), + 'sk': self._build_sk(step), + 'status': status, + 'compact': self.compact, + 'userId': self.user_id, + 'lastLoginDate': self.last_login_date.isoformat(), + 'eventType': self.event_type, + 'step': step, + 'ttl': int(time.time()) + int(timedelta(days=self._TTL_DAYS).total_seconds()), + } + if error_message: + item['errorMessage'] = error_message + + try: + config.event_state_table.put_item(Item=item) + self._attempts[item['sk']] = item + except Exception as e: # noqa: BLE001 tracking is secondary to the work it describes + # The work this describes has already happened, so swallow and let the next run reconcile + logger.error( + 'Unable to record staff user inactivity state', + status=status, + step=step, + **self._log_context(), + error=str(e), + ) + + def _log_context(self) -> dict: + return { + 'compact': self.compact, + 'user_id': self.user_id, + 'last_login_date': self.last_login_date.isoformat(), + 'event_type': self.event_type, + } diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py index 88710db094..29dbfffd15 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/__init__.py @@ -17,6 +17,9 @@ def setUpClass(cls): 'USER_POOL_ID': 'us-east-1-12345', 'USERS_TABLE_NAME': 'provider-table', 'COMPACT_CONFIGURATION_TABLE_NAME': 'compact-configuration-table', + 'EVENT_STATE_TABLE_NAME': 'event-state-table', + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': 'email-notification-service', + 'STAFF_USER_INACTIVITY_PERIOD_DAYS': '60', 'FAM_GIV_INDEX_NAME': 'famGiv', 'COMPACTS': '["socw"]', 'JURISDICTIONS': '["ne", "oh", "ky"]', diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py index ec40e13d67..f5548375f1 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/__init__.py @@ -49,6 +49,7 @@ def build_resources(self): ], ) self.create_compact_configuration_table() + self.create_event_state_table() # Adding a waiter allows for testing against an actual AWS account, if needed waiter = self._table.meta.client.get_waiter('table_exists') waiter.wait(TableName=self._table.name) @@ -64,6 +65,18 @@ def build_resources(self): os.environ['USER_POOL_ID'] = user_pool_response['UserPool']['Id'] self._user_pool_id = user_pool_response['UserPool']['Id'] + def create_event_state_table(self): + """Create the event state table, which tracks what work has already been done for an event.""" + self._event_state_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['EVENT_STATE_TABLE_NAME'], + KeySchema=[{'AttributeName': 'pk', 'KeyType': 'HASH'}, {'AttributeName': 'sk', 'KeyType': 'RANGE'}], + BillingMode='PAY_PER_REQUEST', + ) + def create_compact_configuration_table(self): """Create the compact configuration table for testing.""" self._compact_configuration_table = boto3.resource('dynamodb').create_table( @@ -82,6 +95,7 @@ def create_compact_configuration_table(self): def delete_resources(self): self._table.delete() self._compact_configuration_table.delete() + self._event_state_table.delete() waiter = self._table.meta.client.get_waiter('table_not_exists') waiter.wait(TableName=self._table.name) # Delete the Cognito user pool diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py new file mode 100644 index 0000000000..ffd2106607 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py @@ -0,0 +1,309 @@ +from datetime import datetime, timedelta +from unittest.mock import patch +from uuid import uuid4 + +from moto import mock_aws + +from .. import TstFunction + +# The run's "today". Every seeded lastLoginAt is expressed as an offset back from this. +MOCK_TODAY = datetime.fromisoformat('2026-09-14T12:00:00+00:00') +COMPACT = 'socw' +ADMIN = 'admin' +WRITE = 'write' + + +@mock_aws +class TestStaffUserInactivity(TstFunction): + def setUp(self): + super().setUp() + # A real Lambda context reports its remaining time; the handler bails out when it runs low + self.mock_context.get_remaining_time_in_millis.return_value = 900_000 + + def _seed_user(self, *, days_since_login: int | None, compact_actions=None, jurisdictions=None, status=None): + """Create a staff user, in Cognito and in the table, last seen `days_since_login` days before TODAY.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user.record import UserRecordSchema + + email = f'{uuid4()}@example.com' + user_id = self._create_cognito_user(email=email) + record = { + 'userId': user_id, + 'compact': COMPACT, + 'status': status or StaffUserStatus.ACTIVE.value, + 'attributes': {'email': email, 'givenName': 'Given', 'familyName': 'Family'}, + 'permissions': {'actions': compact_actions or set(), 'jurisdictions': jurisdictions or {}}, + } + if days_since_login is not None: + record['lastLoginAt'] = MOCK_TODAY - timedelta(days=days_since_login) + self._table.put_item(Item=UserRecordSchema().dump(record)) + return user_id, email + + def _run(self, *, days_before: int, **event_overrides): + from handlers.staff_user_inactivity import process_staff_user_inactivity + + event = {'compact': COMPACT, 'daysBeforeDeactivation': days_before, **event_overrides} + with patch('cc_common.config._Config.current_standard_datetime', MOCK_TODAY): + return process_staff_user_inactivity(event, self.mock_context) + + @staticmethod + def _sent_payloads(mock_email_client): + return [call.kwargs for call in mock_email_client.send_staff_user_inactivity_notification_email.call_args_list] + + def _recipient_sets(self, mock_email_client): + return [payload['recipient_emails'] for payload in self._sent_payloads(mock_email_client)] + + def _user_status(self, user_id: str) -> str: + return self._table.get_item(Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{COMPACT}'})['Item']['status'] + + def _is_cognito_user_enabled(self, user_id: str) -> bool: + return self.config.cognito_client.admin_get_user(UserPoolId=self.config.user_pool_id, Username=user_id)[ + 'Enabled' + ] + + # -- event validation -- + + def test_missing_required_fields_raise(self): + """Each case must be rejected for the field that is actually missing, not just rejected.""" + from cc_common.exceptions import CCInvalidRequestException + from handlers.staff_user_inactivity import process_staff_user_inactivity + + cases = ( + ({'daysBeforeDeactivation': 10}, 'compact'), + ({'compact': COMPACT}, 'daysBeforeDeactivation'), + ) + for event, missing_field in cases: + with self.subTest(event=event), self.assertRaises(CCInvalidRequestException) as ctx: + process_staff_user_inactivity(event, self.mock_context) + self.assertEqual(f'Missing required field: {missing_field}', ctx.exception.message) + + def test_invalid_days_before_raises(self): + from cc_common.exceptions import CCInvalidRequestException + + with self.assertRaises(CCInvalidRequestException) as ctx: + self._run(days_before=7) + + self.assertEqual( + 'Invalid daysBeforeDeactivation: 7. Must be one of [0, 3, 10].', + ctx.exception.message, + ) + + def test_invalid_compact_raises(self): + from cc_common.exceptions import CCInvalidRequestException + from handlers.staff_user_inactivity import process_staff_user_inactivity + + with self.assertRaises(CCInvalidRequestException) as ctx: + process_staff_user_inactivity({'compact': 'not-a-compact', 'daysBeforeDeactivation': 10}, self.mock_context) + + self.assertEqual( + f'Invalid compact: not-a-compact. Must be one of {self.config.compacts}.', + ctx.exception.message, + ) + + def test_each_reminder_run_targets_its_own_day(self): + """10-day fires at 51 days since login, 3-day at 58. Both are exact, not ranges.""" + _, ten_day_email = self._seed_user(days_since_login=51) + _, three_day_email = self._seed_user(days_since_login=58) + # Neighbours on either side of both boundaries + for days in (50, 52, 57, 59): + self._seed_user(days_since_login=days) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=10) + self.assertEqual([[ten_day_email]], self._recipient_sets(mock_email_client)) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=3) + self.assertEqual([[three_day_email]], self._recipient_sets(mock_email_client)) + + def test_user_last_seen_exactly_60_days_ago_is_not_deactivated(self): + """The user keeps the whole of day 60 - deactivation lands on day 61.""" + user_id, _ = self._seed_user(days_since_login=60) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(0, result['metrics']['matchedUsers']) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + def test_day_of_run_sweeps_older_users(self): + """A straggler missed by earlier runs is still caught.""" + self._seed_user(days_since_login=61) + self._seed_user(days_since_login=75) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(2, result['metrics']['matchedUsers']) + + def test_reminder_run_does_not_sweep_older_users(self): + self._seed_user(days_since_login=75) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=10) + + self.assertEqual(0, result['metrics']['matchedUsers']) + + def test_users_who_never_signed_in_are_skipped(self): + self._seed_user(days_since_login=None) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(0, result['metrics']['matchedUsers']) + + def test_already_deactivated_users_are_not_swept_again(self): + from cc_common.data_model.schema.common import StaffUserStatus + + self._seed_user(days_since_login=61, status=StaffUserStatus.INACTIVE.value) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(0, result['metrics']['matchedUsers']) + + def test_target_last_login_date_overrides_the_computed_date(self): + self._seed_user(days_since_login=40) + replay_date = (MOCK_TODAY - timedelta(days=40)).date().isoformat() + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=10, targetLastLoginDate=replay_date) + + self.assertEqual(1, result['metrics']['matchedUsers']) + + # -- notifications -- + + def test_sends_separate_emails_to_the_user_and_their_admins(self): + _, user_email = self._seed_user(days_since_login=51, jurisdictions={'oh': {WRITE}}) + _, admin_email = self._seed_user(days_since_login=1, jurisdictions={'oh': {ADMIN}}) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=10) + + self.assertEqual([[user_email], [admin_email]], self._recipient_sets(mock_email_client)) + + def test_deactivation_date_is_the_users_own_day_61(self): + self._seed_user(days_since_login=51) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=10) + + template_variables = self._sent_payloads(mock_email_client)[0]['template_variables'] + self.assertEqual((MOCK_TODAY + timedelta(days=10)).date(), template_variables.deactivation_date) + self.assertEqual(60, template_variables.inactivity_period_days) + + def test_deactivation_date_is_clamped_to_today_for_a_straggler(self): + """A user 75 days dormant would otherwise be told about a date in the past.""" + self._seed_user(days_since_login=75) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=0) + + template_variables = self._sent_payloads(mock_email_client)[0]['template_variables'] + self.assertEqual(MOCK_TODAY.date(), template_variables.deactivation_date) + + def test_already_notified_users_are_not_emailed_twice(self): + self._seed_user(days_since_login=51) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=10) + sends_after_first_run = len(self._sent_payloads(mock_email_client)) + result = self._run(days_before=10) + + self.assertEqual(sends_after_first_run, len(self._sent_payloads(mock_email_client))) + + self.assertEqual(0, result['metrics']['userEmailsSent']) + self.assertGreater(result['metrics']['alreadyDone'], 0) + + def test_an_email_failure_is_counted_and_does_not_abort_the_run(self): + self._seed_user(days_since_login=61) + self._seed_user(days_since_login=61) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + mock_email_client.send_staff_user_inactivity_notification_email.side_effect = RuntimeError('SES is down') + result = self._run(days_before=0) + + self.assertEqual(2, result['metrics']['matchedUsers']) + self.assertEqual(2, result['metrics']['emailsFailed']) + # The deactivation still happens - a failed notice does not buy the user more time + self.assertEqual(2, result['metrics']['deactivated']) + + def test_no_admin_recipients_is_counted(self): + _, user_email = self._seed_user(days_since_login=51, jurisdictions={'oh': {WRITE}}) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + result = self._run(days_before=10) + + self.assertEqual(1, result['metrics']['noAdminRecipients']) + # The user still gets their own copy + self.assertEqual([[user_email]], self._recipient_sets(mock_email_client)) + + # -- deactivation -- + + def test_day_of_run_deactivates_after_sending(self): + from cc_common.data_model.schema.common import StaffUserStatus + + user_id, _ = self._seed_user(days_since_login=61) + statuses_when_emailed = [] + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + mock_email_client.send_staff_user_inactivity_notification_email.side_effect = lambda **_kwargs: ( + statuses_when_emailed.append(self._user_status(user_id)) + ) + result = self._run(days_before=0) + + self.assertEqual([StaffUserStatus.ACTIVE.value], statuses_when_emailed) + self.assertEqual(StaffUserStatus.INACTIVE.value, self._user_status(user_id)) + self.assertFalse(self._is_cognito_user_enabled(user_id)) + self.assertEqual(1, result['metrics']['deactivated']) + + def test_reminder_runs_deactivate_nobody(self): + from cc_common.data_model.schema.common import StaffUserStatus + + user_id, _ = self._seed_user(days_since_login=51) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=10) + + self.assertEqual(0, result['metrics']['deactivated']) + self.assertEqual(StaffUserStatus.ACTIVE.value, self._user_status(user_id)) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + def test_a_sole_compact_admin_is_deactivated_like_anyone_else(self): + """Deliberately uniform: recovery from a compact locking itself out is a console operation.""" + from cc_common.data_model.schema.common import StaffUserStatus + + user_id, _ = self._seed_user(days_since_login=61, compact_actions={ADMIN}) + + with patch('cc_common.config._Config.email_service_client'): + result = self._run(days_before=0) + + self.assertEqual(1, result['metrics']['deactivated']) + self.assertEqual(StaffUserStatus.INACTIVE.value, self._user_status(user_id)) + + def test_a_deactivation_failure_is_counted_and_does_not_abort_the_run(self): + self._seed_user(days_since_login=61) + self._seed_user(days_since_login=61) + + with ( + patch('cc_common.config._Config.email_service_client'), + patch( + 'cc_common.data_model.user_client.UserClient.deactivate_user', + side_effect=RuntimeError('Cognito is down'), + ), + ): + result = self._run(days_before=0) + + self.assertEqual(2, result['metrics']['deactivationsFailed']) + self.assertEqual(0, result['metrics']['deactivated']) + + # -- timeout guard -- + + def test_raises_when_it_runs_out_of_time(self): + from cc_common.exceptions import CCInternalException + + self._seed_user(days_since_login=61) + self.mock_context.get_remaining_time_in_millis.return_value = 1_000 + + with patch('cc_common.config._Config.email_service_client'), self.assertRaises(CCInternalException): + self._run(days_before=0) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py new file mode 100644 index 0000000000..e6dd691a20 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py @@ -0,0 +1,134 @@ +from datetime import date +from unittest.mock import patch +from uuid import uuid4 + +from moto import mock_aws + +from . import TstFunction + +LAST_LOGIN_DATE = date(2024, 11, 8) + + +@mock_aws +class TestStaffUserInactivityTracker(TstFunction): + @staticmethod + def _tracker(*, user_id: str, event_type=None, last_login_date: date = LAST_LOGIN_DATE): + from staff_user_inactivity_tracker import InactivityEventType, StaffUserInactivityTracker + + return StaffUserInactivityTracker( + compact='socw', + user_id=user_id, + last_login_date=last_login_date, + event_type=event_type or InactivityEventType.TEN_DAY, + ) + + def test_not_done_until_recorded(self): + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + + self.assertFalse(self._tracker(user_id=user_id).was_already_done(InactivityStep.USER_EMAIL)) + + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + self.assertTrue(self._tracker(user_id=user_id).was_already_done(InactivityStep.USER_EMAIL)) + + def test_steps_are_tracked_independently(self): + """A failure sending the admin email must not suppress the deactivation, or vice versa.""" + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + tracker = self._tracker(user_id=user_id) + self.assertTrue(tracker.was_already_done(InactivityStep.USER_EMAIL)) + self.assertFalse(tracker.was_already_done(InactivityStep.ADMIN_EMAIL)) + self.assertFalse(tracker.was_already_done(InactivityStep.DEACTIVATION)) + + def test_event_types_are_tracked_independently(self): + """The 3-day reminder must still go out to a user who already had the 10-day one.""" + from staff_user_inactivity_tracker import InactivityEventType, InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id, event_type=InactivityEventType.TEN_DAY).record_success(InactivityStep.USER_EMAIL) + + self.assertFalse( + self._tracker(user_id=user_id, event_type=InactivityEventType.THREE_DAY).was_already_done( + InactivityStep.USER_EMAIL + ) + ) + self.assertFalse( + self._tracker(user_id=user_id, event_type=InactivityEventType.DAY_OF).was_already_done( + InactivityStep.USER_EMAIL + ) + ) + + def test_users_are_tracked_independently(self): + from staff_user_inactivity_tracker import InactivityStep + + recorded_user_id = str(uuid4()) + self._tracker(user_id=recorded_user_id).record_success(InactivityStep.USER_EMAIL) + + self.assertFalse(self._tracker(user_id=str(uuid4())).was_already_done(InactivityStep.USER_EMAIL)) + + def test_a_new_last_login_date_is_a_new_key(self): + """If the user signs in and later goes dormant again, the earlier record must not suppress the new one.""" + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id, last_login_date=date(2024, 11, 8)).record_success(InactivityStep.USER_EMAIL) + + self.assertFalse( + self._tracker(user_id=user_id, last_login_date=date(2025, 3, 1)).was_already_done(InactivityStep.USER_EMAIL) + ) + + def test_record_failure_leaves_the_step_outstanding(self): + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + + self._tracker(user_id=user_id).record_failure(InactivityStep.USER_EMAIL, error_message='SES exploded') + + self.assertFalse(self._tracker(user_id=user_id).was_already_done(InactivityStep.USER_EMAIL)) + stored = self._event_state_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': f'socw#STAFF_USER_INACTIVITY#{user_id}'}, + )['Items'] + self.assertEqual(1, len(stored)) + self.assertEqual('FAILED', stored[0]['status']) + self.assertEqual('SES exploded', stored[0]['errorMessage']) + + def test_records_carry_a_ttl(self): + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + stored = self._event_state_table.query( + KeyConditionExpression='pk = :pk', + ExpressionAttributeValues={':pk': f'socw#STAFF_USER_INACTIVITY#{user_id}'}, + )['Items'] + self.assertIn('ttl', stored[0]) + + def test_read_failure_fails_open(self): + """A duplicate email is a better outcome than a missed deactivation.""" + from staff_user_inactivity_tracker import InactivityStep + + user_id = str(uuid4()) + self._tracker(user_id=user_id).record_success(InactivityStep.USER_EMAIL) + + with patch('cc_common.config._Config.event_state_table') as mock_table: + mock_table.query.side_effect = RuntimeError('DynamoDB is having a day') + tracker = self._tracker(user_id=user_id) + + self.assertFalse(tracker.was_already_done(InactivityStep.USER_EMAIL)) + + def test_write_failure_does_not_raise(self): + """Tracking is secondary - losing a write must not fail the run that already did the work.""" + from staff_user_inactivity_tracker import InactivityStep + + tracker = self._tracker(user_id=str(uuid4())) + + with patch('cc_common.config._Config.event_state_table') as mock_table: + mock_table.put_item.side_effect = RuntimeError('DynamoDB is having a day') + tracker.record_success(InactivityStep.USER_EMAIL) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/staff_user_test_data.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/staff_user_test_data.py new file mode 100644 index 0000000000..953c47c648 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/staff_user_test_data.py @@ -0,0 +1,61 @@ +"""Helpers for building staff users and directories in unit tests. + +Imports of cc_common are deferred into the functions so that the test base class can monkey-patch the +config object from its environment before anything reads it. +""" + +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +DEFAULT_LAST_LOGIN_AT = '2024-11-08T12:00:00+00:00' +ITERATE_USERS = 'cc_common.data_model.user_client.UserClient.iterate_all_users_in_compact' + + +def build_staff_user( + *, + last_login_at: str | None = DEFAULT_LAST_LOGIN_AT, + status: str | None = None, + compact_actions: set | None = None, + jurisdictions: dict | None = None, +): + """Build a StaffUserData. Pass last_login_at=None for a user who has never signed in.""" + from cc_common.data_model.schema.common import StaffUserStatus + from cc_common.data_model.schema.user import StaffUserData + from common_test.test_constants import DEFAULT_FAMILY_NAME, DEFAULT_GIVEN_NAME + from common_test.test_data_generator import TestDataGenerator + + user_id = str(uuid4()) + staff_user = TestDataGenerator.generate_default_staff_user( + { + 'userId': user_id, + 'status': status or StaffUserStatus.ACTIVE.value, + 'lastLoginAt': datetime.fromisoformat(last_login_at or DEFAULT_LAST_LOGIN_AT), + # Each user needs a distinct email, or assertions on recipient sets pass no matter which + # users the code picked + 'attributes': { + 'email': f'{user_id}@example.com', + 'givenName': DEFAULT_GIVEN_NAME, + 'familyName': DEFAULT_FAMILY_NAME, + }, + 'permissions': { + 'actions': compact_actions or set(), + 'jurisdictions': jurisdictions or {}, + }, + } + ) + if last_login_at is not None: + return staff_user + + # A user who has not signed in since login tracking was introduced has no lastLoginAt at all + record = staff_user.serialize_to_database_record() + del record['lastLoginAt'] + return StaffUserData.from_database_record(record) + + +def build_directory(users, *, compact: str = 'socw'): + """Build a CompactStaffUserDirectory over the given users, without touching DynamoDB.""" + from staff_user_directory import CompactStaffUserDirectory + + with patch(ITERATE_USERS, return_value=iter(users)): + return CompactStaffUserDirectory(compact=compact) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py new file mode 100644 index 0000000000..46432ca5b4 --- /dev/null +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py @@ -0,0 +1,97 @@ +from tests import TstLambdas +from tests.unit.staff_user_test_data import build_directory, build_staff_user + +ADMIN = 'admin' +WRITE = 'write' +READ_PRIVATE = 'readPrivate' + + +class TestResolveAdminRecipients(TstLambdas): + @staticmethod + def _resolve(user, directory): + from handlers.staff_user_inactivity import resolve_admin_recipients + + return resolve_admin_recipients(user=user, directory=directory) + + def test_state_user_notifies_that_states_admins_only(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, oh_admin, compact_admin])) + + self.assertEqual({oh_admin.email}, recipients) + + def test_user_in_two_states_notifies_both_admin_sets(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}, 'ne': {WRITE}}) + oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}) + ne_admin = build_staff_user(jurisdictions={'ne': {ADMIN}}) + + recipients = self._resolve(user, build_directory([user, oh_admin, ne_admin])) + + self.assertEqual({oh_admin.email, ne_admin.email}, recipients) + + def test_admin_in_two_of_the_users_states_is_only_listed_once(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}, 'ne': {WRITE}}) + both_states_admin = build_staff_user(jurisdictions={'oh': {ADMIN}, 'ne': {ADMIN}}) + + recipients = self._resolve(user, build_directory([user, both_states_admin])) + + self.assertEqual({both_states_admin.email}, recipients) + + def test_compact_level_user_notifies_compact_admins(self): + user = build_staff_user(compact_actions={READ_PRIVATE}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_user_who_is_the_states_only_admin_notifies_compact_admins(self): + user = build_staff_user(jurisdictions={'oh': {ADMIN}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_state_with_no_admins_notifies_compact_admins(self): + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + oh_writer = build_staff_user(jurisdictions={'oh': {WRITE}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, oh_writer, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_user_is_never_in_their_own_recipient_set(self): + """A user who is both a compact admin and a state admin still must not be told about themselves.""" + user = build_staff_user(compact_actions={ADMIN}, jurisdictions={'oh': {ADMIN}}) + other_compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, other_compact_admin])) + + self.assertNotIn(user.email, recipients) + self.assertEqual({other_compact_admin.email}, recipients) + + def test_user_with_compact_and_state_permissions_notifies_both(self): + user = build_staff_user(compact_actions={READ_PRIVATE}, jurisdictions={'oh': {WRITE}}) + oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, oh_admin, compact_admin])) + + self.assertEqual({oh_admin.email, compact_admin.email}, recipients) + + def test_no_admins_anywhere_returns_empty_set(self): + """A compact with nobody to notify is a configuration problem, but the user still gets their own email.""" + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + + with self.assertLogs() as logs: + recipients = self._resolve(user, build_directory([user])) + + self.assertEqual(set(), recipients) + self.assertTrue( + any(record.levelname == 'ERROR' for record in logs.records), + 'expected an ERROR log when there is no one to notify', + ) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py index 79edd2dbb0..1972a85546 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py @@ -1,52 +1,14 @@ -from datetime import date, datetime -from unittest.mock import patch -from uuid import uuid4 +from datetime import date from tests import TstLambdas +from tests.unit.staff_user_test_data import build_directory, build_staff_user + # The directory's own query and pagination are covered by the UserClient function tests. These tests # feed it users directly, so they are only about how it classifies them. -ITERATE_USERS = 'cc_common.data_model.user_client.UserClient.iterate_all_users_in_compact' - - class TestCompactStaffUserDirectory(TstLambdas): - @staticmethod - def _staff_user( - *, - last_login_at: str | None = '2024-11-08T12:00:00+00:00', - status: str | None = None, - compact_actions: set | None = None, - jurisdictions: dict | None = None, - ): - from cc_common.data_model.schema.common import StaffUserStatus - from cc_common.data_model.schema.user import StaffUserData - from common_test.test_data_generator import TestDataGenerator - - staff_user = TestDataGenerator.generate_default_staff_user( - { - 'userId': str(uuid4()), - 'status': status or StaffUserStatus.ACTIVE.value, - 'lastLoginAt': datetime.fromisoformat(last_login_at or '2024-11-08T12:00:00+00:00'), - 'permissions': { - 'actions': compact_actions or set(), - 'jurisdictions': jurisdictions or {}, - }, - } - ) - if last_login_at is not None: - return staff_user - - # A user who has not signed in since login tracking was introduced has no lastLoginAt at all - record = staff_user.serialize_to_database_record() - del record['lastLoginAt'] - return StaffUserData.from_database_record(record) - - @staticmethod - def _build_directory(users): - from staff_user_directory import CompactStaffUserDirectory - - with patch(ITERATE_USERS, return_value=iter(users)): - return CompactStaffUserDirectory(compact='socw') + _staff_user = staticmethod(build_staff_user) + _build_directory = staticmethod(build_directory) def test_users_last_seen_on_matches_only_that_date(self): day_before = self._staff_user(last_login_at='2024-11-07T23:59:59+00:00') From 49f7ca75c04ed554f0743b353753ae3bfdd6b041 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 11 Aug 2026 14:58:06 -0500 Subject: [PATCH 10/23] Add email notification for staff user inactivity --- .../email-notification-service/lambda.ts | 18 +++++ .../lib/email/email-notification-service.ts | 64 +++++++++++++++++ .../tests/email-notification-service.test.ts | 54 +++++++++++++++ .../email/email-notification-service.test.ts | 69 +++++++++++++++++++ 4 files changed, 205 insertions(+) diff --git a/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts b/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts index a10b985715..f846ca64f8 100644 --- a/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts +++ b/backend/social-work-app/lambdas/nodejs/email-notification-service/lambda.ts @@ -348,6 +348,24 @@ export class Lambda implements LambdaInterface { event.templateVariables.newJurisdiction ); break; + case 'staffUserInactivityNotification': + if (!event.templateVariables?.staffUserFirstName + || !event.templateVariables?.staffUserLastName + || !event.templateVariables?.staffUserEmail + || !event.templateVariables?.deactivationDate + || !event.templateVariables?.inactivityPeriodDays) { + throw new Error('Missing required template variables for staffUserInactivityNotification template.'); + } + await this.emailService.sendStaffUserInactivityNotificationEmail( + event.compact, + event.specificEmails || [], + event.templateVariables.staffUserFirstName, + event.templateVariables.staffUserLastName, + event.templateVariables.staffUserEmail, + event.templateVariables.deactivationDate, + event.templateVariables.inactivityPeriodDays + ); + break; default: logger.info('Unsupported email template provided', { template: event.template }); throw new Error(`Unsupported email template: ${event.template}`); diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts index ea1543e8e6..a9ffbc0fe2 100644 --- a/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts +++ b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts @@ -4,6 +4,15 @@ import { RecipientType } from '../models/email-notification-service-event'; const environmentVariableService = new EnvironmentVariablesService(); +/** Format an ISO 8601 date string (YYYY-MM-DD) for display as MM/DD/YYYY (e.g. "09/14/2026"). Timezone-neutral. */ +function formatIsoDateAsSlashFormat(isoDate: string): string { + const [year, month, day] = isoDate.split('-').map(Number); + const paddedMonth = String(month).padStart(2, '0'); + const paddedDay = String(day).padStart(2, '0'); + + return `${paddedMonth}/${paddedDay}/${year}`; +} + /** * Email service for handling email notifications */ @@ -77,4 +86,59 @@ export class EmailNotificationService extends BaseEmailService { await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send home jurisdiction change state notification email' }); } + + /** + * Sends a notification that a staff user's account is scheduled for inactivity deactivation. + * + * The body is written in the third person so the same email serves the staff user and their + * administrators, and states an absolute date rather than a countdown so it stays true whenever it is read. + * + * @param compact - The compact name + * @param specificEmails - The address(es) to send this notification to + * @param staffUserFirstName - The affected staff user's first name + * @param staffUserLastName - The affected staff user's last name + * @param staffUserEmail - The affected staff user's email address + * @param deactivationDate - ISO 8601 date string (YYYY-MM-DD) the account is deactivated on + * @param inactivityPeriodDays - How many days of inactivity trigger deactivation + */ + public async sendStaffUserInactivityNotificationEmail( + compact: string, + specificEmails: string[], + staffUserFirstName: string, + staffUserLastName: string, + staffUserEmail: string, + deactivationDate: string, + inactivityPeriodDays: number + ): Promise { + this.logger.info('Sending staff user inactivity notification email', { compact: compact }); + + if (specificEmails.length === 0) { + throw new Error('No recipients found for staff user inactivity notification email'); + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const staffUserName = `${staffUserFirstName} ${staffUserLastName}`; + const deactivationDateDisplay = formatIsoDateAsSlashFormat(deactivationDate); + const subject = `CompactConnect account for ${staffUserName} will be deactivated on ${deactivationDateDisplay}`; + + const report = this.getNewEmailTemplate(); + const bodyText = `The ${compactConfig.compactName} CompactConnect account for ${staffUserName} (${staffUserEmail}) will be deactivated on ${deactivationDateDisplay}. CompactConnect deactivates staff user accounts after ${inactivityPeriodDays} days with no sign-in activity.\n\n` + + `To prevent deactivation, ${staffUserName} should sign in to CompactConnect before ${deactivationDateDisplay}.\n\n` + + `If the account has already been deactivated, an administrator will need to re-invite ${staffUserName} to CompactConnect to restore access.\n\n` + + `Sign in: ${environmentVariableService.getUiBasePathUrl()}/Dashboard`; + + this.insertHeader(report, 'Account Deactivation Notice'); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ + htmlContent, + subject, + recipients: specificEmails, + errorMessage: 'Unable to send staff user inactivity notification email' + }); + } + } diff --git a/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts index fa9886abf5..06b339b6e2 100644 --- a/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts +++ b/backend/social-work-app/lambdas/nodejs/tests/email-notification-service.test.ts @@ -1168,4 +1168,58 @@ describe('EmailNotificationServiceLambda', () => { .toThrow('Missing required template variables for home jurisdiction change notification template.'); }); }); + + describe('Staff User Inactivity Notification', () => { + const SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'staffUserInactivityNotification', + recipientType: 'SPECIFIC', + compact: 'socw', + specificEmails: ['jane@example.com'], + templateVariables: { + staffUserFirstName: 'Jane', + staffUserLastName: 'Smith', + staffUserEmail: 'jane@example.com', + deactivationDate: '2026-09-14', + inactivityPeriodDays: 60 + } + }; + + it('should successfully send staff user inactivity notification email', async () => { + mockDynamoDBClient.on(GetItemCommand).resolves({ Item: SAMPLE_COMPACT_CONFIGURATION }); + + const response = await lambda.handler( + SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT, + {} as any + ); + + expect(response).toEqual({ message: 'Email message sent' }); + expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, { + Destination: { + ToAddresses: ['jane@example.com'] + } + }); + }); + + it('should throw error when required template variables are missing', async () => { + const eventWithMissingVariables: EmailNotificationEvent = { + ...SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT, + templateVariables: {} + }; + + await expect(lambda.handler(eventWithMissingVariables, {} as any)) + .rejects + .toThrow('Missing required template variables for staffUserInactivityNotification template.'); + }); + + it('should throw error when no recipients are provided', async () => { + const eventWithNoRecipients: EmailNotificationEvent = { + ...SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT, + specificEmails: [] + }; + + await expect(lambda.handler(eventWithNoRecipients, {} as any)) + .rejects + .toThrow('No recipients found for staff user inactivity notification email'); + }); + }); }); diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts index b61f4c6331..6d8f613086 100644 --- a/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts @@ -201,4 +201,73 @@ describe('EmailNotificationService', () => { )).rejects.toThrow('No recipients found for jurisdiction oh in compact aslp'); }); }); + + describe('Staff User Inactivity Notification', () => { + const sendInactivityNotification = (recipients: string[] = ['jane@example.com']) => + emailService.sendStaffUserInactivityNotificationEmail( + 'aslp', + recipients, + 'Jane', + 'Smith', + 'jane@example.com', + '2026-09-14', + 60 + ); + + it('should send the notification with expected subject and content', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await sendInactivityNotification(['jane@example.com', 'admin@example.com']); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['jane@example.com', 'admin@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'CompactConnect account for Jane Smith will be deactivated on 09/14/2026' + } + } + }, + FromEmailAddress: 'CompactConnect ' + } + ); + }); + + it('should render the deactivation date, the account it refers to, and the recovery path', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await sendInactivityNotification(); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + // Third person throughout, so the same body serves the user and their administrators + expect(htmlContent).toContain('Jane Smith'); + expect(htmlContent).toContain('jane@example.com'); + expect(htmlContent).toContain('09/14/2026'); + expect(htmlContent).toContain('60 days'); + expect(htmlContent).toContain('re-invite'); + expect(htmlContent).toContain('https://app.test.compactconnect.org/Dashboard'); + }); + + it('should throw when there are no recipients', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await expect(sendInactivityNotification([])) + .rejects + .toThrow('No recipients found for staff user inactivity notification email'); + }); + }); }); From 85e4dfd8ef4b28f95e603127981ebf1526cb1915 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 11 Aug 2026 15:24:52 -0500 Subject: [PATCH 11/23] Add stack for processing staff user inactivity tracking --- .../social-work-app/pipeline/backend_stage.py | 14 ++ .../stacks/staff_user_inactivity_stack.py | 156 ++++++++++++++++++ backend/social-work-app/tests/app/base.py | 1 + .../app/test_staff_user_inactivity_stack.py | 125 ++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 backend/social-work-app/stacks/staff_user_inactivity_stack.py create mode 100644 backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py diff --git a/backend/social-work-app/pipeline/backend_stage.py b/backend/social-work-app/pipeline/backend_stage.py index 2819220250..3f144935f1 100644 --- a/backend/social-work-app/pipeline/backend_stage.py +++ b/backend/social-work-app/pipeline/backend_stage.py @@ -15,6 +15,7 @@ from stacks.reporting_stack import ReportingStack from stacks.search_api_stack import SearchApiStack from stacks.search_persistent_stack import SearchPersistentStack +from stacks.staff_user_inactivity_stack import StaffUserInactivityStack from stacks.state_api_stack import StateApiStack from stacks.state_auth import StateAuthStack from stacks.vpc_stack import VpcStack @@ -173,6 +174,19 @@ def __init__( persistent_stack=self.persistent_stack, ) + # This job emails staff users before deactivating them, so it must not run in an + # environment that cannot send email + self.staff_user_inactivity_stack = StaffUserInactivityStack( + self, + 'StaffUserInactivityStack', + env=environment, + environment_context=environment_context, + environment_name=environment_name, + standard_tags=standard_tags, + persistent_stack=self.persistent_stack, + event_state_stack=self.event_state_stack, + ) + # Disaster recovery workflows for DynamoDB tables self.disaster_recovery_stack = DisasterRecoveryStack( self, diff --git a/backend/social-work-app/stacks/staff_user_inactivity_stack.py b/backend/social-work-app/stacks/staff_user_inactivity_stack.py new file mode 100644 index 0000000000..2994d7cd33 --- /dev/null +++ b/backend/social-work-app/stacks/staff_user_inactivity_stack.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import json +import os + +from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, Stats, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import Rule, RuleTargetInput, Schedule +from aws_cdk.aws_events_targets import LambdaFunction +from aws_cdk.aws_logs import QueryDefinition, QueryString, RetentionDays +from cdk_nag import NagSuppressions +from common_constructs.python_function import PythonFunction +from common_constructs.stack import AppStack +from constructs import Construct + +from stacks import event_state_stack as ess +from stacks import persistent_stack as ps + +# How long a staff user can go without signing in before their account is deactivated +STAFF_USER_INACTIVITY_PERIOD_DAYS = 60 + +# Days before the stated deactivation date that each rule fires. 0 is the run that deactivates. +REMINDER_CONFIGS = [ + {'days_before': 10, 'suffix': '10Day'}, + {'days_before': 3, 'suffix': '3Day'}, + {'days_before': 0, 'suffix': 'DayOf'}, +] + +# Jurisdictions span Guam/N. Mariana (UTC+10) through Hawaii (UTC-10), so only 10:00-13:00 UTC is the +# same calendar date everywhere. Running outside that window would deactivate accounts a day before the +# date the notification emails state. See docs/staff-user-inactivity-deactivation-notifications-design.md +RUN_HOUR_UTC = '12' + + +class StaffUserInactivityStack(AppStack): + """ + Stack for staff user inactivity notifications and deactivation. + + - Lambda that notifies a staff user and their administrators ahead of deactivation, and deactivates + on the day-of run + - EventBridge rules per compact and reminder type (10-day, 3-day, day-of) that run daily + - CloudWatch alarms for errors and execution duration + """ + + def __init__( + self, + scope: Construct, + construct_id: str, + *, + environment_name: str, + persistent_stack: ps.PersistentStack, + event_state_stack: ess.EventStateStack, + **kwargs, + ): + super().__init__(scope, construct_id, environment_name=environment_name, **kwargs) + + self.staff_user_inactivity_handler = PythonFunction( + self, + 'StaffUserInactivityHandler', + description='Processes staff user inactivity notifications and deactivations', + lambda_dir='staff-users', + index=os.path.join('handlers', 'staff_user_inactivity.py'), + handler='process_staff_user_inactivity', + timeout=Duration.minutes(15), + memory_size=1024, + log_retention=RetentionDays.ONE_MONTH, + environment={ + 'USER_POOL_ID': persistent_stack.staff_users.user_pool_id, + 'USERS_TABLE_NAME': persistent_stack.staff_users.user_table.table_name, + 'FAM_GIV_INDEX_NAME': persistent_stack.staff_users.user_table.family_given_index_name, + 'EVENT_STATE_TABLE_NAME': event_state_stack.event_state_table.table_name, + 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': ( + persistent_stack.email_notification_service_lambda.function_name + ), + 'STAFF_USER_INACTIVITY_PERIOD_DAYS': str(STAFF_USER_INACTIVITY_PERIOD_DAYS), + **self.common_env_vars, + }, + alarm_topic=persistent_stack.alarm_topic, + ) + + # Write access is needed to mark deactivated users inactive + persistent_stack.staff_users.user_table.grant_read_write_data(self.staff_user_inactivity_handler) + event_state_stack.event_state_table.grant_read_write_data(self.staff_user_inactivity_handler) + persistent_stack.email_notification_service_lambda.grant_invoke(self.staff_user_inactivity_handler) + persistent_stack.staff_users.grant(self.staff_user_inactivity_handler, 'cognito-idp:AdminDisableUser') + + NagSuppressions.add_resource_suppressions_by_path( + self, + f'{self.staff_user_inactivity_handler.role.node.path}/DefaultPolicy/Resource', + [ + { + 'id': 'AwsSolutions-IAM5', + 'reason': 'This policy contains wild-carded actions and resources but they are scoped to the ' + 'specific actions, KMS key, Table, and Lambda that this lambda specifically needs access to.', + }, + ], + ) + + # All three rules fire in the same minute. + for compact in json.loads(self.common_env_vars['COMPACTS']): + for reminder_config in REMINDER_CONFIGS: + Rule( + self, + f'StaffUserInactivity{reminder_config["suffix"]}Rule{compact.upper()}', + description=f'Daily rule to notify staff users in {compact} ' + f'{reminder_config["days_before"]} days before inactivity deactivation', + schedule=Schedule.cron(week_day='*', hour=RUN_HOUR_UTC, minute='0', month='*', year='*'), + targets=[ + LambdaFunction( + handler=self.staff_user_inactivity_handler, + event=RuleTargetInput.from_object( + { + 'compact': compact, + 'daysBeforeDeactivation': reminder_config['days_before'], + } + ), + ) + ], + ) + + Alarm( + self, + 'StaffUserInactivityErrorAlarm', + metric=self.staff_user_inactivity_handler.metric_errors(statistic=Stats.SUM), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'{self.staff_user_inactivity_handler.node.path} failed to process staff user inactivity', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + Alarm( + self, + 'StaffUserInactivityDurationAlarm', + metric=self.staff_user_inactivity_handler.metric_duration(statistic=Stats.MAXIMUM, period=Duration.days(1)), + evaluation_periods=1, + threshold=600_000, # 10 minutes in milliseconds + actions_enabled=True, + alarm_description=f'{self.staff_user_inactivity_handler.node.path} Lambda Duration exceeded 10 minutes', + comparison_operator=ComparisonOperator.GREATER_THAN_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + + QueryDefinition( + self, + 'StaffUserInactivityQuery', + query_definition_name=f'{self.node.id}/StaffUserInactivityHandler', + query_string=QueryString( + fields=['@timestamp', '@log', 'level', 'message', 'compact', 'user_id', 'event_type', '@message'], + filter_statements=['level in ["INFO", "WARNING", "ERROR"]'], + sort='@timestamp desc', + ), + log_groups=[self.staff_user_inactivity_handler.log_group], + ) diff --git a/backend/social-work-app/tests/app/base.py b/backend/social-work-app/tests/app/base.py index 5cf8248507..9a3f7070b3 100644 --- a/backend/social-work-app/tests/app/base.py +++ b/backend/social-work-app/tests/app/base.py @@ -525,6 +525,7 @@ def _check_no_backend_stage_annotations(self, stage: BackendStage): if stage.persistent_stack.hosted_zone: self._check_no_stack_annotations(stage.notification_stack) self._check_no_stack_annotations(stage.reporting_stack) + self._check_no_stack_annotations(stage.staff_user_inactivity_stack) # No backup stack here, because nexted stack annotations are checked in the parent stack def _count_stack_resources(self, stack: Stack) -> int: diff --git a/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py new file mode 100644 index 0000000000..9257485a20 --- /dev/null +++ b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py @@ -0,0 +1,125 @@ +import json +from unittest import TestCase + +from aws_cdk.assertions import Template +from aws_cdk.aws_cloudwatch import CfnAlarm +from aws_cdk.aws_events import CfnRule +from aws_cdk.aws_iam import CfnPolicy +from aws_cdk.aws_lambda import CfnFunction + +from tests.app.base import TstAppABC + +# Only 10:00-13:00 UTC is the same calendar date in every jurisdiction the compact covers, from +# Guam (UTC+10) through Hawaii (UTC-10). Running outside that window would deactivate accounts a day +# before the date the notification emails state. +EXPECTED_SCHEDULE = 'cron(0 12 ? * * *)' +EXPECTED_RULE_SUFFIXES = ('10Day', '3Day', 'DayOf') +EXPECTED_DAYS_BEFORE = {'10Day': 10, '3Day': 3, 'DayOf': 0} + + +class TestStaffUserInactivityStack(TstAppABC, TestCase): + """ + Test cases for the StaffUserInactivityStack, which notifies staff users ahead of inactivity + deactivation and deactivates them on the day-of run. + """ + + @classmethod + def get_context(cls): + with open('cdk.json') as f: + context = json.load(f)['context'] + with open('cdk.context.sandbox-example.json') as f: + context.update(json.load(f)) + + # Suppresses lambda bundling for tests + context['aws:cdk:bundling-stacks'] = [] + return context + + @property + def _stack(self): + return self.app.sandbox_backend_stage.staff_user_inactivity_stack + + def test_handler_created_with_expected_configuration(self): + template = Template.from_stack(self._stack) + + handler = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id(self._stack.staff_user_inactivity_handler.node.default_child), + template.find_resources(CfnFunction.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual('handlers.staff_user_inactivity.process_staff_user_inactivity', handler['Handler']) + self.assertEqual(900, handler['Timeout']) + self.assertEqual('60', handler['Environment']['Variables']['STAFF_USER_INACTIVITY_PERIOD_DAYS']) + + def test_eventbridge_rules_created_for_each_compact_and_reminder_type(self): + template = Template.from_stack(self._stack) + rules = template.find_resources(CfnRule.CFN_RESOURCE_TYPE_NAME) + compacts = self.get_context()['compacts'] + + self.assertEqual( + len(compacts) * len(EXPECTED_RULE_SUFFIXES), + len(rules), + 'Expected one rule per compact per reminder type', + ) + + handler_logical_id = self._stack.get_logical_id(self._stack.staff_user_inactivity_handler.node.default_child) + for compact in compacts: + for suffix in EXPECTED_RULE_SUFFIXES: + rule_name = f'StaffUserInactivity{suffix}Rule{compact.upper()}' + with self.subTest(rule=rule_name): + rule = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id(self._stack.node.find_child(rule_name).node.default_child), + rules, + ) + + self.assertEqual(EXPECTED_SCHEDULE, rule['ScheduleExpression']) + self.assertEqual('ENABLED', rule['State']) + + target = rule['Targets'][0] + self.assertEqual(handler_logical_id, target['Arn']['Fn::GetAtt'][0]) + self.assertEqual( + {'compact': compact, 'daysBeforeDeactivation': EXPECTED_DAYS_BEFORE[suffix]}, + json.loads(target['Input']), + ) + + def test_handler_granted_permission_to_disable_cognito_users(self): + """The day-of run disables users in Cognito, which is what actually revokes their access.""" + template = Template.from_stack(self._stack) + + role_logical_id = self._stack.get_logical_id(self._stack.staff_user_inactivity_handler.role.node.default_child) + role_policies = template.find_resources( + type=CfnPolicy.CFN_RESOURCE_TYPE_NAME, + props={'Properties': {'Roles': [{'Ref': role_logical_id}]}}, + ) + self.assertTrue(role_policies, 'No IAM policy found for the staff user inactivity handler role') + + granted_actions = set() + for policy in role_policies.values(): + for statement in policy['Properties']['PolicyDocument']['Statement']: + actions = statement['Action'] + granted_actions.update(actions if isinstance(actions, list) else [actions]) + + self.assertIn('cognito-idp:AdminDisableUser', granted_actions) + # Deactivation marks every one of the user's records inactive, so read alone is not enough + self.assertIn('dynamodb:UpdateItem', granted_actions) + + def test_alarms_configured(self): + template = Template.from_stack(self._stack) + alarms = template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME) + + error_alarm = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id(self._stack.node.find_child('StaffUserInactivityErrorAlarm').node.default_child), + alarms, + ) + self.assertEqual(1, error_alarm['Threshold']) + self.assertEqual('GreaterThanOrEqualToThreshold', error_alarm['ComparisonOperator']) + self.assertEqual('Errors', error_alarm['MetricName']) + + duration_alarm = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id( + self._stack.node.find_child('StaffUserInactivityDurationAlarm').node.default_child + ), + alarms, + ) + self.assertEqual(600_000, duration_alarm['Threshold']) + self.assertEqual('GreaterThanThreshold', duration_alarm['ComparisonOperator']) + self.assertEqual('Duration', duration_alarm['MetricName']) From 7b15a6abdb7f092873428a5bffa34a38840311de Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 11 Aug 2026 17:11:59 -0500 Subject: [PATCH 12/23] Filter inactive admins from receiving notifications --- .../handlers/staff_user_inactivity.py | 19 ++++++++++++-- .../staff-users/staff_user_directory.py | 6 +++-- .../unit/test_resolve_admin_recipients.py | 26 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py index 267f0fd9c6..408a579ee8 100644 --- a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py @@ -5,6 +5,7 @@ from aws_lambda_powertools.utilities.typing import LambdaContext from cc_common.config import config, logger +from cc_common.data_model.schema.common import StaffUserStatus from cc_common.data_model.schema.user import StaffUserData from cc_common.email_service_client import StaffUserInactivityNotificationTemplateVariables from cc_common.exceptions import CCInternalException, CCInvalidRequestException @@ -284,7 +285,7 @@ def resolve_admin_recipients(*, user: StaffUserData, directory: CompactStaffUser admin_emails: set[str] = set() for jurisdiction in user.jurisdictions: - others = [admin for admin in directory.jurisdiction_admins(jurisdiction) if admin.userId != user.userId] + others = _notifiable_admins(directory.jurisdiction_admins(jurisdiction), excluding=user) if not others: # Covers both "the state has no admins" and "the user is the state's only admin" - once the # user is excluded, those are the same condition @@ -297,7 +298,7 @@ def resolve_admin_recipients(*, user: StaffUserData, directory: CompactStaffUser notify_compact_admins = True if notify_compact_admins: - admin_emails.update(admin.email for admin in directory.compact_admins if admin.userId != user.userId) + admin_emails.update(admin.email for admin in _notifiable_admins(directory.compact_admins, excluding=user)) if not admin_emails: logger.error( @@ -307,3 +308,17 @@ def resolve_admin_recipients(*, user: StaffUserData, directory: CompactStaffUser ) return admin_emails + + +def _notifiable_admins(admins: list[StaffUserData], *, excluding: StaffUserData) -> list[StaffUserData]: + """The admins from this list who are worth notifying. + + The directory returns admins whatever their status, since other callers may want them all. Here we + want only admins who can act: an inactive admin cannot sign in, and counting one would suppress + escalation to the compact admins for a state whose only admin is inactive. + + The user being deactivated is excluded too - they receive their own copy. + """ + return [ + admin for admin in admins if admin.userId != excluding.userId and admin.status == StaffUserStatus.ACTIVE.value + ] diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py index 1724a32d99..69db6b0c13 100644 --- a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py @@ -42,12 +42,14 @@ def users_last_seen_on_or_before(self, last_login_date: date) -> list[StaffUserD return [user for user in self._candidates() if self._last_login_date(user) <= last_login_date] def jurisdiction_admins(self, jurisdiction: str) -> list[StaffUserData]: - """Users holding the admin action in this jurisdiction.""" + """Users holding the admin action in this jurisdiction, whatever their status. + """ return list(self._jurisdiction_admins[jurisdiction]) @property def compact_admins(self) -> list[StaffUserData]: - """Users holding the admin action at the compact level.""" + """Users holding the admin action at the compact level, whatever their status. + """ return list(self._compact_admins) def _candidates(self) -> Generator[StaffUserData, None, None]: diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py index 46432ca5b4..fbdd6cfe48 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py @@ -83,6 +83,32 @@ def test_user_with_compact_and_state_permissions_notifies_both(self): self.assertEqual({oh_admin.email, compact_admin.email}, recipients) + def test_deactivated_state_admin_does_not_suppress_escalation(self): + """A deactivated admin cannot sign in to act, so the state counts as having no admins. + + Without this, a state whose only admin has been deactivated notifies a disabled account and + never escalates to the compact admins - so nobody who can act is told. + """ + from cc_common.data_model.schema.common import StaffUserStatus + + user = build_staff_user(jurisdictions={'oh': {WRITE}}) + deactivated_oh_admin = build_staff_user(jurisdictions={'oh': {ADMIN}}, status=StaffUserStatus.INACTIVE.value) + compact_admin = build_staff_user(compact_actions={ADMIN}) + + recipients = self._resolve(user, build_directory([user, deactivated_oh_admin, compact_admin])) + + self.assertEqual({compact_admin.email}, recipients) + + def test_deactivated_compact_admin_is_not_notified(self): + from cc_common.data_model.schema.common import StaffUserStatus + + user = build_staff_user(compact_actions={READ_PRIVATE}) + deactivated_compact_admin = build_staff_user(compact_actions={ADMIN}, status=StaffUserStatus.INACTIVE.value) + + recipients = self._resolve(user, build_directory([user, deactivated_compact_admin])) + + self.assertEqual(set(), recipients) + def test_no_admins_anywhere_returns_empty_set(self): """A compact with nobody to notify is a configuration problem, but the user still gets their own email.""" user = build_staff_user(jurisdictions={'oh': {WRITE}}) From ee975cab4625e980269ea77ec11c81170c592eb4 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 11 Aug 2026 17:28:23 -0500 Subject: [PATCH 13/23] Add error log alert for pre-token generation lambda --- .../stacks/persistent_stack/staff_users.py | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/backend/social-work-app/stacks/persistent_stack/staff_users.py b/backend/social-work-app/stacks/persistent_stack/staff_users.py index ff3079185a..7acdc97598 100644 --- a/backend/social-work-app/stacks/persistent_stack/staff_users.py +++ b/backend/social-work-app/stacks/persistent_stack/staff_users.py @@ -3,6 +3,8 @@ import json from aws_cdk import Duration +from aws_cdk.aws_cloudwatch import Alarm, ComparisonOperator, TreatMissingData +from aws_cdk.aws_cloudwatch_actions import SnsAction from aws_cdk.aws_cognito import ( ClientAttributes, LambdaVersion, @@ -13,6 +15,7 @@ UserPoolOperation, ) from aws_cdk.aws_kms import IKey +from aws_cdk.aws_logs import FilterPattern, MetricFilter from cdk_nag import NagSuppressions from common_constructs.nodejs_function import NodejsFunction from common_constructs.python_function import PythonFunction @@ -124,7 +127,7 @@ def _add_scope_customization(self, stack: ps.PersistentStack): compacts = self.node.get_context('compacts') jurisdictions = self.node.get_context('jurisdictions') - scope_customization_handler = PythonFunction( + self.scope_customization_handler = PythonFunction( self, 'ScopeCustomizationHandler', description='Auth scope customization handler', @@ -140,10 +143,37 @@ def _add_scope_customization(self, stack: ps.PersistentStack): **stack.common_env_vars, }, ) - self.user_table.grant_read_write_data(scope_customization_handler) + self.user_table.grant_read_write_data(self.scope_customization_handler) + + # This handler swallows its own errors so that a failure cannot block authentication, which means + # a broken scope calculation or an unrecorded login would otherwise be invisible. The ERROR logs it + # writes instead are the only signal, so we alarm on them. + scope_customization_error_metric = MetricFilter( + self, + 'ScopeCustomizationHandlerErrorLogMetric', + log_group=self.scope_customization_handler.log_group, + metric_namespace='CompactConnect/StaffUsers', + metric_name='ScopeCustomizationHandlerErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + + Alarm( + self, + 'ScopeCustomizationHandlerErrorLogAlarm', + metric=scope_customization_error_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'The Scope Customization Lambda logged an ERROR level message. Investigate the logs ' + f'for the {self.scope_customization_handler.function_name} lambda to determine the cause.', + 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( - scope_customization_handler.role, + self.scope_customization_handler.role, apply_to_children=True, suppressions=[ { @@ -157,7 +187,7 @@ def _add_scope_customization(self, stack: ps.PersistentStack): ) self.add_trigger( UserPoolOperation.PRE_TOKEN_GENERATION_CONFIG, - scope_customization_handler, + self.scope_customization_handler, lambda_version=LambdaVersion.V2_0, ) From a8d56635282db2932cab88e6de2f04da1054220a Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 12 Aug 2026 14:10:23 -0500 Subject: [PATCH 14/23] Send notification the last day before deactivation --- .../handlers/staff_user_inactivity.py | 3 +- .../staff-users/staff_user_directory.py | 6 +-- .../staff_user_inactivity_tracker.py | 3 +- .../test_staff_user_inactivity.py | 47 ++++++++++++++----- .../test_staff_user_inactivity_tracker.py | 10 ++-- .../stacks/staff_user_inactivity_stack.py | 1 + .../app/test_staff_user_inactivity_stack.py | 4 +- 7 files changed, 50 insertions(+), 24 deletions(-) diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py index 408a579ee8..7e9e23eb07 100644 --- a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py @@ -15,6 +15,7 @@ DAYS_BEFORE_TO_EVENT_TYPE = { 10: InactivityEventType.TEN_DAY, 3: InactivityEventType.THREE_DAY, + 1: InactivityEventType.ONE_DAY, 0: InactivityEventType.DAY_OF, } @@ -71,7 +72,7 @@ def process_staff_user_inactivity(event: dict, context: LambdaContext) -> dict: Event format: { "compact": "socw", # required - "daysBeforeDeactivation": 10, # required - 10, 3, or 0 + "daysBeforeDeactivation": 10, # required - 10, 3, 1, or 0 "targetLastLoginDate": "2026-06-21" # optional - replay a specific day's matched_users } """ diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py index 69db6b0c13..c2b420b952 100644 --- a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py @@ -42,14 +42,12 @@ def users_last_seen_on_or_before(self, last_login_date: date) -> list[StaffUserD return [user for user in self._candidates() if self._last_login_date(user) <= last_login_date] def jurisdiction_admins(self, jurisdiction: str) -> list[StaffUserData]: - """Users holding the admin action in this jurisdiction, whatever their status. - """ + """Users holding the admin action in this jurisdiction, whatever their status.""" return list(self._jurisdiction_admins[jurisdiction]) @property def compact_admins(self) -> list[StaffUserData]: - """Users holding the admin action at the compact level, whatever their status. - """ + """Users holding the admin action at the compact level, whatever their status.""" return list(self._compact_admins) def _candidates(self) -> Generator[StaffUserData, None, None]: diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py index 71416cb3ca..3e2e79f401 100644 --- a/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_inactivity_tracker.py @@ -8,10 +8,11 @@ class InactivityEventType(StrEnum): - """Which of the three scheduled runs an attempt belongs to.""" + """Which of the scheduled runs an attempt belongs to.""" TEN_DAY = 'staffUser.inactivity.10day' THREE_DAY = 'staffUser.inactivity.3day' + ONE_DAY = 'staffUser.inactivity.1day' DAY_OF = 'staffUser.inactivity.dayOf' diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py index ffd2106607..8254d48b20 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py @@ -84,7 +84,7 @@ def test_invalid_days_before_raises(self): self._run(days_before=7) self.assertEqual( - 'Invalid daysBeforeDeactivation: 7. Must be one of [0, 3, 10].', + 'Invalid daysBeforeDeactivation: 7. Must be one of [0, 1, 3, 10].', ctx.exception.message, ) @@ -101,23 +101,48 @@ def test_invalid_compact_raises(self): ) def test_each_reminder_run_targets_its_own_day(self): - """10-day fires at 51 days since login, 3-day at 58. Both are exact, not ranges.""" - _, ten_day_email = self._seed_user(days_since_login=51) - _, three_day_email = self._seed_user(days_since_login=58) - # Neighbours on either side of both boundaries - for days in (50, 52, 57, 59): + """10-day fires at 51 days since login, 3-day at 58, 1-day at 60. All exact, not ranges.""" + expected_email_by_run = { + 10: self._seed_user(days_since_login=51)[1], + 3: self._seed_user(days_since_login=58)[1], + 1: self._seed_user(days_since_login=60)[1], + } + # Neighbours on either side of every boundary + for days in (50, 52, 57, 59, 61): self._seed_user(days_since_login=days) + for days_before, expected_email in expected_email_by_run.items(): + with ( + self.subTest(days_before=days_before), + patch('cc_common.config._Config.email_service_client') as mock_email_client, + ): + self._run(days_before=days_before) + self.assertEqual([[expected_email]], self._recipient_sets(mock_email_client)) + + def test_one_day_run_notifies_on_the_last_usable_day(self): + """The 1-day notice lands on day 60 - the last day the user can sign in and stop this.""" + user_id, user_email = self._seed_user(days_since_login=60) + with patch('cc_common.config._Config.email_service_client') as mock_email_client: - self._run(days_before=10) - self.assertEqual([[ten_day_email]], self._recipient_sets(mock_email_client)) + result = self._run(days_before=1) + + self.assertEqual(1, result['metrics']['matchedUsers']) + self.assertEqual([[user_email]], self._recipient_sets(mock_email_client)) + self.assertEqual(0, result['metrics']['deactivated']) + self.assertTrue(self._is_cognito_user_enabled(user_id)) + + def test_one_day_notice_says_deactivation_is_tomorrow(self): + """On the final usable day the notice must point at the following day, not today.""" + self._seed_user(days_since_login=60) with patch('cc_common.config._Config.email_service_client') as mock_email_client: - self._run(days_before=3) - self.assertEqual([[three_day_email]], self._recipient_sets(mock_email_client)) + self._run(days_before=1) + + template_variables = self._sent_payloads(mock_email_client)[0]['template_variables'] + self.assertEqual((MOCK_TODAY + timedelta(days=1)).date(), template_variables.deactivation_date) def test_user_last_seen_exactly_60_days_ago_is_not_deactivated(self): - """The user keeps the whole of day 60 - deactivation lands on day 61.""" + """The user keeps the whole of day 60 - the day-of run does not reach back that far.""" user_id, _ = self._seed_user(days_since_login=60) with patch('cc_common.config._Config.email_service_client'): diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py index e6dd691a20..bc9e2b68f6 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_staff_user_inactivity_tracker.py @@ -57,11 +57,11 @@ def test_event_types_are_tracked_independently(self): InactivityStep.USER_EMAIL ) ) - self.assertFalse( - self._tracker(user_id=user_id, event_type=InactivityEventType.DAY_OF).was_already_done( - InactivityStep.USER_EMAIL - ) - ) + for event_type in (InactivityEventType.ONE_DAY, InactivityEventType.DAY_OF): + with self.subTest(event_type=event_type): + self.assertFalse( + self._tracker(user_id=user_id, event_type=event_type).was_already_done(InactivityStep.USER_EMAIL) + ) def test_users_are_tracked_independently(self): from staff_user_inactivity_tracker import InactivityStep diff --git a/backend/social-work-app/stacks/staff_user_inactivity_stack.py b/backend/social-work-app/stacks/staff_user_inactivity_stack.py index 2994d7cd33..e20aa4d83d 100644 --- a/backend/social-work-app/stacks/staff_user_inactivity_stack.py +++ b/backend/social-work-app/stacks/staff_user_inactivity_stack.py @@ -24,6 +24,7 @@ REMINDER_CONFIGS = [ {'days_before': 10, 'suffix': '10Day'}, {'days_before': 3, 'suffix': '3Day'}, + {'days_before': 1, 'suffix': '1Day'}, {'days_before': 0, 'suffix': 'DayOf'}, ] diff --git a/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py index 9257485a20..50a283a363 100644 --- a/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py +++ b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py @@ -13,8 +13,8 @@ # Guam (UTC+10) through Hawaii (UTC-10). Running outside that window would deactivate accounts a day # before the date the notification emails state. EXPECTED_SCHEDULE = 'cron(0 12 ? * * *)' -EXPECTED_RULE_SUFFIXES = ('10Day', '3Day', 'DayOf') -EXPECTED_DAYS_BEFORE = {'10Day': 10, '3Day': 3, 'DayOf': 0} +EXPECTED_RULE_SUFFIXES = ('10Day', '3Day', '1Day', 'DayOf') +EXPECTED_DAYS_BEFORE = {'10Day': 10, '3Day': 3, '1Day': 1, 'DayOf': 0} class TestStaffUserInactivityStack(TstAppABC, TestCase): From 22fec07142b995fd314398cdc2740dee214bd426 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 12 Aug 2026 15:06:21 -0500 Subject: [PATCH 15/23] Do not send notification the day of deactivation --- .../handlers/staff_user_inactivity.py | 28 +++++++------ .../test_staff_user_inactivity.py | 40 +++++++++---------- .../stacks/staff_user_inactivity_stack.py | 23 +++++++---- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py index 7e9e23eb07..b49db4cf11 100644 --- a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py @@ -66,8 +66,9 @@ def as_dict(self) -> dict[str, int]: def process_staff_user_inactivity(event: dict, context: LambdaContext) -> dict: - """Notify a staff user and their administrators that the account is nearing inactivity deactivation, - and on the day-of run, deactivate. + """Send inactivity reminders to a staff user and their administrators, and deactivate on the day-of + run. The day-of run only deactivates - the 1-day run already sent the last actionable notice, so a + second email at the moment access is revoked would just be noise on top of a decision already made. Event format: { @@ -137,7 +138,6 @@ def process_staff_user_inactivity(event: dict, context: LambdaContext) -> dict: user=user, directory=directory, event_type=event_type, - today=today, days_to_deactivation=days_to_deactivation, inactivity_period_days=inactivity_period_days, is_deactivation_run=is_deactivation_run, @@ -158,28 +158,34 @@ def _process_user( user: StaffUserData, directory: CompactStaffUserDirectory, event_type: InactivityEventType, - today: date, days_to_deactivation: int, inactivity_period_days: int, is_deactivation_run: bool, metrics: Metrics, ) -> None: - """Notify one user and their admins, then deactivate if this is the deactivation run.""" - last_login_date = user.lastLoginAt.astimezone(UTC).date() - # Clamped, so a straggler swept up late is not told about a date in the past - deactivation_date = max(last_login_date + timedelta(days=days_to_deactivation), today) + """Deactivate the user on the day-of run; otherwise send their reminder notice. + The day-of run never sends a notice of its own - the 1-day run already told this user their account + would be deactivated today, so nothing new is left to say. + """ + last_login_date = user.lastLoginAt.astimezone(UTC).date() tracker = StaffUserInactivityTracker( compact=directory.compact, user_id=str(user.userId), last_login_date=last_login_date, event_type=event_type, ) + + if is_deactivation_run: + _deactivate_user(user=user, tracker=tracker, metrics=metrics) + return + + # Every non-deactivation run matches on an exact lastLoginAt date, so this is always in the future. template_variables = StaffUserInactivityNotificationTemplateVariables( staff_user_first_name=user.givenName, staff_user_last_name=user.familyName, staff_user_email=user.email, - deactivation_date=deactivation_date, + deactivation_date=last_login_date + timedelta(days=days_to_deactivation), inactivity_period_days=inactivity_period_days, ) @@ -211,10 +217,6 @@ def _process_user( step=InactivityStep.ADMIN_EMAIL, ) - # Deactivate only after the notifications have been attempted, so nobody is locked out silently - if is_deactivation_run: - _deactivate_user(user=user, tracker=tracker, metrics=metrics) - def _send_tracked_email( *, diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py index 8254d48b20..b1272e7c95 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py @@ -217,16 +217,6 @@ def test_deactivation_date_is_the_users_own_day_61(self): self.assertEqual((MOCK_TODAY + timedelta(days=10)).date(), template_variables.deactivation_date) self.assertEqual(60, template_variables.inactivity_period_days) - def test_deactivation_date_is_clamped_to_today_for_a_straggler(self): - """A user 75 days dormant would otherwise be told about a date in the past.""" - self._seed_user(days_since_login=75) - - with patch('cc_common.config._Config.email_service_client') as mock_email_client: - self._run(days_before=0) - - template_variables = self._sent_payloads(mock_email_client)[0]['template_variables'] - self.assertEqual(MOCK_TODAY.date(), template_variables.deactivation_date) - def test_already_notified_users_are_not_emailed_twice(self): self._seed_user(days_since_login=51) @@ -241,17 +231,15 @@ def test_already_notified_users_are_not_emailed_twice(self): self.assertGreater(result['metrics']['alreadyDone'], 0) def test_an_email_failure_is_counted_and_does_not_abort_the_run(self): - self._seed_user(days_since_login=61) - self._seed_user(days_since_login=61) + self._seed_user(days_since_login=60) + self._seed_user(days_since_login=60) with patch('cc_common.config._Config.email_service_client') as mock_email_client: mock_email_client.send_staff_user_inactivity_notification_email.side_effect = RuntimeError('SES is down') - result = self._run(days_before=0) + result = self._run(days_before=1) self.assertEqual(2, result['metrics']['matchedUsers']) self.assertEqual(2, result['metrics']['emailsFailed']) - # The deactivation still happens - a failed notice does not buy the user more time - self.assertEqual(2, result['metrics']['deactivated']) def test_no_admin_recipients_is_counted(self): _, user_email = self._seed_user(days_since_login=51, jurisdictions={'oh': {WRITE}}) @@ -265,22 +253,32 @@ def test_no_admin_recipients_is_counted(self): # -- deactivation -- - def test_day_of_run_deactivates_after_sending(self): + def test_day_of_run_deactivates_without_sending_a_notice(self): + """The 1-day run already sent the last actionable notice, so day-of only deactivates.""" from cc_common.data_model.schema.common import StaffUserStatus user_id, _ = self._seed_user(days_since_login=61) - statuses_when_emailed = [] with patch('cc_common.config._Config.email_service_client') as mock_email_client: - mock_email_client.send_staff_user_inactivity_notification_email.side_effect = lambda **_kwargs: ( - statuses_when_emailed.append(self._user_status(user_id)) - ) result = self._run(days_before=0) - self.assertEqual([StaffUserStatus.ACTIVE.value], statuses_when_emailed) + mock_email_client.send_staff_user_inactivity_notification_email.assert_not_called() + self.assertEqual(StaffUserStatus.INACTIVE.value, self._user_status(user_id)) self.assertFalse(self._is_cognito_user_enabled(user_id)) self.assertEqual(1, result['metrics']['deactivated']) + self.assertEqual(0, result['metrics']['userEmailsSent']) + self.assertEqual(0, result['metrics']['adminEmailsSent']) + self.assertEqual(0, result['metrics']['noAdminRecipients']) + + def test_day_of_run_sends_no_notice_even_for_a_straggler(self): + """A user 75 days dormant was missed by the 1-day run, but day-of still does not email them.""" + self._seed_user(days_since_login=75) + + with patch('cc_common.config._Config.email_service_client') as mock_email_client: + self._run(days_before=0) + + mock_email_client.send_staff_user_inactivity_notification_email.assert_not_called() def test_reminder_runs_deactivate_nobody(self): from cc_common.data_model.schema.common import StaffUserStatus diff --git a/backend/social-work-app/stacks/staff_user_inactivity_stack.py b/backend/social-work-app/stacks/staff_user_inactivity_stack.py index e20aa4d83d..7ee7fdcfcc 100644 --- a/backend/social-work-app/stacks/staff_user_inactivity_stack.py +++ b/backend/social-work-app/stacks/staff_user_inactivity_stack.py @@ -20,7 +20,8 @@ # How long a staff user can go without signing in before their account is deactivated STAFF_USER_INACTIVITY_PERIOD_DAYS = 60 -# Days before the stated deactivation date that each rule fires. 0 is the run that deactivates. +# Days before the stated deactivation date that each rule fires. 0 is the run that deactivates - it +# sends no notice of its own, since the 1-day run already sent the last actionable one. REMINDER_CONFIGS = [ {'days_before': 10, 'suffix': '10Day'}, {'days_before': 3, 'suffix': '3Day'}, @@ -38,9 +39,9 @@ class StaffUserInactivityStack(AppStack): """ Stack for staff user inactivity notifications and deactivation. - - Lambda that notifies a staff user and their administrators ahead of deactivation, and deactivates - on the day-of run - - EventBridge rules per compact and reminder type (10-day, 3-day, day-of) that run daily + - Lambda that notifies a staff user and their administrators ahead of deactivation (10-day, 3-day, + and 1-day runs), and deactivates on the day-of run + - EventBridge rules per compact and reminder type (10-day, 3-day, 1-day, day-of) that run daily - CloudWatch alarms for errors and execution duration """ @@ -98,14 +99,20 @@ def __init__( ], ) - # All three rules fire in the same minute. + # All four rules fire in the same minute. for compact in json.loads(self.common_env_vars['COMPACTS']): for reminder_config in REMINDER_CONFIGS: + days_before = reminder_config['days_before'] + description = ( + f'Daily rule to deactivate inactive staff users in {compact}' + if days_before == 0 + else f'Daily rule to notify staff users in {compact} {days_before} days before ' + 'inactivity deactivation' + ) Rule( self, f'StaffUserInactivity{reminder_config["suffix"]}Rule{compact.upper()}', - description=f'Daily rule to notify staff users in {compact} ' - f'{reminder_config["days_before"]} days before inactivity deactivation', + description=description, schedule=Schedule.cron(week_day='*', hour=RUN_HOUR_UTC, minute='0', month='*', year='*'), targets=[ LambdaFunction( @@ -113,7 +120,7 @@ def __init__( event=RuleTargetInput.from_object( { 'compact': compact, - 'daysBeforeDeactivation': reminder_config['days_before'], + 'daysBeforeDeactivation': days_before, } ), ) From 94880576c4cfbd651af8a51a36726f74a89eb776 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 12 Aug 2026 15:18:56 -0500 Subject: [PATCH 16/23] Fix comment --- .../python/staff-users/handlers/staff_user_inactivity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py index b49db4cf11..5e5a6b61d4 100644 --- a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py @@ -189,8 +189,8 @@ def _process_user( inactivity_period_days=inactivity_period_days, ) - # The user and their admins get separate sends, so one admin cannot see another's address and a - # partial failure only retries the half that failed + # The user and their admins get separate sends, so a partial failure only retries the half that + # failed. metrics.record_email_outcome( _send_tracked_email( tracker=tracker, From 43353449612615a426c3cedd8a3c79d1ba478270 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 12 Aug 2026 16:50:20 -0500 Subject: [PATCH 17/23] update dateOfUpdate when deactivating user --- .../python/common/cc_common/data_model/user_client.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index 4c4c8a9ff6..ab4c226f01 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -116,12 +116,16 @@ def deactivate_user(self, *, user_id: str) -> None: user_records = self.config.users_table.query(KeyConditionExpression=Key('pk').eq(f'USER#{user_id}')).get( 'Items', [] ) + date_of_update = self.config.current_standard_datetime.isoformat() for record in user_records: self.config.users_table.update_item( Key={'pk': record['pk'], 'sk': record['sk']}, - UpdateExpression='SET #status = :status', + UpdateExpression='SET #status = :status, dateOfUpdate = :dateOfUpdate', ExpressionAttributeNames={'#status': 'status'}, - ExpressionAttributeValues={':status': StaffUserStatus.INACTIVE.value}, + ExpressionAttributeValues={ + ':status': StaffUserStatus.INACTIVE.value, + ':dateOfUpdate': date_of_update, + }, ) def iterate_all_users_in_compact(self, *, compact: str) -> Generator[StaffUserData, None, None]: From d3486b84c006b147332f2ff496e0ac5f3d70cf6c Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 12 Aug 2026 17:30:02 -0500 Subject: [PATCH 18/23] Add smoke tests for deactivation notifications --- backend/social-work-app/tests/smoke/README.md | 2 + backend/social-work-app/tests/smoke/config.py | 4 + .../tests/smoke/smoke_common.py | 29 +- .../tests/smoke/smoke_tests_env_example.json | 3 +- .../staff_user_inactivity_smoke_tests.py | 269 ++++++++++++++++++ 5 files changed, 297 insertions(+), 10 deletions(-) create mode 100644 backend/social-work-app/tests/smoke/staff_user_inactivity_smoke_tests.py diff --git a/backend/social-work-app/tests/smoke/README.md b/backend/social-work-app/tests/smoke/README.md index 0edc5a992b..c011341ffa 100644 --- a/backend/social-work-app/tests/smoke/README.md +++ b/backend/social-work-app/tests/smoke/README.md @@ -79,6 +79,8 @@ Some smoke tests create their own practitioner data via the state API (for examp - `CC_TEST_ROLLBACK_STEP_FUNCTION_ARN`: Step function ARN for rollback tests - `CC_TEST_RATE_LIMITING_DYNAMO_TABLE_NAME`: DynamoDB table name for rate limiting - `CC_TEST_SSN_DYNAMO_TABLE_NAME`: DynamoDB table name for SSN data + - `CC_TEST_STAFF_USER_INACTIVITY_LAMBDA_NAME`: Function name of the staff user inactivity handler, used by + `staff_user_inactivity_smoke_tests.py` (found in the `StaffUserInactivityStack`) 3. **Important:** Never commit `smoke_tests_env.json` to version control. It contains sensitive credentials and should be in `.gitignore`. diff --git a/backend/social-work-app/tests/smoke/config.py b/backend/social-work-app/tests/smoke/config.py index a86ee8bde1..6be0a98bd4 100644 --- a/backend/social-work-app/tests/smoke/config.py +++ b/backend/social-work-app/tests/smoke/config.py @@ -76,6 +76,10 @@ def test_provider_id(self): def smoke_test_notification_email(self): return os.environ['CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL'] + @property + def staff_user_inactivity_lambda_name(self): + return os.environ['CC_TEST_STAFF_USER_INACTIVITY_LAMBDA_NAME'] + @cached_property def cognito_client(self): return boto3.client('cognito-idp') diff --git a/backend/social-work-app/tests/smoke/smoke_common.py b/backend/social-work-app/tests/smoke/smoke_common.py index 808e857abc..dd7bf8369b 100644 --- a/backend/social-work-app/tests/smoke/smoke_common.py +++ b/backend/social-work-app/tests/smoke/smoke_common.py @@ -43,9 +43,13 @@ def __init__(self, message): _TEMP_STAFF_PASSWORD = 'TempPass123!' # noqa: S105 temporary password for creating test staff users -def _create_staff_user_in_cognito(*, email: str) -> str: +def _create_staff_user_in_cognito(*, email: str, suppress_welcome_message: bool = False) -> str: """ Creates a staff user in Cognito and returns the user's sub. + + :param email: The email address for the new user + :param suppress_welcome_message: If True, Cognito will not send its default welcome/invite email. + Defaults to False to preserve the existing behavior for smoke tests that rely on that email. """ def get_sub_from_attributes(user_attributes: list): @@ -55,12 +59,15 @@ def get_sub_from_attributes(user_attributes: list): 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, - ) + create_user_kwargs = { + 'UserPoolId': config.cognito_staff_user_pool_id, + 'Username': email, + 'UserAttributes': [{'Name': 'email', 'Value': email}], + 'TemporaryPassword': _TEMP_STAFF_PASSWORD, + } + if suppress_welcome_message: + create_user_kwargs['MessageAction'] = 'SUPPRESS' + user_data = config.cognito_client.admin_create_user(**create_user_kwargs) logger.info(f"Created staff user, '{email}'. Setting password.") # set this to simplify login flow for user config.cognito_client.admin_set_user_password( @@ -104,18 +111,22 @@ def delete_test_staff_user(email: str, user_sub: str, compact: str): raise e -def create_test_staff_user(*, email: str, compact: str, jurisdiction: str, permissions: dict): +def create_test_staff_user( + *, email: str, compact: str, jurisdiction: str, permissions: dict, suppress_welcome_message: bool = False +): """Creates a test staff user in Cognito, stores their data in DynamoDB, and returns their user sub id. :param email: The email address of the staff user to create :param compact: The compact identifier :param jurisdiction: The jurisdiction identifier :param permissions: The permissions dictionary for the user + :param suppress_welcome_message: If True, Cognito will not send its default welcome/invite email. + Defaults to False to preserve the existing behavior for smoke tests that rely on that email. :return: The staff user's sub ID """ logger.info(f"Creating staff user, '{email}', in {compact}/{jurisdiction}") user_attributes = {'email': email, 'familyName': 'Dokes', 'givenName': 'Joe'} - sub = _create_staff_user_in_cognito(email=email) + sub = _create_staff_user_in_cognito(email=email, suppress_welcome_message=suppress_welcome_message) schema = UserRecordSchema() config.staff_users_dynamodb_table.put_item( Item=schema.dump( diff --git a/backend/social-work-app/tests/smoke/smoke_tests_env_example.json b/backend/social-work-app/tests/smoke/smoke_tests_env_example.json index b2416e8688..315947d3b2 100644 --- a/backend/social-work-app/tests/smoke/smoke_tests_env_example.json +++ b/backend/social-work-app/tests/smoke/smoke_tests_env_example.json @@ -14,5 +14,6 @@ "CC_TEST_PROVIDER_ID": "exampleProviderId", "ENVIRONMENT_NAME": "sandboxEnvironmentNamePlaceholder", "CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL": "smoke-test-notifications@example.com", - "CC_TEST_ROLLBACK_STEP_FUNCTION_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:Sandbox-DisasterRecoveryStack-LicenseUploadRollbackStateMachine" + "CC_TEST_ROLLBACK_STEP_FUNCTION_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:Sandbox-DisasterRecoveryStack-LicenseUploadRollbackStateMachine", + "CC_TEST_STAFF_USER_INACTIVITY_LAMBDA_NAME": "Sandbox-StaffUserInactivityStack-StaffUserInactivityHandler12345" } diff --git a/backend/social-work-app/tests/smoke/staff_user_inactivity_smoke_tests.py b/backend/social-work-app/tests/smoke/staff_user_inactivity_smoke_tests.py new file mode 100644 index 0000000000..c9d35a82f1 --- /dev/null +++ b/backend/social-work-app/tests/smoke/staff_user_inactivity_smoke_tests.py @@ -0,0 +1,269 @@ +# ruff: noqa: T201 we use print statements for smoke testing +#!/usr/bin/env python3 +""" +Smoke tests for staff user inactivity notifications and deactivation. + +Walks one staff user through the whole inactivity lifecycle by moving their lastLoginAt backwards and +invoking the scheduled handler for each reminder type: + + 51 days dormant -> 10-day notice + 58 days dormant -> 3-day notice + 60 days dormant -> 1-day notice, on the user's last usable day, while they can still act on it + 61 days dormant -> the account is deactivated, with no further notice + +This is primarily a developer-verified test: CompactConnect reporting a send as successful only means SES +accepted it, not that the message arrived or reads correctly, so each notice-producing phase pauses and +asks the developer to check two inboxes before continuing: + + - the staff user's own address, CC_TEST_SMOKE_TEST_NOTIFICATION_EMAIL + - a state admin address built by adding a "+state+admin" suffix to that same address, so both land + in the same real mailbox as distinguishable messages + +That check can still fail through no fault of this test, though: all of a jurisdiction's admins are +notified in a single combined send (one email, several recipients), and SES can reject the whole send if +any one recipient is not a real, deliverable, or verified address - not just the offending one. If +STAFF_USER_INACTIVITY_SMOKE_JURISDICTION already has another staff admin in that environment with a bad +address, the email will fail to send. Pick a jurisdiction with no other staff users in the environment +you are running against, and change the constant below if needed. + +WARNING - Only run this test against testing environments. Invoking the handler runs it against every +staff user in the compact, not just the test user. The day-of invocation is a sweep, so any other staff +user in that environment who has been dormant for more than the inactivity period will be notified and +deactivated too. + +Because of that, the metric assertions below are deliberately lower bounds rather than exact counts - +other users may legitimately appear in the same run. The test user's own outcome is verified directly +against Cognito and DynamoDB. + +Both staff users created here - and their DynamoDB records - are always cleaned up, pass or fail. +""" + +import json +from datetime import UTC, datetime, timedelta + +from smoke_common import ( + SmokeTestFailureException, + config, + create_test_staff_user, + delete_test_staff_user, + get_lambda_client, + load_smoke_test_env, + logger, +) + +STAFF_USER_INACTIVITY_SMOKE_COMPACT = 'socw' +# Must have no other staff users in the environment this runs against - see the module docstring for why +# a pre-existing admin with a bad address here would fail the adminEmailsSent check. +STAFF_USER_INACTIVITY_SMOKE_JURISDICTION = 'wa' + +# The handler deactivates the day after the inactivity period elapses, so the user keeps all of day 60. +INACTIVITY_PERIOD_DAYS = 60 +DAYS_TO_DEACTIVATION = INACTIVITY_PERIOD_DAYS + 1 + + +def _state_admin_email(staff_user_email: str) -> str: + """Build a distinguishable admin address that still lands in the same real mailbox. + + Most mail providers, including the ones these smoke test env files already use, deliver a + "local+anything@domain" address to the same inbox as "local@domain". + """ + local, domain = staff_user_email.split('@', 1) + return f'{local}+state+admin@{domain}' + + +def _set_last_login_days_ago(user_sub: str, days_ago: int) -> None: + """Move the user's lastLoginAt back far enough to land them in the cohort we want to exercise.""" + last_login_at = (datetime.now(tz=UTC) - timedelta(days=days_ago)).isoformat() + logger.info(f'Setting lastLoginAt to {days_ago} days ago ({last_login_at})') + config.staff_users_dynamodb_table.update_item( + Key={'pk': f'USER#{user_sub}', 'sk': f'COMPACT#{STAFF_USER_INACTIVITY_SMOKE_COMPACT}'}, + UpdateExpression='SET lastLoginAt = :lastLoginAt', + ExpressionAttributeValues={':lastLoginAt': last_login_at}, + ) + + +def _invoke_inactivity_handler(days_before_deactivation: int) -> dict: + """Invoke the scheduled handler exactly as its EventBridge rule would, and return its metrics.""" + logger.info(f'Invoking staff user inactivity handler with daysBeforeDeactivation={days_before_deactivation}') + response = get_lambda_client().invoke( + FunctionName=config.staff_user_inactivity_lambda_name, + InvocationType='RequestResponse', + Payload=json.dumps( + { + 'compact': STAFF_USER_INACTIVITY_SMOKE_COMPACT, + 'daysBeforeDeactivation': days_before_deactivation, + } + ), + ) + + payload = json.loads(response['Payload'].read()) + if response.get('FunctionError'): + raise SmokeTestFailureException(f'Inactivity handler failed: {payload}') + + logger.info(f'Handler returned metrics: {payload["metrics"]}') + return payload['metrics'] + + +def _prompt_developer_to_verify_email(description: str, *, user_email: str, admin_email: str) -> None: + """Ask the developer to confirm the notice actually arrived and reads correctly. + + A successful send just means SES accepted the message - it does not prove delivery or content, so a + human has to look. A "n" answer fails the test via SmokeTestFailureException, same as any other + assertion here, so the run still reaches main()'s finally block and cleans up both test users. + """ + print('\n' + '=' * 78) + print(f'MANUAL VERIFICATION NEEDED: {description}') + print(f' Staff user inbox : {user_email}') + print(f' State admin inbox: {admin_email}') + print(' (Both addresses route to the same mailbox via the "+" suffix.)') + print('=' * 78) + answer = input('Did both emails arrive and read as expected? [y/n]: ').strip().lower() + if answer != 'y': + raise SmokeTestFailureException(f'Developer did not confirm {description} - check the inboxes above') + + +def _verify_notified(metrics: dict, *, run_description: str, user_email: str, admin_email: str) -> None: + """Check that CompactConnect attempted to notify the test user and at least one state admin, then + have the developer confirm the emails actually arrived. + + Lower bounds, not exact counts: other staff users in the environment may be in the same cohort. The + test user's lastLoginAt was just moved to a date they have never been notified for, so the tracker + cannot suppress their notice - at least one send must be reported for each audience. + """ + if metrics['matchedUsers'] < 1: + raise SmokeTestFailureException(f'Expected the test user in the {run_description} cohort, got: {metrics}') + if metrics['userEmailsSent'] < 1: + raise SmokeTestFailureException(f'Expected a notice addressed to the test user, got: {metrics}') + if metrics['adminEmailsSent'] < 1: + raise SmokeTestFailureException(f'Expected a notice addressed to a state admin, got: {metrics}') + + _prompt_developer_to_verify_email(f'the {run_description} notice', user_email=user_email, admin_email=admin_email) + + +def _get_user_status(user_sub: str) -> str: + record = config.staff_users_dynamodb_table.get_item( + Key={'pk': f'USER#{user_sub}', 'sk': f'COMPACT#{STAFF_USER_INACTIVITY_SMOKE_COMPACT}'} + ).get('Item') + if record is None: + raise SmokeTestFailureException('Test staff user record is missing from DynamoDB') + return record['status'] + + +def _is_cognito_user_enabled(email: str) -> bool: + user_data = config.cognito_client.admin_get_user( + UserPoolId=config.cognito_staff_user_pool_id, + Username=email, + ) + return user_data['Enabled'] + + +def test_ten_day_notice(user_sub: str, email: str, admin_email: str): + """A user 51 days dormant is 10 days from deactivation.""" + _set_last_login_days_ago(user_sub, DAYS_TO_DEACTIVATION - 10) + + metrics = _invoke_inactivity_handler(10) + + _verify_notified(metrics, run_description='10-day', user_email=email, admin_email=admin_email) + if metrics['deactivated'] != 0: + raise SmokeTestFailureException(f'The 10-day run must not deactivate anyone, got: {metrics}') + + logger.info('10-day notice sent as expected') + + +def test_three_day_notice(user_sub: str, email: str, admin_email: str): + """A user 58 days dormant is 3 days from deactivation.""" + _set_last_login_days_ago(user_sub, DAYS_TO_DEACTIVATION - 3) + + metrics = _invoke_inactivity_handler(3) + + _verify_notified(metrics, run_description='3-day', user_email=email, admin_email=admin_email) + if metrics['deactivated'] != 0: + raise SmokeTestFailureException(f'The 3-day run must not deactivate anyone, got: {metrics}') + + logger.info('3-day notice sent as expected') + + +def test_one_day_notice(user_sub: str, email: str, admin_email: str): + """The 1-day notice arrives on the user's last usable day, and does not deactivate them.""" + _set_last_login_days_ago(user_sub, INACTIVITY_PERIOD_DAYS) + + metrics = _invoke_inactivity_handler(1) + + _verify_notified(metrics, run_description='1-day', user_email=email, admin_email=admin_email) + if metrics['deactivated'] != 0: + raise SmokeTestFailureException(f'The 1-day run must not deactivate anyone, got: {metrics}') + if not _is_cognito_user_enabled(email): + raise SmokeTestFailureException( + f'The test user was disabled after only {INACTIVITY_PERIOD_DAYS} days - they should keep their final day' + ) + if _get_user_status(user_sub) != 'active': + raise SmokeTestFailureException(f'The test user was marked inactive after only {INACTIVITY_PERIOD_DAYS} days') + + logger.info(f'1-day notice sent at {INACTIVITY_PERIOD_DAYS} days dormant, without deactivating') + + +def test_day_of_deactivation_without_a_notice(user_sub: str, email: str): + """One day further on, the user is deactivated. The 1-day run already sent the last notice, so the + day-of run must not send another - nothing to manually verify here.""" + _set_last_login_days_ago(user_sub, DAYS_TO_DEACTIVATION) + + metrics = _invoke_inactivity_handler(0) + + if metrics['deactivated'] < 1: + raise SmokeTestFailureException(f'Expected the test user to be deactivated, got: {metrics}') + if metrics['userEmailsSent'] != 0 or metrics['adminEmailsSent'] != 0: + raise SmokeTestFailureException(f'The day-of run must not send a notice, got: {metrics}') + + if _is_cognito_user_enabled(email): + raise SmokeTestFailureException('The test user is still enabled in Cognito after deactivation') + if _get_user_status(user_sub) != 'inactive': + raise SmokeTestFailureException('The test user record was not marked inactive after deactivation') + + logger.info('Account was deactivated, with no further notice, in both Cognito and DynamoDB') + + +def main(): + load_smoke_test_env() + + email = config.smoke_test_notification_email + admin_email = _state_admin_email(email) + user_sub = None + admin_sub = None + + try: + # A state admin for the same jurisdiction, so resolve_admin_recipients has someone real to find + # instead of falling back to compact admins already in the shared environment + admin_sub = create_test_staff_user( + email=admin_email, + compact=STAFF_USER_INACTIVITY_SMOKE_COMPACT, + jurisdiction=STAFF_USER_INACTIVITY_SMOKE_JURISDICTION, + permissions={'jurisdictions': {STAFF_USER_INACTIVITY_SMOKE_JURISDICTION: {'admin'}}}, + suppress_welcome_message=True, + ) + user_sub = create_test_staff_user( + email=email, + compact=STAFF_USER_INACTIVITY_SMOKE_COMPACT, + jurisdiction=STAFF_USER_INACTIVITY_SMOKE_JURISDICTION, + permissions={'jurisdictions': {STAFF_USER_INACTIVITY_SMOKE_JURISDICTION: {'write'}}}, + suppress_welcome_message=True, + ) + + test_ten_day_notice(user_sub, email, admin_email) + test_three_day_notice(user_sub, email, admin_email) + test_one_day_notice(user_sub, email, admin_email) + test_day_of_deactivation_without_a_notice(user_sub, email) + + except Exception as e: + logger.error(f'Staff user inactivity smoke tests failed: {str(e)}') + raise + finally: + if user_sub: + delete_test_staff_user(email, user_sub, STAFF_USER_INACTIVITY_SMOKE_COMPACT) + if admin_sub: + delete_test_staff_user(admin_email, admin_sub, STAFF_USER_INACTIVITY_SMOKE_COMPACT) + + logger.info('All staff user inactivity smoke tests passed!') + + +if __name__ == '__main__': + main() From 46b7fe59a548007ea6ef3ae9c3d52ba2c059e1a7 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 13 Aug 2026 09:36:21 -0500 Subject: [PATCH 19/23] Set dateOfUpdate when staff user permissions or attributes modified --- .../cc_common/data_model/user_client.py | 9 +- .../test_data_model/test_user_client.py | 86 ++++++++++++------- .../function/test_handlers/test_patch_me.py | 8 ++ .../function/test_handlers/test_patch_user.py | 29 ++++--- 4 files changed, 86 insertions(+), 46 deletions(-) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index ab4c226f01..fe4e4a6059 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -281,7 +281,8 @@ def _handle_user_permission_additions( expression_attribute_values[f':{jurisdiction}AddActions'] = actions if update_expression_parts: - update_expression = 'ADD ' + ', '.join(update_expression_parts) + update_expression = 'ADD ' + ', '.join(update_expression_parts) + ' SET dateOfUpdate = :dateOfUpdate' + expression_attribute_values[':dateOfUpdate'] = self.config.current_standard_datetime.isoformat() try: return self.config.users_table.update_item( @@ -328,7 +329,8 @@ def _handle_user_permission_removals( expression_attribute_values[f':{jurisdiction}DeleteActions'] = actions if update_expression_parts: - update_expression = 'DELETE ' + ', '.join(update_expression_parts) + update_expression = 'DELETE ' + ', '.join(update_expression_parts) + ' SET dateOfUpdate = :dateOfUpdate' + expression_attribute_values[':dateOfUpdate'] = self.config.current_standard_datetime.isoformat() return self.config.users_table.update_item( Key={'pk': f'USER#{user_id}', 'sk': f'COMPACT#{compact}'}, @@ -366,6 +368,9 @@ def update_user_attributes(self, *, user_id: str, attributes: dict): expression_attribute_names[f'#{attr_name}'] = attr_name expression_attribute_values[f':{attr_name}'] = attr_value + update_expression_parts.append('dateOfUpdate = :dateOfUpdate') + expression_attribute_values[':dateOfUpdate'] = self.config.current_standard_datetime.isoformat() + update_expression = 'SET ' + ', '.join(update_expression_parts) records = self.get_user(user_id=user_id)['items'] diff --git a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py index bdb6cdd89a..3ed93b65f4 100644 --- a/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py +++ b/backend/social-work-app/lambdas/python/common/tests/function/test_data_model/test_user_client.py @@ -7,8 +7,14 @@ from .. import TstFunction +# Frozen "now" for the whole class, so any write that stamps a timestamp is assertable by value. The +# user.json fixture carries an older dateOfUpdate, so a record still holding that value has not been +# written by the code under test. +MOCK_DATETIME = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + @mock_aws +@patch('cc_common.config._Config.current_standard_datetime', MOCK_DATETIME) class TestClient(TstFunction): def _get_email_from_user_attributes(self, user_data: dict) -> str: for attribute in user_data['UserAttributes']: @@ -42,12 +48,10 @@ def test_record_user_login_sets_last_login_at_and_status(self): # The fixture user has never signed in self.assertEqual(StaffUserStatus.INACTIVE.value, self._get_user_record(user_id)['status']) - login_time = datetime.fromisoformat('2024-11-08T23:59:59+00:00') - with patch('cc_common.config._Config.current_standard_datetime', login_time): - UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) user_record = self._get_user_record(user_id) - self.assertEqual(login_time.isoformat(), user_record['lastLoginAt']) + self.assertEqual(MOCK_DATETIME.isoformat(), user_record['lastLoginAt']) self.assertEqual(StaffUserStatus.ACTIVE.value, user_record['status']) def test_record_user_login_refreshes_last_login_at_for_active_user(self): @@ -57,9 +61,9 @@ def test_record_user_login_refreshes_last_login_at_for_active_user(self): user_id = self._load_user_data() client = UserClient(self.config) - first_login = datetime.fromisoformat('2024-11-08T23:59:59+00:00') - with patch('cc_common.config._Config.current_standard_datetime', first_login): - client.record_user_login(user_id=user_id, compacts=['socw']) + # The first login uses the class-wide frozen time; the second overrides it, since this test is + # specifically about the stamp moving between two sign-ins + client.record_user_login(user_id=user_id, compacts=['socw']) second_login = datetime.fromisoformat('2024-12-25T08:00:00+00:00') with patch('cc_common.config._Config.current_standard_datetime', second_login): @@ -73,11 +77,7 @@ def test_record_user_login_leaves_other_fields_untouched(self): user_id = self._load_user_data() original_record = self._get_user_record(user_id) - with patch( - 'cc_common.config._Config.current_standard_datetime', - datetime.fromisoformat('2024-11-08T23:59:59+00:00'), - ): - UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw']) updated_record = self._get_user_record(user_id) for field in ('attributes', 'permissions', 'famGiv', 'compact', 'type', 'userId'): @@ -94,13 +94,11 @@ def test_record_user_login_updates_every_compact_record(self): Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} ) - login_time = datetime.fromisoformat('2024-11-08T23:59:59+00:00') - with patch('cc_common.config._Config.current_standard_datetime', login_time): - UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) for compact in ('socw', 'some-other-compact'): self.assertEqual( - login_time.isoformat(), + MOCK_DATETIME.isoformat(), self._get_user_record(user_id, compact)['lastLoginAt'], f'the {compact} record should have been stamped', ) @@ -118,13 +116,7 @@ def test_record_user_login_raises_when_record_does_not_exist(self): # This user only has a socw record user_id = self._load_user_data() - with ( - patch( - 'cc_common.config._Config.current_standard_datetime', - datetime.fromisoformat('2024-11-08T23:59:59+00:00'), - ), - self.assertRaises(CCNotFoundException), - ): + with self.assertRaises(CCNotFoundException): UserClient(self.config).record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) stub_record = self.config.users_table.get_item( @@ -160,11 +152,7 @@ def test_deactivate_user_marks_every_compact_record_inactive(self): Item=self._get_user_record(user_id) | {'sk': 'COMPACT#some-other-compact', 'compact': 'some-other-compact'} ) client = UserClient(self.config) - with patch( - 'cc_common.config._Config.current_standard_datetime', - datetime.fromisoformat('2024-11-08T23:59:59+00:00'), - ): - client.record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) + client.record_user_login(user_id=user_id, compacts=['socw', 'some-other-compact']) client.deactivate_user(user_id=user_id) @@ -198,11 +186,7 @@ def test_deactivate_user_disables_cognito_before_marking_records_inactive(self): user_id = self._load_user_data() client = UserClient(self.config) - with patch( - 'cc_common.config._Config.current_standard_datetime', - datetime.fromisoformat('2024-11-08T23:59:59+00:00'), - ): - client.record_user_login(user_id=user_id, compacts=['socw']) + client.record_user_login(user_id=user_id, compacts=['socw']) statuses_when_disabled = [] with patch('cc_common.config._Config.cognito_client') as mock_cognito_client: @@ -450,6 +434,31 @@ def test_update_user_permissions_no_change(self): jurisdiction_action_additions={}, ) + def test_update_user_permissions_additions_refresh_date_of_update(self): + """Permission additions build a raw ADD expression rather than going through + UserRecordSchema.dump, so they do not get the schema's populate_date_of_update hook for free.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + + UserClient(self.config).update_user_permissions( + compact='socw', user_id=user_id, jurisdiction_action_additions={'ky': {'write'}} + ) + + self.assertEqual(MOCK_DATETIME.isoformat(), self._get_user_record(user_id)['dateOfUpdate']) + + def test_update_user_permissions_removals_refresh_date_of_update(self): + """Removals build a separate raw DELETE expression, so they need their own dateOfUpdate set.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + + UserClient(self.config).update_user_permissions( + compact='socw', user_id=user_id, jurisdiction_action_removals={'oh': {'write'}} + ) + + self.assertEqual(MOCK_DATETIME.isoformat(), self._get_user_record(user_id)['dateOfUpdate']) + def test_update_user_attributes(self): # The sample user looks like board staff in socw/oh user_id = UUID(self._load_user_data()) @@ -481,6 +490,17 @@ def test_update_user_attributes_not_found(self): attributes={'givenName': 'Bob', 'familyName': 'Smith'}, ) + def test_update_user_attributes_refreshes_date_of_update(self): + """Attribute updates build a raw SET expression rather than going through UserRecordSchema.dump, + so they do not get the schema's populate_date_of_update hook for free.""" + from cc_common.data_model.user_client import UserClient + + user_id = self._load_user_data() + + UserClient(self.config).update_user_attributes(user_id=user_id, attributes={'givenName': 'Changed'}) + + self.assertEqual(MOCK_DATETIME.isoformat(), self._get_user_record(user_id)['dateOfUpdate']) + def test_create_new_user(self): from cc_common.data_model.schema.common import StaffUserStatus from cc_common.data_model.user_client import UserClient diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py index fdcf5935fd..09c7d121b1 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_me.py @@ -1,11 +1,17 @@ import json +from datetime import datetime +from unittest.mock import patch from moto import mock_aws from .. import TstFunction +# Frozen "now" for the whole class, so writes that refresh dateOfUpdate land on an assertable value +MOCK_DATETIME = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + @mock_aws +@patch('cc_common.config._Config.current_standard_datetime', MOCK_DATETIME) class TestPatchMe(TstFunction): def test_patch_me_not_found(self): from handlers.me import patch_me @@ -44,6 +50,8 @@ def test_patch_me(self): with open('tests/resources/api/user-response.json') as f: expected_user = json.load(f) expected_user['attributes']['givenName'] = 'George' + # The patch refreshes dateOfUpdate, so it no longer matches the fixture's original value + expected_user['dateOfUpdate'] = MOCK_DATETIME.isoformat() body = json.loads(resp['body']) diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py index d066cee026..f06d203b01 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py @@ -1,11 +1,17 @@ import json +from datetime import datetime +from unittest.mock import patch from moto import mock_aws from .. import TstFunction +# Frozen "now" for the whole class, so writes that refresh dateOfUpdate land on an assertable value +MOCK_DATETIME = datetime.fromisoformat('2024-11-08T23:59:59+00:00') + @mock_aws +@patch('cc_common.config._Config.current_standard_datetime', MOCK_DATETIME) class TestPatchUser(TstFunction): def _when_testing_with_valid_jurisdiction(self, compact: str): # load oh jurisdiction for provided compact to pass the jurisdiction validation @@ -35,7 +41,8 @@ def test_patch_user(self): self.assertEqual( { 'attributes': {'email': 'justin@example.org', 'familyName': 'Williams', 'givenName': 'Justin'}, - 'dateOfUpdate': '2024-09-12T23:59:59+00:00', + # Refreshed by the patch, so no longer the fixture's original value + 'dateOfUpdate': MOCK_DATETIME.isoformat(), 'status': StaffUserStatus.INACTIVE.value, 'permissions': { 'socw': { @@ -96,9 +103,6 @@ def test_patch_user_document_path_overlap(self): self.assertEqual(200, resp['statusCode']) user = json.loads(resp['body']) - # Don't compare the dateOfUpdate in comparison, since its value is dynamic - del user['dateOfUpdate'] - self.assertEqual( { 'attributes': { @@ -106,6 +110,8 @@ def test_patch_user_document_path_overlap(self): 'familyName': 'User', 'givenName': 'Test', }, + # Refreshed by the patch, so no longer the seeded record's original value + 'dateOfUpdate': MOCK_DATETIME.isoformat(), 'permissions': { 'socw': { 'actions': {'read': True}, @@ -155,12 +161,12 @@ def test_patch_user_add_to_empty_actions(self): self.assertEqual(200, resp['statusCode']) user = json.loads(resp['body']) - # Drop backend-generated fields from comparison + # userId is backend-generated, so it cannot be compared against the request body del user['userId'] - del user['dateOfUpdate'] - # Add status to the comparison + # Add the fields the response carries that the request body does not api_user['status'] = StaffUserStatus.INACTIVE.value + api_user['dateOfUpdate'] = MOCK_DATETIME.isoformat() self.assertEqual(api_user, user) @@ -198,12 +204,12 @@ def test_patch_user_remove_all_actions(self): self.assertEqual(200, resp['statusCode']) user = json.loads(resp['body']) - # Drop backend-generated fields from comparison + # userId is backend-generated, so it cannot be compared against the request body del user['userId'] - del user['dateOfUpdate'] - # Add status to the comparison + # Add the fields the response carries that the request body does not api_user['status'] = StaffUserStatus.INACTIVE.value + api_user['dateOfUpdate'] = MOCK_DATETIME.isoformat() api_user['permissions'] = {'socw': {'jurisdictions': {}}} self.assertEqual(api_user, user) @@ -305,7 +311,8 @@ def test_patch_user_allows_adding_read_private_permission(self): self.assertEqual( { 'attributes': {'email': 'justin@example.org', 'familyName': 'Williams', 'givenName': 'Justin'}, - 'dateOfUpdate': '2024-09-12T23:59:59+00:00', + # Refreshed by the patch, so no longer the fixture's original value + 'dateOfUpdate': MOCK_DATETIME.isoformat(), 'status': StaffUserStatus.INACTIVE.value, 'permissions': { 'socw': { From 42a737b82c36d27e0644da06ab2874cb8a71d95f Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Fri, 14 Aug 2026 14:38:56 -0500 Subject: [PATCH 20/23] update nanoid version for node check --- backend/compact-connect/lambdas/nodejs/yarn.lock | 6 +++--- backend/cosmetology-app/lambdas/nodejs/yarn.lock | 6 +++--- backend/social-work-app/lambdas/nodejs/yarn.lock | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/compact-connect/lambdas/nodejs/yarn.lock b/backend/compact-connect/lambdas/nodejs/yarn.lock index af62e8dc5e..7c2b9ec127 100644 --- a/backend/compact-connect/lambdas/nodejs/yarn.lock +++ b/backend/compact-connect/lambdas/nodejs/yarn.lock @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== napi-postinstall@^0.3.4: version "0.3.4" diff --git a/backend/cosmetology-app/lambdas/nodejs/yarn.lock b/backend/cosmetology-app/lambdas/nodejs/yarn.lock index af62e8dc5e..7c2b9ec127 100644 --- a/backend/cosmetology-app/lambdas/nodejs/yarn.lock +++ b/backend/cosmetology-app/lambdas/nodejs/yarn.lock @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== napi-postinstall@^0.3.4: version "0.3.4" diff --git a/backend/social-work-app/lambdas/nodejs/yarn.lock b/backend/social-work-app/lambdas/nodejs/yarn.lock index af62e8dc5e..7c2b9ec127 100644 --- a/backend/social-work-app/lambdas/nodejs/yarn.lock +++ b/backend/social-work-app/lambdas/nodejs/yarn.lock @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== napi-postinstall@^0.3.4: version "0.3.4" From 4ab179dd6aa95a903673bd11c772d9bc1e4cf6ad Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 18 Aug 2026 12:14:07 -0500 Subject: [PATCH 21/23] Bold date in email notification based on client feedback --- .../nodejs/lib/email/email-notification-service.ts | 4 ++-- .../lib/email/email-notification-service.test.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts index a9ffbc0fe2..e0f5a63fff 100644 --- a/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts +++ b/backend/social-work-app/lambdas/nodejs/lib/email/email-notification-service.ts @@ -122,8 +122,8 @@ export class EmailNotificationService extends BaseEmailService { const subject = `CompactConnect account for ${staffUserName} will be deactivated on ${deactivationDateDisplay}`; const report = this.getNewEmailTemplate(); - const bodyText = `The ${compactConfig.compactName} CompactConnect account for ${staffUserName} (${staffUserEmail}) will be deactivated on ${deactivationDateDisplay}. CompactConnect deactivates staff user accounts after ${inactivityPeriodDays} days with no sign-in activity.\n\n` + - `To prevent deactivation, ${staffUserName} should sign in to CompactConnect before ${deactivationDateDisplay}.\n\n` + + const bodyText = `The ${compactConfig.compactName} CompactConnect account for ${staffUserName} (${staffUserEmail}) will be deactivated on **${deactivationDateDisplay}**. CompactConnect deactivates staff user accounts after ${inactivityPeriodDays} days with no sign-in activity.\n\n` + + `To prevent deactivation, ${staffUserName} should sign in to CompactConnect before **${deactivationDateDisplay}**.\n\n` + `If the account has already been deactivated, an administrator will need to re-invite ${staffUserName} to CompactConnect to restore access.\n\n` + `Sign in: ${environmentVariableService.getUiBasePathUrl()}/Dashboard`; diff --git a/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts index 6d8f613086..e5406aea8f 100644 --- a/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts +++ b/backend/social-work-app/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts @@ -262,6 +262,19 @@ describe('EmailNotificationService', () => { expect(htmlContent).toContain('https://app.test.compactconnect.org/Dashboard'); }); + it('should render the deactivation date in bold everywhere it appears in the body', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + + await sendInactivityNotification(); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + // The date appears twice in the body: once stating the deactivation date, once as the + // sign-in deadline. Both should be emphasized so the date doesn't get lost in the paragraph. + expect(htmlContent?.match(/09\/14\/2026<\/strong>/g)).toHaveLength(2); + }); + it('should throw when there are no recipients', async () => { mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); From 6dc445b72428409374752c0ab7534c6f2aaa299c Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 20 Aug 2026 14:35:43 -0500 Subject: [PATCH 22/23] Add error log alert for inactivity lambda --- .../stacks/staff_user_inactivity_stack.py | 30 ++++++++++++- .../app/test_staff_user_inactivity_stack.py | 44 +++++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/backend/social-work-app/stacks/staff_user_inactivity_stack.py b/backend/social-work-app/stacks/staff_user_inactivity_stack.py index 7ee7fdcfcc..ad1c5daf13 100644 --- a/backend/social-work-app/stacks/staff_user_inactivity_stack.py +++ b/backend/social-work-app/stacks/staff_user_inactivity_stack.py @@ -8,7 +8,7 @@ from aws_cdk.aws_cloudwatch_actions import SnsAction from aws_cdk.aws_events import Rule, RuleTargetInput, Schedule from aws_cdk.aws_events_targets import LambdaFunction -from aws_cdk.aws_logs import QueryDefinition, QueryString, RetentionDays +from aws_cdk.aws_logs import FilterPattern, MetricFilter, QueryDefinition, QueryString, RetentionDays from cdk_nag import NagSuppressions from common_constructs.python_function import PythonFunction from common_constructs.stack import AppStack @@ -151,6 +151,34 @@ def __init__( treat_missing_data=TreatMissingData.NOT_BREACHING, ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + # Several failure paths in the handler (a failed send, a failed deactivation, no admins found to + # notify) are deliberately swallowed per-user so one bad record can't fail the whole batch, and + # so never surface on the Lambda Errors metric above. The ERROR logs they write are the only + # signal, so we alarm on those directly. + error_log_metric = MetricFilter( + self, + 'StaffUserInactivityErrorLogMetric', + log_group=self.staff_user_inactivity_handler.log_group, + metric_namespace='CompactConnect/StaffUsers', + metric_name='StaffUserInactivityHandlerErrors', + filter_pattern=FilterPattern.string_value(json_field='$.level', comparison='=', value='ERROR'), + metric_value='1', + default_value=0, + ) + + Alarm( + self, + 'StaffUserInactivityErrorLogAlarm', + metric=error_log_metric.metric(statistic='Sum'), + evaluation_periods=1, + threshold=1, + actions_enabled=True, + alarm_description=f'The Staff User Inactivity Lambda logged an ERROR level message. Investigate the ' + f'logs for the {self.staff_user_inactivity_handler.function_name} lambda to determine the cause.', + comparison_operator=ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, + treat_missing_data=TreatMissingData.NOT_BREACHING, + ).add_alarm_action(SnsAction(persistent_stack.alarm_topic)) + QueryDefinition( self, 'StaffUserInactivityQuery', diff --git a/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py index 50a283a363..20e1410987 100644 --- a/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py +++ b/backend/social-work-app/tests/app/test_staff_user_inactivity_stack.py @@ -6,6 +6,7 @@ from aws_cdk.aws_events import CfnRule from aws_cdk.aws_iam import CfnPolicy from aws_cdk.aws_lambda import CfnFunction +from aws_cdk.aws_logs import CfnMetricFilter from tests.app.base import TstAppABC @@ -123,3 +124,46 @@ def test_alarms_configured(self): self.assertEqual(600_000, duration_alarm['Threshold']) self.assertEqual('GreaterThanThreshold', duration_alarm['ComparisonOperator']) self.assertEqual('Duration', duration_alarm['MetricName']) + + def test_error_log_metric_filter_created(self): + """Several failure paths in this handler (a failed send, a failed deactivation, no admins to + notify) are deliberately swallowed per-user so one bad record can't fail the whole batch - so + they never surface on the Lambda Errors metric. The ERROR logs they write are the only signal.""" + template = Template.from_stack(self._stack) + + metric_filter = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id( + self._stack.node.find_child('StaffUserInactivityErrorLogMetric').node.default_child + ), + template.find_resources(CfnMetricFilter.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual('{ $.level = "ERROR" }', metric_filter['FilterPattern']) + transformation = metric_filter['MetricTransformations'][0] + self.assertEqual('CompactConnect/StaffUsers', transformation['MetricNamespace']) + self.assertEqual('StaffUserInactivityHandlerErrors', transformation['MetricName']) + self.assertEqual('1', transformation['MetricValue']) + self.assertEqual(0, transformation['DefaultValue']) + + def test_error_log_alarm_created_and_notifies_the_alarm_topic(self): + template = Template.from_stack(self._stack) + + alarm = self.get_resource_properties_by_logical_id( + self._stack.get_logical_id( + self._stack.node.find_child('StaffUserInactivityErrorLogAlarm').node.default_child + ), + template.find_resources(CfnAlarm.CFN_RESOURCE_TYPE_NAME), + ) + + self.assertEqual('StaffUserInactivityHandlerErrors', alarm['MetricName']) + self.assertEqual('CompactConnect/StaffUsers', alarm['Namespace']) + self.assertEqual('Sum', alarm['Statistic']) + self.assertEqual(1, alarm['Threshold']) + self.assertEqual(1, alarm['EvaluationPeriods']) + self.assertEqual('GreaterThanOrEqualToThreshold', alarm['ComparisonOperator']) + self.assertEqual('notBreaching', alarm['TreatMissingData']) + + # The alarm topic lives in a different stack, so this resolves to a cross-stack Fn::ImportValue + # rather than a same-stack Ref - just confirm exactly one action was wired up, not its exact name. + self.assertEqual(1, len(alarm['AlarmActions'])) + self.assertIn('Fn::ImportValue', alarm['AlarmActions'][0]) From 36a5af9ce12441c6783ad964f0da8d7e9c8b0f2d Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 20 Aug 2026 15:00:18 -0500 Subject: [PATCH 23/23] PR feedback - clarify comments and tests --- .../python/common/cc_common/data_model/user_client.py | 7 ++++--- .../python/staff-users/handlers/staff_user_inactivity.py | 1 - .../lambdas/python/staff-users/staff_user_directory.py | 6 +++--- .../function/test_handlers/test_staff_user_inactivity.py | 8 ++++++-- .../tests/unit/test_resolve_admin_recipients.py | 4 ++++ .../staff-users/tests/unit/test_staff_user_directory.py | 8 ++++---- backend/social-work-app/pipeline/backend_stage.py | 2 +- 7 files changed, 22 insertions(+), 14 deletions(-) diff --git a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py index fe4e4a6059..4ae458aa2c 100644 --- a/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py +++ b/backend/social-work-app/lambdas/python/common/cc_common/data_model/user_client.py @@ -106,9 +106,10 @@ def deactivate_user(self, *, user_id: str) -> None: logger.info('Deactivating staff user', user_id=user_id) # Disable in Cognito first. If this succeeds but the record updates below do not, the user is - # locked out while still showing active, and the next sweep finishes the job. The reverse order - # would leave a user marked inactive who can still sign in - and the pre-token hook would then - # flip them straight back to active. + # locked out while still showing active, and the next day's scheduled day-of run retries the + # DynamoDB update alone (the inactivity tracker records each step separately, so it knows this + # one didn't succeed). The reverse order would leave a user marked inactive who can still sign + # in - and the pre-token hook would then flip them straight back to active. self.config.cognito_client.admin_disable_user(UserPoolId=self.config.user_pool_id, Username=user_id) # A user only ever has a handful of compact records, all in one partition, so a single query diff --git a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py index 5e5a6b61d4..3f829175b9 100644 --- a/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/handlers/staff_user_inactivity.py @@ -180,7 +180,6 @@ def _process_user( _deactivate_user(user=user, tracker=tracker, metrics=metrics) return - # Every non-deactivation run matches on an exact lastLoginAt date, so this is always in the future. template_variables = StaffUserInactivityNotificationTemplateVariables( staff_user_first_name=user.givenName, staff_user_last_name=user.familyName, diff --git a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py index c2b420b952..a850d1bbb2 100644 --- a/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py +++ b/backend/social-work-app/lambdas/python/staff-users/staff_user_directory.py @@ -11,7 +11,7 @@ class CompactStaffUserDirectory: """All staff users in a compact, loaded in a single pass over the famGiv GSI. Built once and queried repeatedly. The admin buckets are classified eagerly at construction; - cohort selection is a query against the loaded set, so this class is usable by anything that + matching is a query against the loaded set, so this class is usable by anything that needs to reach a compact's staff users or their admins. """ @@ -51,10 +51,10 @@ def compact_admins(self) -> list[StaffUserData]: return list(self._compact_admins) def _candidates(self) -> Generator[StaffUserData, None, None]: - """Users eligible for an inactivity cohort. + """Users eligible to be matched for inactivity processing. Users who are already inactive have been deactivated, and users with no lastLoginAt have not - signed in since login tracking was introduced, so neither has an inactivity clock running. + signed in, so neither has an inactivity clock running. """ return ( user for user in self._users if user.status == StaffUserStatus.ACTIVE.value and user.lastLoginAt is not None diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py index b1272e7c95..92f0ca9389 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/function/test_handlers/test_staff_user_inactivity.py @@ -21,7 +21,7 @@ def setUp(self): self.mock_context.get_remaining_time_in_millis.return_value = 900_000 def _seed_user(self, *, days_since_login: int | None, compact_actions=None, jurisdictions=None, status=None): - """Create a staff user, in Cognito and in the table, last seen `days_since_login` days before TODAY.""" + """Create a staff user, in Cognito and in the table, last seen `days_since_login` days before MOCK_TODAY.""" from cc_common.data_model.schema.common import StaffUserStatus from cc_common.data_model.schema.user.record import UserRecordSchema @@ -101,7 +101,8 @@ def test_invalid_compact_raises(self): ) def test_each_reminder_run_targets_its_own_day(self): - """10-day fires at 51 days since login, 3-day at 58, 1-day at 60. All exact, not ranges.""" + """Under the default 60-day-inactivity threshold, 10-day fires at 51 days since login, 3-day at 58, + 1-day at 60. All exact, not ranges.""" expected_email_by_run = { 10: self._seed_user(days_since_login=51)[1], 3: self._seed_user(days_since_login=58)[1], @@ -188,6 +189,9 @@ def test_already_deactivated_users_are_not_swept_again(self): self.assertEqual(0, result['metrics']['matchedUsers']) def test_target_last_login_date_overrides_the_computed_date(self): + """In the case of a failure that needs to be manually replayed, verify that the replay date is used in place + of the current date. + """ self._seed_user(days_since_login=40) replay_date = (MOCK_TODAY - timedelta(days=40)).date().isoformat() diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py index fbdd6cfe48..e31c7c46f5 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_resolve_admin_recipients.py @@ -108,6 +108,10 @@ def test_deactivated_compact_admin_is_not_notified(self): recipients = self._resolve(user, build_directory([user, deactivated_compact_admin])) self.assertEqual(set(), recipients) + self.assertTrue( + any(record.levelname == 'ERROR' for record in logs.records), + 'expected an ERROR log when there is no one to notify', + ) def test_no_admins_anywhere_returns_empty_set(self): """A compact with nobody to notify is a configuration problem, but the user still gets their own email.""" diff --git a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py index 1972a85546..dc3ed3b164 100644 --- a/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py +++ b/backend/social-work-app/lambdas/python/staff-users/tests/unit/test_staff_user_directory.py @@ -34,7 +34,7 @@ def test_users_last_seen_on_or_before_includes_earlier_dates(self): {user.userId for user in directory.users_last_seen_on_or_before(date(2024, 11, 8))}, ) - def test_cohorts_exclude_inactive_users(self): + def test_matching_excludes_inactive_users(self): """Already-deactivated users must not be swept up again.""" from cc_common.data_model.schema.common import StaffUserStatus @@ -49,7 +49,7 @@ def test_cohorts_exclude_inactive_users(self): [user.userId for user in directory.users_last_seen_on_or_before(date(2024, 11, 8))], ) - def test_cohorts_exclude_users_who_have_never_signed_in(self): + def test_matching_excludes_users_who_have_never_signed_in(self): never_signed_in = self._staff_user(last_login_at=None) directory = self._build_directory([never_signed_in]) @@ -83,8 +83,8 @@ def test_compact_admins(self): self.assertEqual([compact_admin.userId], [user.userId for user in directory.compact_admins]) - def test_admin_lookups_do_not_require_cohort_selection(self): - """The directory is usable purely as an admin lookup, without picking a cohort first.""" + def test_admin_lookups_do_not_require_matching(self): + """The directory is usable purely as an admin lookup, without matching users first.""" from cc_common.data_model.schema.common import CCPermissionsAction compact_admin = self._staff_user(compact_actions={CCPermissionsAction.ADMIN.value}) diff --git a/backend/social-work-app/pipeline/backend_stage.py b/backend/social-work-app/pipeline/backend_stage.py index 3f144935f1..65bc0368e0 100644 --- a/backend/social-work-app/pipeline/backend_stage.py +++ b/backend/social-work-app/pipeline/backend_stage.py @@ -175,7 +175,7 @@ def __init__( ) # This job emails staff users before deactivating them, so it must not run in an - # environment that cannot send email + # environment that cannot send email because it does not have a hosted zone. self.staff_user_inactivity_stack = StaffUserInactivityStack( self, 'StaffUserInactivityStack',