From 6bc5ccaedd58c31ea8a8fb5de55e3f3b1c5fe7ec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 13 May 2026 18:32:08 +0000 Subject: [PATCH] fix: Email order filenames, SSM param TTL, DST-safe reopen_at - Flask dev server: persist orders under lowercase email slug (match Lambda/Dynamo) - shared.secrets: split secret vs SSM caches; expire get_parameter entries every 5 minutes - form-status reopen_at: calendar Monday + datetime.combine for 8am ET (not 24h timedelta) - Add _eastern_now() for testability; tests for SSM TTL and DST weekend edge case Co-authored-by: Adam Moussa --- functions/submit_order/handler.py | 25 +++++++++++----- src/server/app.py | 11 +++---- src/shared/shared/secrets.py | 29 +++++++++++++----- tests/test_secrets.py | 50 +++++++++++++++++++++++++++++++ tests/test_submit_order.py | 44 ++++++++++++++++++++------- 5 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 tests/test_secrets.py diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index 504ec8c..59b854f 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -5,7 +5,8 @@ import sys import time import urllib.error import urllib.request -from datetime import datetime, timedelta +import datetime as _dt +from datetime import timedelta from decimal import Decimal, ROUND_HALF_UP from zoneinfo import ZoneInfo @@ -22,6 +23,12 @@ if not logger.handlers: EASTERN = ZoneInfo("America/New_York") CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values + +def _eastern_now() -> _dt.datetime: + """Wall-clock 'now' in Eastern time (patch target for form-status tests).""" + return _dt.datetime.now(EASTERN) + + _api_key = None _settings = None _settings_ts = 0.0 @@ -128,14 +135,18 @@ def handle_form_status(event): status = get_form_status(week) result = {"week": week, "status": status} if status == "closed": - # Compute next Monday 8:00 AM Eastern, accounting for DST - now_et = datetime.now(EASTERN) - days_until_monday = (7 - now_et.weekday()) % 7 + # Next Monday 8:00 AM Eastern: use calendar date math + combine() so reopen_at + # stays on the correct civil Monday across DST (timedelta(days=n) is always 24n hours). + now_et = _eastern_now() + today = now_et.date() + weekday = today.weekday() # Monday=0 ... Sunday=6 + days_until_monday = (7 - weekday) % 7 if days_until_monday == 0 and now_et.hour >= 8: # If today is Monday past 8am, next Monday is 7 days away days_until_monday = 7 - next_monday = (now_et + timedelta(days=days_until_monday)).replace( - hour=8, minute=0, second=0, microsecond=0 + reopen_date = today + timedelta(days=days_until_monday) + next_monday = _dt.datetime.combine( + reopen_date, _dt.time(8, 0), tzinfo=EASTERN ) result["reopen_at"] = int(next_monday.timestamp()) return response(200, result) @@ -248,7 +259,7 @@ def handle_submit(event): order_data = { "employee_name": name, "employee_email": email, - "submitted_at": datetime.now(EASTERN).isoformat(), + "submitted_at": _eastern_now().isoformat(), "items": filtered_items, "total": total, } diff --git a/src/server/app.py b/src/server/app.py index 38b5d61..cc7c576 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -154,13 +154,10 @@ def submit_order(): week_dir = ORDERS_DIR / week week_dir.mkdir(parents=True, exist_ok=True) - safe_name = ( - "".join(c if c.isalnum() or c in "-_ " else "" for c in name) - .strip() - .replace(" ", "-") - .lower() - ) - order_file = week_dir / f"{safe_name}.json" + # Match Lambda: one order file per employee email (not display name). + slug = email.strip().lower() + slug_safe = slug.replace("/", "_").replace("\\", "_") + order_file = week_dir / f"{slug_safe}.json" total = float( sum(Decimal(str(i["subtotal"])) for i in filtered).quantize( diff --git a/src/shared/shared/secrets.py b/src/shared/shared/secrets.py index b5a17df..3738453 100644 --- a/src/shared/shared/secrets.py +++ b/src/shared/shared/secrets.py @@ -1,19 +1,32 @@ +import time + import boto3 -_cache = {} +_secret_cache: dict[str, str] = {} +_parameter_cache: dict[str, tuple[str, float]] = {} _sm = boto3.client("secretsmanager") _ssm = boto3.client("ssm") +# SSM reads use a TTL so callers (e.g. submit_order Google client ID) can refresh +# on the same cadence as their own caches. Secrets stay cached for the process lifetime. +PARAM_CACHE_TTL_SECONDS = 300.0 + def get_secret(secret_id: str) -> str: - if secret_id not in _cache: + if secret_id not in _secret_cache: resp = _sm.get_secret_value(SecretId=secret_id) - _cache[secret_id] = resp["SecretString"] - return _cache[secret_id] + _secret_cache[secret_id] = resp["SecretString"] + return _secret_cache[secret_id] def get_parameter(name: str, decrypt: bool = True) -> str: - if name not in _cache: - resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt) - _cache[name] = resp["Parameter"]["Value"] - return _cache[name] + now = time.monotonic() + entry = _parameter_cache.get(name) + if entry is not None: + value, cached_at = entry + if now - cached_at < PARAM_CACHE_TTL_SECONDS: + return value + resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt) + value = resp["Parameter"]["Value"] + _parameter_cache[name] = (value, now) + return value diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..5db2d44 --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,50 @@ +"""Tests for shared.secrets caching.""" + +from unittest.mock import MagicMock, patch + + +def test_get_parameter_refetches_after_ttl(): + """SSM parameter values expire so callers can observe rotations.""" + from shared import secrets + + secrets._secret_cache.clear() + secrets._parameter_cache.clear() + + mock_ssm = MagicMock() + mock_ssm.get_parameter.side_effect = [ + {"Parameter": {"Value": "first"}}, + {"Parameter": {"Value": "second"}}, + ] + + with patch.object(secrets, "_ssm", mock_ssm): + with patch.object(secrets, "PARAM_CACHE_TTL_SECONDS", 10.0): + with patch( + "shared.secrets.time.monotonic", + side_effect=[0.0, 5.0, 15.0], + ): + assert secrets.get_parameter("/test/param") == "first" + assert secrets.get_parameter("/test/param") == "first" + assert secrets.get_parameter("/test/param") == "second" + + assert mock_ssm.get_parameter.call_count == 2 + + +def test_get_secret_stays_cached(): + """Secrets Manager values remain cached (no TTL).""" + from shared import secrets + + secrets._secret_cache.clear() + secrets._parameter_cache.clear() + + mock_sm = MagicMock() + mock_sm.get_secret_value.side_effect = [ + {"SecretString": "a"}, + {"SecretString": "b"}, + ] + + with patch.object(secrets, "_sm", mock_sm): + with patch("shared.secrets.time.monotonic", side_effect=[0.0, 5000.0]): + assert secrets.get_secret("arn:aws:secret") == "a" + assert secrets.get_secret("arn:aws:secret") == "a" + + assert mock_sm.get_secret_value.call_count == 1 diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index b72abea..ef2a667 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -135,10 +135,11 @@ def _reset_handler_caches(): @pytest.fixture(autouse=True) def _reset_shared_caches(): - """Reset shared.secrets cache before each test.""" + """Reset shared.secrets caches before each test.""" from shared import secrets - secrets._cache = {} + secrets._secret_cache.clear() + secrets._parameter_cache.clear() yield @@ -908,10 +909,7 @@ def test_form_status_closed_reopen_at(mock_week, mock_status): # Freeze "now" to Thursday 2026-05-14 at 10:00 AM Eastern thursday = datetime(2026, 5, 14, 10, 0, 0, tzinfo=EASTERN) - with patch("submit_order_handler.datetime") as mock_dt: - mock_dt.now.return_value = thursday - mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) - + with patch("submit_order_handler._eastern_now", return_value=thursday): event = _make_event( method="GET", path="/form-status/2026-W20", @@ -943,10 +941,7 @@ def test_form_status_monday_before_8am(mock_week, mock_status): # Monday 2026-05-18 at 6:30 AM Eastern (before 8am cutoff) monday_early = datetime(2026, 5, 18, 6, 30, 0, tzinfo=EASTERN) - with patch("submit_order_handler.datetime") as mock_dt: - mock_dt.now.return_value = monday_early - mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw) - + with patch("submit_order_handler._eastern_now", return_value=monday_early): event = _make_event( method="GET", path="/form-status/2026-W20", @@ -968,6 +963,35 @@ def test_form_status_monday_before_8am(mock_week, mock_status): ) +@patch("submit_order_handler.get_form_status", return_value="closed") +@patch("submit_order_handler.current_week", return_value="2025-W45") +def test_form_status_closed_saturday_before_dst_end(mock_week, mock_status): + """Saturday before fall-back: reopen_at is the *calendar* Monday at 8am ET. + + Using timedelta(days=n) for n weekdays is 24n hours and can land on the wrong + local calendar day when a 25-hour Sunday sits in between. + """ + from submit_order_handler import lambda_handler, EASTERN + + # Nov 1 2025 is Saturday (DST still on until early Nov 2). Next Monday is Nov 3. + saturday = datetime(2025, 11, 1, 12, 0, 0, tzinfo=EASTERN) + with patch("submit_order_handler._eastern_now", return_value=saturday): + event = _make_event( + method="GET", + path="/form-status/2025-W45", + path_parameters={"week": "2025-W45"}, + ) + result = lambda_handler(event, None) + + status, body = _parse_response(result) + assert status == 200 + expected_monday = datetime(2025, 11, 3, 8, 0, 0, tzinfo=EASTERN) + expected_ts = int(expected_monday.timestamp()) + assert body["reopen_at"] == expected_ts, ( + f"reopen_at should be {expected_ts} (Mon 2025-11-03 8am ET), got {body['reopen_at']}" + ) + + # =========================================================================== # VALIDATION (High) # ===========================================================================