From 832691bcfd39a63cd97d296bf85ffbc45729986c Mon Sep 17 00:00:00 2001 From: Andrea Amorosi Date: Thu, 3 Sep 2026 18:11:50 +0200 Subject: [PATCH] fix(feature_flags): handle same-hour midnight rollover and reject malformed HH:MM SCHEDULE_BETWEEN_TIME_RANGE decided whether a range crossed midnight by comparing hours only, so a range like 23:30 -> 23:00 (equal hours) took the same-day branch and could never match. Compare minutes since midnight instead. TIME_RANGE_PATTERN was unanchored and used with re.match, so values such as "10:00abc" passed schema validation and then failed silently at evaluation time. Anchor the pattern so only exact HH:MM strings validate. Fixes #8423 --- .../utilities/feature_flags/comparators.py | 9 +++- .../utilities/feature_flags/constants.py | 2 +- .../_boto3/test_schema_validation.py | 5 +++ .../_boto3/test_time_based_actions.py | 44 +++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/aws_lambda_powertools/utilities/feature_flags/comparators.py b/aws_lambda_powertools/utilities/feature_flags/comparators.py index 0d836d19b11..2888c1cb14b 100644 --- a/aws_lambda_powertools/utilities/feature_flags/comparators.py +++ b/aws_lambda_powertools/utilities/feature_flags/comparators.py @@ -55,10 +55,15 @@ def compare_time_range(context_value: Any, condition_value: dict) -> bool: start_time = current_time.replace(hour=int(start_hour), minute=int(start_min)) end_time = current_time.replace(hour=int(end_hour), minute=int(end_min)) - if int(end_hour) < int(start_hour): + # Compare full minutes-since-midnight so that ranges within the same hour (e.g. 23:30 -> 23:00) + # are correctly detected as crossing midnight. + start_minutes = int(start_hour) * 60 + int(start_min) + end_minutes = int(end_hour) * 60 + int(end_min) + + if end_minutes < start_minutes: # In normal circumstances, we need to assert **both** conditions """ - # When the end hour is smaller than start hour, it means we are crossing a day's boundary. + # When the end time is earlier than start time, it means we are crossing a day's boundary. # In this case we need to assert that current_time is **either** on one side or the other side of the boundary # # ┌─────┐ ┌─────┐ ┌─────┐ diff --git a/aws_lambda_powertools/utilities/feature_flags/constants.py b/aws_lambda_powertools/utilities/feature_flags/constants.py index dcb3a7419e5..7276fb8ab7b 100644 --- a/aws_lambda_powertools/utilities/feature_flags/constants.py +++ b/aws_lambda_powertools/utilities/feature_flags/constants.py @@ -9,5 +9,5 @@ CONDITION_ACTION = "action" FEATURE_DEFAULT_VAL_TYPE_KEY = "boolean_type" TIME_RANGE_FORMAT = "%H:%M" # hour:min 24 hours clock -TIME_RANGE_PATTERN = re.compile(r"2[0-3]:[0-5]\d|[0-1]\d:[0-5]\d") # 24 hour clock +TIME_RANGE_PATTERN = re.compile(r"^(?:2[0-3]|[01]\d):[0-5]\d$") # 24 hour clock, exactly HH:MM HOUR_MIN_SEPARATOR = ":" diff --git a/tests/functional/feature_flags/_boto3/test_schema_validation.py b/tests/functional/feature_flags/_boto3/test_schema_validation.py index afc7130505e..b723463e314 100644 --- a/tests/functional/feature_flags/_boto3/test_schema_validation.py +++ b/tests/functional/feature_flags/_boto3/test_schema_validation.py @@ -523,6 +523,9 @@ def test_validate_time_condition_between_time_range_invalid_condition_value_inva [ {TimeValues.START.value: "11-11", TimeValues.END.value: "23:59"}, {TimeValues.START.value: "24:99", TimeValues.END.value: "23:59"}, + {TimeValues.START.value: "10:00abc", TimeValues.END.value: "23:59"}, # trailing garbage + {TimeValues.START.value: "abc10:00", TimeValues.END.value: "23:59"}, # leading garbage + {TimeValues.START.value: "110:00", TimeValues.END.value: "23:59"}, # extra leading digit ], ) def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_start_time_value(cond_value): @@ -549,6 +552,8 @@ def test_validate_time_condition_between_time_range_invalid_condition_value_inva [ {TimeValues.START.value: "10:11", TimeValues.END.value: "11-11"}, {TimeValues.START.value: "10:11", TimeValues.END.value: "999:59"}, + {TimeValues.START.value: "10:11", TimeValues.END.value: "12:00abc"}, # trailing garbage + {TimeValues.START.value: "10:11", TimeValues.END.value: "12:000"}, # extra trailing digit ], ) def test_validate_time_condition_between_time_range_invalid_condition_value_invalid_end_time_value(cond_value): diff --git a/tests/functional/feature_flags/_boto3/test_time_based_actions.py b/tests/functional/feature_flags/_boto3/test_time_based_actions.py index 640434f1f46..30cef1dda71 100644 --- a/tests/functional/feature_flags/_boto3/test_time_based_actions.py +++ b/tests/functional/feature_flags/_boto3/test_time_based_actions.py @@ -175,6 +175,50 @@ def test_time_based_utc_in_between_time_range_between_days_rule_no_match(mocker) ) +def test_time_based_utc_in_between_time_range_same_hour_between_days_rule_match(mocker): + # GIVEN a range whose END is earlier than START but within the same hour (23:30 -> 23:00) + # WHEN the current time is 10:00, which lies inside the range after crossing midnight + # THEN the rule matches + assert evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 23:30-23:00": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "23:30", TimeValues.END.value: "23:00"}, + }, + ], + }, + }, + mocked_time=(2022, 2, 15, 10, 0, 0, datetime.timezone.utc), # rule match 10:00 am + ) + + +def test_time_based_utc_in_between_time_range_same_hour_between_days_rule_no_match(mocker): + # GIVEN a range whose END is earlier than START but within the same hour (23:30 -> 23:00) + # WHEN the current time is 23:15, the only 30-minute gap not covered by the range + # THEN the rule does not match + assert not evaluate_mocked_schema( + mocker=mocker, + rules={ + "lambda time is between UTC 23:30-23:00": { + RULE_MATCH_VALUE: True, + CONDITIONS_KEY: [ + { + CONDITION_ACTION: RuleAction.SCHEDULE_BETWEEN_TIME_RANGE.value, + CONDITION_KEY: TimeKeys.CURRENT_TIME.value, + CONDITION_VALUE: {TimeValues.START.value: "23:30", TimeValues.END.value: "23:00"}, + }, + ], + }, + }, + mocked_time=(2022, 2, 15, 23, 15, 0, datetime.timezone.utc), # rule no match 23:15 + ) + + def test_time_based_between_time_range_rule_timezone_match(mocker): timezone_name = "Europe/Copenhagen"