mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-10-06 20:31:57 +00:00
Compare commits
5 commits
9ccf3ac38c
...
f0d52dba71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0d52dba71 | ||
|
|
373f959282 | ||
|
|
6bc5ccaedd | ||
|
|
fa9ac6974e | ||
|
|
5db992b0be |
8 changed files with 250 additions and 73 deletions
|
|
@ -13,10 +13,10 @@ EASTERN = ZoneInfo("America/New_York")
|
||||||
|
|
||||||
def lambda_handler(event, context):
|
def lambda_handler(event, context):
|
||||||
now_et = datetime.now(EASTERN)
|
now_et = datetime.now(EASTERN)
|
||||||
if not (now_et.weekday() == 4 and now_et.hour >= 23):
|
if not (now_et.weekday() == 3 and now_et.hour >= 23):
|
||||||
return {
|
return {
|
||||||
"status": "skipped",
|
"status": "skipped",
|
||||||
"reason": "outside close window (must be Friday >= 11 PM ET)",
|
"reason": "outside close window (must be Thursday >= 11 PM ET)",
|
||||||
}
|
}
|
||||||
|
|
||||||
week = event.get("week", current_week())
|
week = event.get("week", current_week())
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
import datetime as _dt
|
||||||
|
from datetime import timedelta
|
||||||
from decimal import Decimal, ROUND_HALF_UP
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
@ -22,6 +23,12 @@ if not logger.handlers:
|
||||||
EASTERN = ZoneInfo("America/New_York")
|
EASTERN = ZoneInfo("America/New_York")
|
||||||
CACHE_TTL_SECONDS = 300 # 5-minute TTL for cached config values
|
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
|
_api_key = None
|
||||||
_settings = None
|
_settings = None
|
||||||
_settings_ts = 0.0
|
_settings_ts = 0.0
|
||||||
|
|
@ -50,19 +57,17 @@ def _get_discount_settings() -> tuple[Decimal, Decimal]:
|
||||||
return _settings
|
return _settings
|
||||||
|
|
||||||
|
|
||||||
|
def _google_auth_configured() -> bool:
|
||||||
|
return bool(os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""))
|
||||||
|
|
||||||
|
|
||||||
def _get_google_client_id() -> str:
|
def _get_google_client_id() -> str:
|
||||||
global _google_client_id, _google_client_id_ts
|
global _google_client_id, _google_client_id_ts
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if _google_client_id is None or (now - _google_client_id_ts) > CACHE_TTL_SECONDS:
|
if _google_client_id is None or (now - _google_client_id_ts) > CACHE_TTL_SECONDS:
|
||||||
param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "")
|
param = os.environ.get("GOOGLE_CLIENT_ID_PARAM", "")
|
||||||
if param:
|
if param:
|
||||||
try:
|
_google_client_id = get_parameter(param, decrypt=False) or ""
|
||||||
_google_client_id = get_parameter(param, decrypt=False) or ""
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"Could not fetch Google client ID from SSM (%s): %s", param, exc
|
|
||||||
)
|
|
||||||
_google_client_id = ""
|
|
||||||
else:
|
else:
|
||||||
_google_client_id = ""
|
_google_client_id = ""
|
||||||
_google_client_id_ts = now
|
_google_client_id_ts = now
|
||||||
|
|
@ -130,15 +135,17 @@ def handle_form_status(event):
|
||||||
status = get_form_status(week)
|
status = get_form_status(week)
|
||||||
result = {"week": week, "status": status}
|
result = {"week": week, "status": status}
|
||||||
if status == "closed":
|
if status == "closed":
|
||||||
# Compute next Monday 8:00 AM Eastern, accounting for DST
|
# Next Monday 8:00 AM Eastern: use calendar date math + combine() so reopen_at
|
||||||
now_et = datetime.now(EASTERN)
|
# stays on the correct civil Monday across DST (timedelta(days=n) is always 24n hours).
|
||||||
days_until_monday = (7 - now_et.weekday()) % 7
|
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 days_until_monday == 0 and now_et.hour >= 8:
|
||||||
# If today is Monday past 8am, next Monday is 7 days away
|
# If today is Monday past 8am, next Monday is 7 days away
|
||||||
days_until_monday = 7
|
days_until_monday = 7
|
||||||
next_monday = (now_et + timedelta(days=days_until_monday)).replace(
|
reopen_date = today + timedelta(days=days_until_monday)
|
||||||
hour=8, minute=0, second=0, microsecond=0
|
next_monday = _dt.datetime.combine(reopen_date, _dt.time(8, 0), tzinfo=EASTERN)
|
||||||
)
|
|
||||||
result["reopen_at"] = int(next_monday.timestamp())
|
result["reopen_at"] = int(next_monday.timestamp())
|
||||||
return response(200, result)
|
return response(200, result)
|
||||||
|
|
||||||
|
|
@ -160,12 +167,24 @@ def handle_submit(event):
|
||||||
return response(400, {"error": "Invalid JSON"})
|
return response(400, {"error": "Invalid JSON"})
|
||||||
|
|
||||||
# --- Authentication ---
|
# --- Authentication ---
|
||||||
# If Google auth is configured, require a valid google_id_token.
|
# If Google auth is configured (SSM param name is set), require a valid
|
||||||
# Manual name/email fallback is only allowed when Google auth is NOT configured.
|
# google_id_token. Manual fallback is only allowed when auth is NOT configured.
|
||||||
google_auth_enabled = bool(_get_google_client_id())
|
# If SSM fetch fails, fail closed (503) rather than silently disabling auth.
|
||||||
google_token = body.get("google_id_token")
|
google_token = body.get("google_id_token")
|
||||||
|
|
||||||
if google_auth_enabled:
|
if _google_auth_configured():
|
||||||
|
try:
|
||||||
|
client_id = _get_google_client_id()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("SSM fetch failed for Google client ID: %s", exc)
|
||||||
|
return response(
|
||||||
|
503, {"error": "Authentication service temporarily unavailable"}
|
||||||
|
)
|
||||||
|
if not client_id:
|
||||||
|
logger.error("Google auth configured but client ID is empty")
|
||||||
|
return response(
|
||||||
|
503, {"error": "Authentication service temporarily unavailable"}
|
||||||
|
)
|
||||||
if not google_token:
|
if not google_token:
|
||||||
return response(403, {"error": "Google authentication is required"})
|
return response(403, {"error": "Google authentication is required"})
|
||||||
user_info, verify_status = _verify_google_token(google_token)
|
user_info, verify_status = _verify_google_token(google_token)
|
||||||
|
|
@ -238,7 +257,7 @@ def handle_submit(event):
|
||||||
order_data = {
|
order_data = {
|
||||||
"employee_name": name,
|
"employee_name": name,
|
||||||
"employee_email": email,
|
"employee_email": email,
|
||||||
"submitted_at": datetime.now(EASTERN).isoformat(),
|
"submitted_at": _eastern_now().isoformat(),
|
||||||
"items": filtered_items,
|
"items": filtered_items,
|
||||||
"total": total,
|
"total": total,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ def _verify_google_token(token: str, client_id: str) -> dict | None:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
if data.get("aud") != client_id:
|
if data.get("aud") != client_id:
|
||||||
return None
|
return None
|
||||||
|
if data.get("hd") != "seahavenind.com":
|
||||||
|
return None
|
||||||
return {"name": data.get("name", ""), "email": data.get("email", "")}
|
return {"name": data.get("name", ""), "email": data.get("email", "")}
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
@ -101,9 +103,12 @@ def submit_order():
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"error": "No data received"}), 400
|
return jsonify({"error": "No data received"}), 400
|
||||||
|
|
||||||
|
client_id = _get_google_client_id()
|
||||||
google_token = data.get("google_id_token")
|
google_token = data.get("google_id_token")
|
||||||
if google_token:
|
|
||||||
client_id = _get_google_client_id()
|
if client_id:
|
||||||
|
if not google_token:
|
||||||
|
return jsonify({"error": "Google authentication is required"}), 403
|
||||||
user_info = _verify_google_token(google_token, client_id)
|
user_info = _verify_google_token(google_token, client_id)
|
||||||
if not user_info:
|
if not user_info:
|
||||||
return jsonify({"error": "Invalid or unauthorized Google account"}), 403
|
return jsonify({"error": "Invalid or unauthorized Google account"}), 403
|
||||||
|
|
@ -126,6 +131,8 @@ def submit_order():
|
||||||
TWO_PLACES = Decimal("0.01")
|
TWO_PLACES = Decimal("0.01")
|
||||||
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
|
bulk_pct = Decimal(str(config.get("bulk_discount_percent", 0)))
|
||||||
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
|
subsidy_pct = Decimal(str(config.get("company_subsidy_percent", 0)))
|
||||||
|
bulk_pct = max(Decimal("0"), min(Decimal("100"), bulk_pct))
|
||||||
|
subsidy_pct = max(Decimal("0"), min(Decimal("100"), subsidy_pct))
|
||||||
bulk_mult = Decimal("1") - (bulk_pct / Decimal("100"))
|
bulk_mult = Decimal("1") - (bulk_pct / Decimal("100"))
|
||||||
subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100"))
|
subsidy_mult = Decimal("1") - (subsidy_pct / Decimal("100"))
|
||||||
|
|
||||||
|
|
@ -147,13 +154,10 @@ def submit_order():
|
||||||
week_dir = ORDERS_DIR / week
|
week_dir = ORDERS_DIR / week
|
||||||
week_dir.mkdir(parents=True, exist_ok=True)
|
week_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
safe_name = (
|
# Match Lambda: one order file per employee email (not display name).
|
||||||
"".join(c if c.isalnum() or c in "-_ " else "" for c in name)
|
slug = email.strip().lower()
|
||||||
.strip()
|
slug_safe = slug.replace("/", "_").replace("\\", "_")
|
||||||
.replace(" ", "-")
|
order_file = week_dir / f"{slug_safe}.json"
|
||||||
.lower()
|
|
||||||
)
|
|
||||||
order_file = week_dir / f"{safe_name}.json"
|
|
||||||
|
|
||||||
total = float(
|
total = float(
|
||||||
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
sum(Decimal(str(i["subtotal"])) for i in filtered).quantize(
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ def generate_form(
|
||||||
api_key_json = json.dumps(api_key).replace("</", "<\\/")
|
api_key_json = json.dumps(api_key).replace("</", "<\\/")
|
||||||
has_discount = bulk_discount > 0 or company_subsidy > 0
|
has_discount = bulk_discount > 0 or company_subsidy > 0
|
||||||
use_google_auth = bool(google_client_id)
|
use_google_auth = bool(google_client_id)
|
||||||
google_client_id_json = json.dumps(google_client_id)
|
google_client_id_json = json.dumps(google_client_id).replace("</", "<\\/")
|
||||||
|
|
||||||
if use_google_auth:
|
if use_google_auth:
|
||||||
auth_section_html = """ <div class="employee-info">
|
auth_section_html = """ <div class="employee-info">
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
|
import time
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
|
|
||||||
_cache = {}
|
_secret_cache: dict[str, str] = {}
|
||||||
|
_parameter_cache: dict[str, tuple[str, float]] = {}
|
||||||
_sm = boto3.client("secretsmanager")
|
_sm = boto3.client("secretsmanager")
|
||||||
_ssm = boto3.client("ssm")
|
_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:
|
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)
|
resp = _sm.get_secret_value(SecretId=secret_id)
|
||||||
_cache[secret_id] = resp["SecretString"]
|
_secret_cache[secret_id] = resp["SecretString"]
|
||||||
return _cache[secret_id]
|
return _secret_cache[secret_id]
|
||||||
|
|
||||||
|
|
||||||
def get_parameter(name: str, decrypt: bool = True) -> str:
|
def get_parameter(name: str, decrypt: bool = True) -> str:
|
||||||
if name not in _cache:
|
now = time.monotonic()
|
||||||
resp = _ssm.get_parameter(Name=name, WithDecryption=decrypt)
|
entry = _parameter_cache.get(name)
|
||||||
_cache[name] = resp["Parameter"]["Value"]
|
if entry is not None:
|
||||||
return _cache[name]
|
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
|
||||||
|
|
|
||||||
|
|
@ -32,33 +32,42 @@ def _make_datetime(year, month, day, hour, minute=0):
|
||||||
|
|
||||||
class TestCloseFormGuard:
|
class TestCloseFormGuard:
|
||||||
@patch("close_form_handler.datetime")
|
@patch("close_form_handler.datetime")
|
||||||
def test_skipped_on_thursday(self, mock_dt):
|
def test_skipped_on_wednesday(self, mock_dt):
|
||||||
"""Thursday 11pm ET -> skipped (not Friday)."""
|
"""Wednesday 11pm ET -> skipped (not Thursday)."""
|
||||||
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23) # Thursday
|
mock_dt.now.return_value = _make_datetime(2026, 5, 13, 23) # Wednesday
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
assert result["status"] == "skipped"
|
assert result["status"] == "skipped"
|
||||||
|
|
||||||
@patch("close_form_handler.datetime")
|
@patch("close_form_handler.datetime")
|
||||||
def test_skipped_friday_before_11pm(self, mock_dt):
|
def test_skipped_on_friday(self, mock_dt):
|
||||||
"""Friday 3am ET (UTC cron fires but too early) -> skipped."""
|
"""Friday 3am ET (wrong-tz EDT cron during EST) -> skipped."""
|
||||||
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 3) # Friday 3am
|
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 3) # Friday 3am
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
assert result["status"] == "skipped"
|
assert result["status"] == "skipped"
|
||||||
|
|
||||||
|
@patch("close_form_handler.datetime")
|
||||||
|
def test_skipped_thursday_before_11pm(self, mock_dt):
|
||||||
|
"""Thursday 10pm ET -> skipped (too early)."""
|
||||||
|
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 22) # Thursday 10pm
|
||||||
|
|
||||||
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
|
assert result["status"] == "skipped"
|
||||||
|
|
||||||
@patch("close_form_handler.set_form_status")
|
@patch("close_form_handler.set_form_status")
|
||||||
@patch("close_form_handler.get_form_status", return_value="open")
|
@patch("close_form_handler.get_form_status", return_value="open")
|
||||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||||
@patch("close_form_handler._lambda")
|
@patch("close_form_handler._lambda")
|
||||||
@patch("close_form_handler.datetime")
|
@patch("close_form_handler.datetime")
|
||||||
def test_runs_friday_at_11pm(
|
def test_runs_thursday_at_11pm(
|
||||||
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
||||||
):
|
):
|
||||||
"""Friday 11pm ET -> proceeds to close."""
|
"""Thursday 11pm ET -> proceeds to close."""
|
||||||
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 23) # Friday 11pm
|
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23) # Thursday 11pm
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
|
|
@ -70,31 +79,22 @@ class TestCloseFormGuard:
|
||||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||||
@patch("close_form_handler._lambda")
|
@patch("close_form_handler._lambda")
|
||||||
@patch("close_form_handler.datetime")
|
@patch("close_form_handler.datetime")
|
||||||
def test_runs_friday_at_1159pm(
|
def test_runs_thursday_at_1159pm(
|
||||||
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
self, mock_dt, mock_lam, mock_week, mock_status, mock_set
|
||||||
):
|
):
|
||||||
"""Friday 11:59pm ET -> proceeds to close."""
|
"""Thursday 11:59pm ET -> proceeds to close."""
|
||||||
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 23, 59)
|
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23, 59)
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
assert result["status"] == "closed"
|
assert result["status"] == "closed"
|
||||||
|
|
||||||
@patch("close_form_handler.datetime")
|
|
||||||
def test_skipped_on_wednesday(self, mock_dt):
|
|
||||||
"""Wednesday at any hour -> skipped."""
|
|
||||||
mock_dt.now.return_value = _make_datetime(2026, 5, 13, 23)
|
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
|
||||||
|
|
||||||
assert result["status"] == "skipped"
|
|
||||||
|
|
||||||
@patch("close_form_handler.get_form_status", return_value="closed")
|
@patch("close_form_handler.get_form_status", return_value="closed")
|
||||||
@patch("close_form_handler.current_week", return_value="2026-W19")
|
@patch("close_form_handler.current_week", return_value="2026-W19")
|
||||||
@patch("close_form_handler.datetime")
|
@patch("close_form_handler.datetime")
|
||||||
def test_already_closed(self, mock_dt, mock_week, mock_status):
|
def test_already_closed(self, mock_dt, mock_week, mock_status):
|
||||||
"""Friday 11pm but already closed -> returns already_closed."""
|
"""Thursday 11pm but already closed -> returns already_closed."""
|
||||||
mock_dt.now.return_value = _make_datetime(2026, 5, 15, 23)
|
mock_dt.now.return_value = _make_datetime(2026, 5, 14, 23)
|
||||||
|
|
||||||
result = close_form_handler.lambda_handler({}, None)
|
result = close_form_handler.lambda_handler({}, None)
|
||||||
|
|
||||||
|
|
|
||||||
50
tests/test_secrets.py
Normal file
50
tests/test_secrets.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -135,10 +135,11 @@ def _reset_handler_caches():
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_shared_caches():
|
def _reset_shared_caches():
|
||||||
"""Reset shared.secrets cache before each test."""
|
"""Reset shared.secrets caches before each test."""
|
||||||
from shared import secrets
|
from shared import secrets
|
||||||
|
|
||||||
secrets._cache = {}
|
secrets._secret_cache.clear()
|
||||||
|
secrets._parameter_cache.clear()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -365,8 +366,16 @@ def test_total_summation_multiple_items(
|
||||||
@patch(
|
@patch(
|
||||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||||
)
|
)
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
def test_google_auth_required_when_configured(
|
def test_google_auth_required_when_configured(
|
||||||
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
|
mock_gac,
|
||||||
|
mock_gcid,
|
||||||
|
mock_secret,
|
||||||
|
mock_settings,
|
||||||
|
mock_week,
|
||||||
|
mock_status,
|
||||||
|
mock_put,
|
||||||
|
mock_lam,
|
||||||
):
|
):
|
||||||
"""When Google auth is configured and no token is provided, return 403."""
|
"""When Google auth is configured and no token is provided, return 403."""
|
||||||
from submit_order_handler import lambda_handler
|
from submit_order_handler import lambda_handler
|
||||||
|
|
@ -396,8 +405,16 @@ def test_google_auth_required_when_configured(
|
||||||
@patch(
|
@patch(
|
||||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||||
)
|
)
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
def test_google_auth_bypass_prevention(
|
def test_google_auth_bypass_prevention(
|
||||||
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam
|
mock_gac,
|
||||||
|
mock_gcid,
|
||||||
|
mock_secret,
|
||||||
|
mock_settings,
|
||||||
|
mock_week,
|
||||||
|
mock_status,
|
||||||
|
mock_put,
|
||||||
|
mock_lam,
|
||||||
):
|
):
|
||||||
"""Google auth enabled + name/email in body but no token -> 403 (can't bypass)."""
|
"""Google auth enabled + name/email in body but no token -> 403 (can't bypass)."""
|
||||||
from submit_order_handler import lambda_handler
|
from submit_order_handler import lambda_handler
|
||||||
|
|
@ -434,7 +451,9 @@ def test_google_auth_bypass_prevention(
|
||||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||||
)
|
)
|
||||||
@patch("submit_order_handler.urllib.request.urlopen")
|
@patch("submit_order_handler.urllib.request.urlopen")
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
def test_google_token_audience_mismatch(
|
def test_google_token_audience_mismatch(
|
||||||
|
mock_gac,
|
||||||
mock_urlopen,
|
mock_urlopen,
|
||||||
mock_gcid,
|
mock_gcid,
|
||||||
mock_secret,
|
mock_secret,
|
||||||
|
|
@ -483,7 +502,9 @@ def test_google_token_audience_mismatch(
|
||||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||||
)
|
)
|
||||||
@patch("submit_order_handler.urllib.request.urlopen")
|
@patch("submit_order_handler.urllib.request.urlopen")
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
def test_google_token_domain_mismatch(
|
def test_google_token_domain_mismatch(
|
||||||
|
mock_gac,
|
||||||
mock_urlopen,
|
mock_urlopen,
|
||||||
mock_gcid,
|
mock_gcid,
|
||||||
mock_secret,
|
mock_secret,
|
||||||
|
|
@ -534,7 +555,9 @@ def test_google_token_domain_mismatch(
|
||||||
"submit_order_handler.urllib.request.urlopen",
|
"submit_order_handler.urllib.request.urlopen",
|
||||||
side_effect=urllib.error.URLError("Connection refused"),
|
side_effect=urllib.error.URLError("Connection refused"),
|
||||||
)
|
)
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
def test_google_token_service_unavailable(
|
def test_google_token_service_unavailable(
|
||||||
|
mock_gac,
|
||||||
mock_urlopen,
|
mock_urlopen,
|
||||||
mock_gcid,
|
mock_gcid,
|
||||||
mock_secret,
|
mock_secret,
|
||||||
|
|
@ -581,7 +604,9 @@ def test_google_token_service_unavailable(
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
def test_google_token_http_error_returns_403(
|
def test_google_token_http_error_returns_403(
|
||||||
|
mock_gac,
|
||||||
mock_urlopen,
|
mock_urlopen,
|
||||||
mock_gcid,
|
mock_gcid,
|
||||||
mock_secret,
|
mock_secret,
|
||||||
|
|
@ -608,6 +633,47 @@ def test_google_token_http_error_returns_403(
|
||||||
mock_put.assert_not_called()
|
mock_put.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("submit_order_handler._lambda")
|
||||||
|
@patch("submit_order_handler.put_order")
|
||||||
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
|
@patch("submit_order_handler.current_week", return_value="2026-W20")
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler.get_settings",
|
||||||
|
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
|
||||||
|
)
|
||||||
|
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
|
||||||
|
@patch(
|
||||||
|
"submit_order_handler._get_google_client_id",
|
||||||
|
side_effect=Exception("ParameterNotFound"),
|
||||||
|
)
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
|
def test_ssm_failure_fails_closed(
|
||||||
|
mock_gac,
|
||||||
|
mock_gcid,
|
||||||
|
mock_secret,
|
||||||
|
mock_settings,
|
||||||
|
mock_week,
|
||||||
|
mock_status,
|
||||||
|
mock_put,
|
||||||
|
mock_lam,
|
||||||
|
):
|
||||||
|
"""SSM fetch failure with auth configured -> 503, not silent fallback to manual."""
|
||||||
|
from submit_order_handler import lambda_handler
|
||||||
|
|
||||||
|
items = _make_items([(10.00, 1)])
|
||||||
|
event = _submit_event(items)
|
||||||
|
result = lambda_handler(event, None)
|
||||||
|
status, body = _parse_response(result)
|
||||||
|
|
||||||
|
assert status == 503, (
|
||||||
|
f"Expected 503 for SSM failure (fail-closed), got {status}: {body}"
|
||||||
|
)
|
||||||
|
assert "temporarily unavailable" in body["error"], (
|
||||||
|
f"Expected 'temporarily unavailable' in error, got: {body['error']}"
|
||||||
|
)
|
||||||
|
mock_put.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@patch("submit_order_handler._lambda")
|
@patch("submit_order_handler._lambda")
|
||||||
@patch("submit_order_handler.put_order")
|
@patch("submit_order_handler.put_order")
|
||||||
@patch("submit_order_handler.get_form_status", return_value="open")
|
@patch("submit_order_handler.get_form_status", return_value="open")
|
||||||
|
|
@ -621,7 +687,9 @@ def test_google_token_http_error_returns_403(
|
||||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||||
)
|
)
|
||||||
@patch("submit_order_handler.urllib.request.urlopen")
|
@patch("submit_order_handler.urllib.request.urlopen")
|
||||||
|
@patch("submit_order_handler._google_auth_configured", return_value=True)
|
||||||
def test_google_token_valid_success(
|
def test_google_token_valid_success(
|
||||||
|
mock_gac,
|
||||||
mock_urlopen,
|
mock_urlopen,
|
||||||
mock_gcid,
|
mock_gcid,
|
||||||
mock_secret,
|
mock_secret,
|
||||||
|
|
@ -841,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
|
# Freeze "now" to Thursday 2026-05-14 at 10:00 AM Eastern
|
||||||
thursday = datetime(2026, 5, 14, 10, 0, 0, tzinfo=EASTERN)
|
thursday = datetime(2026, 5, 14, 10, 0, 0, tzinfo=EASTERN)
|
||||||
with patch("submit_order_handler.datetime") as mock_dt:
|
with patch("submit_order_handler._eastern_now", return_value=thursday):
|
||||||
mock_dt.now.return_value = thursday
|
|
||||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
|
||||||
|
|
||||||
event = _make_event(
|
event = _make_event(
|
||||||
method="GET",
|
method="GET",
|
||||||
path="/form-status/2026-W20",
|
path="/form-status/2026-W20",
|
||||||
|
|
@ -876,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 2026-05-18 at 6:30 AM Eastern (before 8am cutoff)
|
||||||
monday_early = datetime(2026, 5, 18, 6, 30, 0, tzinfo=EASTERN)
|
monday_early = datetime(2026, 5, 18, 6, 30, 0, tzinfo=EASTERN)
|
||||||
with patch("submit_order_handler.datetime") as mock_dt:
|
with patch("submit_order_handler._eastern_now", return_value=monday_early):
|
||||||
mock_dt.now.return_value = monday_early
|
|
||||||
mock_dt.side_effect = lambda *a, **kw: datetime(*a, **kw)
|
|
||||||
|
|
||||||
event = _make_event(
|
event = _make_event(
|
||||||
method="GET",
|
method="GET",
|
||||||
path="/form-status/2026-W20",
|
path="/form-status/2026-W20",
|
||||||
|
|
@ -901,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)
|
# VALIDATION (High)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue