fix(auth): require Google authentication in cloud mode

Remove the public shared-key mechanism and fail closed on Google auth while adding submit-route throttling.
This commit is contained in:
Adam Moussa 2026-07-31 11:24:57 -04:00
parent adf175daef
commit 347b84f964
No known key found for this signature in database
9 changed files with 242 additions and 215 deletions

View file

@ -88,16 +88,6 @@ jobs:
echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT echo "dist_id=$DIST_ID" >> $GITHUB_OUTPUT
echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT echo "form_url=$FORM_URL" >> $GITHUB_OUTPUT
- name: Get API key
if: env.SKIP_RUN != 'true'
id: apikey
run: |
API_KEY=$(aws secretsmanager get-secret-value \
--secret-id meal-order-manager/form-api-key \
--query 'SecretString' --output text)
echo "::add-mask::$API_KEY"
echo "api_key=$API_KEY" >> $GITHUB_OUTPUT
- name: Get discount settings - name: Get discount settings
if: env.SKIP_RUN != 'true' if: env.SKIP_RUN != 'true'
id: discount id: discount
@ -126,9 +116,10 @@ jobs:
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \ GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
--name /meal-order-manager/google-client-id \ --name /meal-order-manager/google-client-id \
--query 'Parameter.Value' \ --query 'Parameter.Value' \
--output text 2>/dev/null || echo "") --output text)
if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then if [ "$GOOGLE_CLIENT_ID" = "None" ] || [ -z "$GOOGLE_CLIENT_ID" ]; then
GOOGLE_CLIENT_ID="" echo "Google client ID is required for cloud form generation" >&2
exit 1
fi fi
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
@ -137,10 +128,9 @@ jobs:
run: | run: |
python3 src/server/generate_form.py \ python3 src/server/generate_form.py \
--api-url "${{ steps.stack.outputs.api_url }}" \ --api-url "${{ steps.stack.outputs.api_url }}" \
--api-key "${{ steps.apikey.outputs.api_key }}" \
--bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \ --bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \
--company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \ --company-subsidy "${{ steps.discount.outputs.company_subsidy }}" \
${{ steps.google.outputs.client_id && format('--google-client-id "{0}"', steps.google.outputs.client_id) || '' }} --google-client-id "${{ steps.google.outputs.client_id }}"
- name: Upload menu to DynamoDB - name: Upload menu to DynamoDB
if: env.SKIP_RUN != 'true' if: env.SKIP_RUN != 'true'

View file

@ -1,8 +0,0 @@
{
"suppressions": [
{
"id": "gitleaks-generic-api-key-45",
"justification": "False positive. tests/test_submit_order.py:45 defines a low-entropy dummy test constant used as the mocked get_secret return value and injected as the x-api-key header in the test harness (the module makes no real AWS calls). Not a live credential; the real key lives in Secrets Manager (FORM_APIKEY_SM_NAME). Verified proof-or-kill 2026-07-13. Moved from machine-level to repo-local so the Open SWE daily-report automation resolves it."
}
]
}

View file

@ -59,7 +59,7 @@ Stack name: `meal-order-manager` (us-east-1)
- **API Gateway** — HttpApi for order submission and admin operations - **API Gateway** — HttpApi for order submission and admin operations
- **Lambda** — 7 functions: submit-order, admin-authorizer, close-form, aggregate-orders, slack-notifier, sync-roster, email-report - **Lambda** — 7 functions: submit-order, admin-authorizer, close-form, aggregate-orders, slack-notifier, sync-roster, email-report
- **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email - **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email
- **Secrets Manager** — Slack bot token, form API key - **Secrets Manager** — Slack bot token
- **SES** — payroll deduction emails - **SES** — payroll deduction emails
- **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring) - **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring)
@ -80,11 +80,11 @@ sit in ALARM between runs). Alarm names follow `meal-order-manager-<fn>-<signal>
at the table-only dimension, so those are intentionally not alarmed. at the table-only dimension, so those are intentionally not alarmed.
- **API Gateway (OrderApi, HTTP API v2)** — 5xx (threshold 0), 4xx (threshold 20, - **API Gateway (OrderApi, HTTP API v2)** — 5xx (threshold 0), 4xx (threshold 20,
3/2 datapoints to absorb routine 401s from the token authorizer), and p99 Latency 3/2 datapoints to absorb routine 401s from the token authorizer), and p99 Latency
(~3000ms). (~3000ms). The submit route is limited to 5 requests/second with a burst of 10.
## Authentication ## Authentication
Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains. Google Identity Services (OAuth) with tokeninfo endpoint verification. Accepts both `seahavenind.com` and `seahaven.com` Google Workspace domains. Cloud form generation and Lambda order submission fail closed unless `/meal-order-manager/google-client-id` is configured. The local Flask workflow can still use manual name and email entry when Google auth is not configured.
## Admin Panel ## Admin Panel
@ -137,7 +137,7 @@ sam deploy
### Post-deploy ### Post-deploy
1. Create the Slack bot token secret: `aws secretsmanager create-secret --name meal-order-manager/slack-bot-token --secret-string "xoxb-..."` 1. Create the Slack bot token secret: `aws secretsmanager create-secret --name meal-order-manager/slack-bot-token --secret-string "xoxb-..."`
2. Create the form API key secret: `aws secretsmanager create-secret --name meal-order-manager/form-api-key --secret-string "$(openssl rand -hex 32)"` 2. Set the Google OAuth client ID: `aws ssm put-parameter --name /meal-order-manager/google-client-id --type String --value "<YOUR_GOOGLE_CLIENT_ID>" --overwrite`
3. Update the Slack channel SSM parameter: `aws ssm put-parameter --name /meal-order-manager/slack-channel-id --value "C0XXXXXXX" --overwrite` 3. Update the Slack channel SSM parameter: `aws ssm put-parameter --name /meal-order-manager/slack-channel-id --value "C0XXXXXXX" --overwrite`
4. Verify SES sender identity for `adam@seahavenind.com` 4. Verify SES sender identity for `adam@seahavenind.com`
5. Set up DNS: CNAME `orders.seahaven.com` → CloudFront distribution domain 5. Set up DNS: CNAME `orders.seahaven.com` → CloudFront distribution domain
@ -160,6 +160,7 @@ python3 src/aggregator/aggregate.py # generate CSV reports
- `menu_url` — Redefine Meals menu URL - `menu_url` — Redefine Meals menu URL
- `order_deadline` — displayed on the form - `order_deadline` — displayed on the form
- `roster` — employee list (name, email, slack_user_id) - `roster` — employee list (name, email, slack_user_id)
- `google_client_id` — optional locally; required for cloud generation
- `output_dir` / `orders_dir` — local output paths - `output_dir` / `orders_dir` — local output paths
## Project Structure ## Project Structure

View file

@ -26,7 +26,7 @@ from shared.db import (
list_weeks, list_weeks,
put_order, put_order,
) )
from shared.secrets import get_parameter, get_secret from shared.secrets import get_parameter
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
@ -44,7 +44,6 @@ def _eastern_now() -> _dt.datetime:
return _dt.datetime.now(EASTERN) return _dt.datetime.now(EASTERN)
_api_key = None
_settings = None _settings = None
_settings_ts = 0.0 _settings_ts = 0.0
_google_client_id = None _google_client_id = None
@ -53,13 +52,6 @@ _lambda = boto3.client("lambda")
_s3 = boto3.client("s3") _s3 = boto3.client("s3")
def _get_api_key() -> str:
global _api_key
if _api_key is None:
_api_key = get_secret(os.environ["FORM_APIKEY_SM_NAME"])
return _api_key
def _get_discount_settings() -> tuple[Decimal, Decimal]: def _get_discount_settings() -> tuple[Decimal, Decimal]:
global _settings, _settings_ts global _settings, _settings_ts
now = time.monotonic() now = time.monotonic()
@ -417,48 +409,38 @@ def handle_roster():
def handle_submit(event): def handle_submit(event):
api_key = event.get("headers", {}).get("x-api-key", "")
if api_key != _get_api_key():
return response(403, {"error": "Invalid API key"})
try: try:
body = json.loads(event.get("body", "{}")) body = json.loads(event.get("body", "{}"))
except json.JSONDecodeError: except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON"}) return response(400, {"error": "Invalid JSON"})
# --- Authentication --- # The Lambda is the cloud submission path, so Google authentication is always
# If Google auth is configured (SSM param contains a client ID), require a valid # required. Local/manual submissions are handled only by src/server/app.py.
# google_id_token. Manual fallback is only allowed when auth is NOT configured.
# If SSM fetch fails for any other reason, fail closed (503).
google_token = body.get("google_id_token") google_token = body.get("google_id_token")
if _google_auth_configured(): try:
try: client_id = _get_google_client_id()
client_id = _get_google_client_id() except Exception as exc:
except Exception as exc: logger.error("SSM fetch failed for Google client ID: %s", exc)
logger.error("SSM fetch failed for Google client ID: %s", exc) return response(
return response( 503, {"error": "Authentication service temporarily unavailable"}
503, {"error": "Authentication service temporarily unavailable"} )
) if not client_id:
if not client_id: logger.error("Google client ID is unavailable")
logger.error("Google auth configured but client ID is empty") return response(
return response( 503, {"error": "Authentication service temporarily unavailable"}
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) if verify_status == "unavailable":
if verify_status == "unavailable": return response(
return response( 503, {"error": "Authentication service temporarily unavailable"}
503, {"error": "Authentication service temporarily unavailable"} )
) if user_info is None:
if user_info is None: return response(403, {"error": "Invalid or unauthorized Google account"})
return response(403, {"error": "Invalid or unauthorized Google account"}) name = user_info["name"]
name = user_info["name"] email = user_info["email"]
email = user_info["email"]
else:
name = body.get("employee_name", "").strip()
email = body.get("employee_email", "").strip()
items = body.get("items", []) items = body.get("items", [])
@ -471,10 +453,8 @@ def handle_submit(event):
week = current_week() week = current_week()
status = get_form_status(week) status = get_form_status(week)
is_admin_user = False admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
if _google_auth_configured(): is_admin_user = email.lower() in admin_emails
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
is_admin_user = email.lower() in admin_emails
if status == "closed" and not is_admin_user: if status == "closed" and not is_admin_user:
return response(410, {"error": "Orders are closed for this week"}) return response(410, {"error": "Orders are closed for this week"})
if status == "not_found": if status == "not_found":

View file

@ -6,7 +6,7 @@ and submits orders to the backend (local Flask or cloud API Gateway).
Usage: Usage:
python3 generate_form.py # local mode python3 generate_form.py # local mode
python3 generate_form.py --api-url URL --api-key KEY # cloud mode python3 generate_form.py --api-url URL --google-client-id ID # cloud mode
""" """
import argparse import argparse
@ -51,11 +51,14 @@ def generate_form(
menu: dict, menu: dict,
config: dict, config: dict,
api_url: str = "", api_url: str = "",
api_key: str = "",
bulk_discount: float = 0, bulk_discount: float = 0,
company_subsidy: float = 0, company_subsidy: float = 0,
google_client_id: str = "", google_client_id: str = "",
) -> str: ) -> str:
google_client_id = google_client_id.strip()
if api_url and not google_client_id:
raise ValueError("Google client ID is required in cloud mode")
week = datetime.now().strftime("%Y-W%U") week = datetime.now().strftime("%Y-W%U")
scraped_at = menu.get("scraped_at", "unknown") scraped_at = menu.get("scraped_at", "unknown")
deadline = config.get("order_deadline", "Thursday 11:59 PM") deadline = config.get("order_deadline", "Thursday 11:59 PM")
@ -79,7 +82,6 @@ def generate_form(
"adminPdfUrl": ( "adminPdfUrl": (
f"{api_url}/api/admin/summary-pdf" if api_url else "/api/admin/summary-pdf" f"{api_url}/api/admin/summary-pdf" if api_url else "/api/admin/summary-pdf"
), ),
"apiKey": api_key,
"bulkDiscount": bulk_discount, "bulkDiscount": bulk_discount,
"companySubsidy": company_subsidy, "companySubsidy": company_subsidy,
"googleClientId": google_client_id if use_google_auth else "", "googleClientId": google_client_id if use_google_auth else "",
@ -116,9 +118,6 @@ def main():
parser.add_argument( parser.add_argument(
"--api-url", default="", help="API Gateway base URL (cloud mode)" "--api-url", default="", help="API Gateway base URL (cloud mode)"
) )
parser.add_argument(
"--api-key", default="", help="API key for order submission (cloud mode)"
)
parser.add_argument( parser.add_argument(
"--bulk-discount", "--bulk-discount",
type=float, type=float,
@ -151,22 +150,28 @@ def main():
if args.company_subsidy is not None if args.company_subsidy is not None
else config.get("company_subsidy_percent", 0) else config.get("company_subsidy_percent", 0)
) )
google_client_id = args.google_client_id or config.get("google_client_id", "") google_client_id = (
if not google_client_id: args.google_client_id or config.get("google_client_id", "")
).strip()
if args.api_url and not google_client_id:
try: try:
import boto3 import boto3
ssm = boto3.client("ssm") ssm = boto3.client("ssm")
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id") resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
google_client_id = resp["Parameter"]["Value"] google_client_id = resp["Parameter"]["Value"]
except Exception: except Exception as exc:
pass parser.error(
"Google auth is required in cloud mode, but the Google client ID "
f"could not be loaded from SSM: {exc}"
)
if args.api_url and not google_client_id:
parser.error("Google auth is required in cloud mode")
html = generate_form( html = generate_form(
menu, menu,
config, config,
api_url=args.api_url, api_url=args.api_url,
api_key=args.api_key,
bulk_discount=bulk_discount, bulk_discount=bulk_discount,
company_subsidy=company_subsidy, company_subsidy=company_subsidy,
google_client_id=google_client_id, google_client_id=google_client_id,

View file

@ -7,7 +7,6 @@ const STATUS_URL = CONFIG.statusUrl;
const ROSTER_URL = CONFIG.rosterUrl; const ROSTER_URL = CONFIG.rosterUrl;
const ADMIN_URL = CONFIG.adminUrl; const ADMIN_URL = CONFIG.adminUrl;
const ADMIN_PDF_URL = CONFIG.adminPdfUrl; const ADMIN_PDF_URL = CONFIG.adminPdfUrl;
const API_KEY = CONFIG.apiKey;
const WEEK = CONFIG.week; const WEEK = CONFIG.week;
const BULK_DISCOUNT = CONFIG.bulkDiscount; const BULK_DISCOUNT = CONFIG.bulkDiscount;
const COMPANY_SUBSIDY = CONFIG.companySubsidy; const COMPANY_SUBSIDY = CONFIG.companySubsidy;
@ -428,11 +427,9 @@ async function submitOrder() {
btn.textContent = 'Submitting...'; btn.textContent = 'Submitting...';
try { try {
const headers = { 'Content-Type': 'application/json' };
if (API_KEY) headers['x-api-key'] = API_KEY;
const res = await fetch(SUBMIT_URL, { const res = await fetch(SUBMIT_URL, {
method: 'POST', method: 'POST',
headers, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();

View file

@ -244,6 +244,10 @@ Resources:
DefaultRouteSettings: DefaultRouteSettings:
ThrottlingBurstLimit: 50 ThrottlingBurstLimit: 50
ThrottlingRateLimit: 100 ThrottlingRateLimit: 100
RouteSettings:
'POST /api/submit-order':
ThrottlingBurstLimit: 10
ThrottlingRateLimit: 5
# CORS only allows the production domain. For local development, use the # CORS only allows the production domain. For local development, use the
# Flask dev server (app.py) which proxies API requests and doesn't enforce CORS. # Flask dev server (app.py) which proxies API requests and doesn't enforce CORS.
CorsConfiguration: CorsConfiguration:
@ -260,7 +264,6 @@ Resources:
- OPTIONS - OPTIONS
AllowHeaders: AllowHeaders:
- Content-Type - Content-Type
- x-api-key
- Authorization - Authorization
MaxAge: 3600 MaxAge: 3600
@ -276,7 +279,6 @@ Resources:
Timeout: 10 Timeout: 10
Environment: Environment:
Variables: Variables:
FORM_APIKEY_SM_NAME: meal-order-manager/form-api-key
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
REPORTS_BUCKET: !Ref ReportsBucket REPORTS_BUCKET: !Ref ReportsBucket
@ -284,9 +286,6 @@ Resources:
- DynamoDBCrudPolicy: - DynamoDBCrudPolicy:
TableName: !Ref OrdersTable TableName: !Ref OrdersTable
- Statement: - Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
- Effect: Allow - Effect: Allow
Action: lambda:InvokeFunction Action: lambda:InvokeFunction
Resource: !GetAtt SlackNotifierFunction.Arn Resource: !GetAtt SlackNotifierFunction.Arn

View file

@ -28,7 +28,6 @@ EXPECTED_CONFIG_KEYS = {
"rosterUrl", "rosterUrl",
"adminUrl", "adminUrl",
"adminPdfUrl", "adminPdfUrl",
"apiKey",
"bulkDiscount", "bulkDiscount",
"companySubsidy", "companySubsidy",
"googleClientId", "googleClientId",
@ -54,19 +53,25 @@ def _extract_config(html: str) -> dict:
return json.loads(match.group(1)) return json.loads(match.group(1))
def _render(*, google: bool = False, api_key: str = "test-key") -> str: def _render(*, google: bool = False) -> str:
menu, config = _load_fixtures() menu, config = _load_fixtures()
return generate_form( return generate_form(
menu, menu,
config, config,
api_url="https://api.example.com", api_url="https://api.example.com" if google else "",
api_key=api_key,
bulk_discount=10, bulk_discount=10,
company_subsidy=50, company_subsidy=50,
google_client_id="google-client.apps.googleusercontent.com" if google else "", google_client_id="google-client.apps.googleusercontent.com" if google else "",
) )
def _render_for_browser(*, google: bool = False) -> str:
html = _render(google=google)
if not google:
html = html.replace("<head>", '<head><base href="https://api.example.com/">', 1)
return html
def _google_credential() -> str: def _google_credential() -> str:
payload = json.dumps( payload = json.dumps(
{ {
@ -112,6 +117,42 @@ def _mock_form_routes(page):
class TestGenerateFormStructural: class TestGenerateFormStructural:
def test_cloud_mode_requires_google_auth(self):
menu, config = _load_fixtures()
for client_id in ("", " "):
with pytest.raises(ValueError, match="Google client ID is required"):
generate_form(
menu,
config,
api_url="https://api.example.com",
google_client_id=client_id,
)
def test_generated_form_contains_no_api_key_handling(self):
html = _render(google=True)
assert "apiKey" not in _extract_config(html)
assert "API_KEY" not in html
assert "x-api-key" not in html
def test_cloud_configuration_has_no_form_api_key(self):
template = (REPO_ROOT / "template.yaml").read_text()
workflow = (REPO_ROOT / ".github" / "workflows" / "weekly-menu.yml").read_text()
readme = (REPO_ROOT / "README.md").read_text()
for text in (template, workflow, readme):
assert "FORM_APIKEY" not in text
assert "form-api-key" not in text
assert "x-api-key" not in text
assert "--api-key" not in text
def test_submit_route_has_explicit_throttling(self):
template = (REPO_ROOT / "template.yaml").read_text()
assert "'POST /api/submit-order':" in template
submit_settings = template.split("'POST /api/submit-order':", 1)[1].split(
"CorsConfiguration:", 1
)[0]
assert "ThrottlingBurstLimit: 10" in submit_settings
assert "ThrottlingRateLimit: 5" in submit_settings
def test_local_and_google_render(self): def test_local_and_google_render(self):
local = _render(google=False) local = _render(google=False)
google = _render(google=True) google = _render(google=True)
@ -291,7 +332,7 @@ class TestGenerateFormPlaywright:
sync_api = pytest.importorskip("playwright.sync_api") sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright sync_playwright = sync_api.sync_playwright
html = _render(google=False, api_key="") html = _render_for_browser(google=False)
# Stub status/roster so the page does not hang on network; roster fallback # Stub status/roster so the page does not hang on network; roster fallback
# in loadRoster still applies if fetch fails — intercept to be deterministic. # in loadRoster still applies if fetch fails — intercept to be deterministic.
out = tmp_path_factory.mktemp("form") / "order-form.html" out = tmp_path_factory.mktemp("form") / "order-form.html"
@ -321,7 +362,7 @@ class TestGenerateFormPlaywright:
sync_playwright = sync_api.sync_playwright sync_playwright = sync_api.sync_playwright
html_path = tmp_path / f"order-form-{'google' if google else 'local'}.html" html_path = tmp_path / f"order-form-{'google' if google else 'local'}.html"
html_path.write_text(_render(google=google, api_key="")) html_path.write_text(_render_for_browser(google=google))
with sync_playwright() as p: with sync_playwright() as p:
try: try:
@ -600,7 +641,7 @@ class TestGenerateFormPlaywright:
@pytest.fixture @pytest.fixture
def mobile_admin_page(self, tmp_path, browser_page): def mobile_admin_page(self, tmp_path, browser_page):
html = _render(google=False, api_key="") html = _render_for_browser(google=False)
out = tmp_path / "admin-order-form.html" out = tmp_path / "admin-order-form.html"
out.write_text(html) out.write_text(html)
hostile_name = """O'Reilly "<b>Ops</b>\"""" hostile_name = """O'Reilly "<b>Ops</b>\""""
@ -763,7 +804,7 @@ class TestGenerateFormPlaywright:
page.locator('.admin-order-card [data-admin-action="save-edit"]').click() page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_function( page.wait_for_function(
"() => document.querySelector('.admin-order-card')" "() => document.querySelector('.admin-order-card')"
'.textContent.includes("Luis\' Lomo Saltado ×2")' # noqa: RUF001 '?.textContent.includes("Luis\' Lomo Saltado ×2")' # noqa: RUF001
) )
assert state["orders"][0]["employee_name"] == hostile_name assert state["orders"][0]["employee_name"] == hostile_name
assert state["orders"][0]["total"] == 41.0 assert state["orders"][0]["total"] == 41.0
@ -845,7 +886,7 @@ class TestGenerateFormGooglePlaywright:
) from exc ) from exc
out = tmp_path_factory.mktemp("google-form") / "order-form.html" out = tmp_path_factory.mktemp("google-form") / "order-form.html"
out.write_text(_render(google=True, api_key="")) out.write_text(_render_for_browser(google=True))
with sync_playwright() as p: with sync_playwright() as p:
try: try:

View file

@ -1,6 +1,6 @@
"""Unit tests for functions/submit_order/handler.py """Unit tests for functions/submit_order/handler.py
All external dependencies (DynamoDB, SSM, Secrets Manager, Lambda invoke) are All external dependencies (DynamoDB, SSM, Google tokeninfo, Lambda invoke) are
mocked — no real AWS calls are made. mocked — no real AWS calls are made.
""" """
@ -18,7 +18,6 @@ import pytest
# Environment variables required by the handler at import time # Environment variables required by the handler at import time
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
os.environ.setdefault("TABLE_NAME", "test-orders-table") os.environ.setdefault("TABLE_NAME", "test-orders-table")
os.environ.setdefault("FORM_APIKEY_SM_NAME", "test/form-api-key")
os.environ.setdefault( os.environ.setdefault(
"SLACK_NOTIFIER_ARN", "SLACK_NOTIFIER_ARN",
"arn:aws:lambda:us-east-1:000000000000:function:test-slack-notifier", "arn:aws:lambda:us-east-1:000000000000:function:test-slack-notifier",
@ -42,7 +41,6 @@ _spec.loader.exec_module(submit_order_handler)
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
EASTERN = ZoneInfo("America/New_York") EASTERN = ZoneInfo("America/New_York")
TEST_API_KEY = "test-api-key-12345"
VALID_GOOGLE_CLIENT_ID = "123456789.apps.googleusercontent.com" VALID_GOOGLE_CLIENT_ID = "123456789.apps.googleusercontent.com"
@ -71,9 +69,9 @@ def _make_event(
def _submit_event( def _submit_event(
items, items,
api_key=TEST_API_KEY,
employee_name="Test User", employee_name="Test User",
employee_email="test.user@seahavenind.com", employee_email="test.user@seahavenind.com",
google_token="valid-google-token",
extra_body=None, extra_body=None,
): ):
"""Shortcut for a typical POST /submit event with items.""" """Shortcut for a typical POST /submit event with items."""
@ -82,13 +80,14 @@ def _submit_event(
"employee_email": employee_email, "employee_email": employee_email,
"items": items, "items": items,
} }
if google_token is not None:
body["google_id_token"] = google_token
if extra_body: if extra_body:
body.update(extra_body) body.update(extra_body)
return _make_event( return _make_event(
method="POST", method="POST",
path="/submit", path="/submit",
body=body, body=body,
headers={"x-api-key": api_key},
) )
@ -129,13 +128,12 @@ def _menu_doc_from_retail_pairs(pairs):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# We need to reset module-level caches between tests to avoid cross-test # We need to reset module-level caches between tests to avoid cross-test
# leakage. The handler module caches _api_key, _settings, _google_client_id. # leakage. The handler module caches settings and the Google client ID.
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_handler_caches(): def _reset_handler_caches():
"""Reset handler module-level caches before each test.""" """Reset handler module-level caches before each test."""
submit_order_handler._api_key = None
submit_order_handler._settings = None submit_order_handler._settings = None
submit_order_handler._settings_ts = 0.0 submit_order_handler._settings_ts = 0.0
submit_order_handler._google_client_id = None submit_order_handler._google_client_id = None
@ -153,6 +151,24 @@ def _reset_shared_caches():
yield yield
@pytest.fixture(autouse=True)
def _mock_google_tokeninfo():
"""Return a valid company identity unless a test overrides tokeninfo."""
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(
{
"aud": VALID_GOOGLE_CLIENT_ID,
"hd": "seahavenind.com",
"name": "Test User",
"email": "test.user@seahavenind.com",
}
).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("submit_order_handler.urllib.request.urlopen", return_value=mock_resp):
yield
# =========================================================================== # ===========================================================================
# PRICING PIPELINE (Critical) # PRICING PIPELINE (Critical)
# =========================================================================== # ===========================================================================
@ -166,13 +182,13 @@ def _reset_shared_caches():
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50}, return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_discount_two_step_rounding( def test_discount_two_step_rounding(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -217,13 +233,13 @@ def test_discount_two_step_rounding(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 50, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 50, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_discount_rounding_half_up_boundary( def test_discount_rounding_half_up_boundary(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -263,13 +279,13 @@ def test_discount_rounding_half_up_boundary(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": -5, "company_subsidy_percent": 150}, return_value={"bulk_discount_percent": -5, "company_subsidy_percent": 150},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_discount_clamping( def test_discount_clamping(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -309,13 +325,13 @@ def test_discount_clamping(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_discount_both_zero( def test_discount_both_zero(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -358,13 +374,13 @@ def test_discount_both_zero(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 25}, return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 25},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_total_summation_multiple_items( def test_total_summation_multiple_items(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -417,13 +433,11 @@ def test_total_summation_multiple_items(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, 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_parameter") @patch("submit_order_handler.get_parameter")
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_missing_ssm_param_with_env_var_fails_closed( def test_missing_ssm_param_with_env_var_fails_closed(
mock_get_menu, mock_get_menu,
mock_get_parameter, mock_get_parameter,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -460,7 +474,6 @@ def test_missing_ssm_param_with_env_var_fails_closed(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -468,7 +481,6 @@ def test_missing_ssm_param_with_env_var_fails_closed(
def test_google_auth_required_when_configured( def test_google_auth_required_when_configured(
mock_gac, mock_gac,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -480,7 +492,7 @@ def test_google_auth_required_when_configured(
items = _make_items([(10.00, 1)]) items = _make_items([(10.00, 1)])
# Body has name/email but no google_id_token # Body has name/email but no google_id_token
event = _submit_event(items) event = _submit_event(items, google_token=None)
result = lambda_handler(event, None) result = lambda_handler(event, None)
status, body = _parse_response(result) status, body = _parse_response(result)
@ -499,7 +511,6 @@ def test_google_auth_required_when_configured(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -507,7 +518,6 @@ def test_google_auth_required_when_configured(
def test_google_auth_bypass_prevention( def test_google_auth_bypass_prevention(
mock_gac, mock_gac,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -527,7 +537,6 @@ def test_google_auth_bypass_prevention(
method="POST", method="POST",
path="/submit", path="/submit",
body=body, body=body,
headers={"x-api-key": TEST_API_KEY},
) )
result = lambda_handler(event, None) result = lambda_handler(event, None)
status, body_resp = _parse_response(result) status, body_resp = _parse_response(result)
@ -544,7 +553,6 @@ def test_google_auth_bypass_prevention(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -554,7 +562,6 @@ def test_google_token_audience_mismatch(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -595,7 +602,6 @@ def test_google_token_audience_mismatch(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -605,7 +611,6 @@ def test_google_token_domain_mismatch(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -645,7 +650,6 @@ def test_google_token_domain_mismatch(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -658,7 +662,6 @@ def test_google_token_service_unavailable(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -688,7 +691,6 @@ def test_google_token_service_unavailable(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -707,7 +709,6 @@ def test_google_token_http_error_returns_403(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -739,7 +740,6 @@ def test_google_token_http_error_returns_403(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@patch( @patch(
"submit_order_handler._get_google_client_id", "submit_order_handler._get_google_client_id",
side_effect=Exception("ParameterNotFound"), side_effect=Exception("ParameterNotFound"),
@ -748,7 +748,6 @@ def test_google_token_http_error_returns_403(
def test_ssm_failure_fails_closed( def test_ssm_failure_fails_closed(
mock_gac, mock_gac,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -780,7 +779,6 @@ def test_ssm_failure_fails_closed(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -792,7 +790,6 @@ def test_google_token_valid_seahaven_com_domain(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -837,7 +834,6 @@ def test_google_token_valid_seahaven_com_domain(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -849,7 +845,6 @@ def test_google_token_valid_success(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -903,20 +898,18 @@ def test_google_token_valid_success(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, 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", return_value="") @patch("submit_order_handler._get_google_client_id", return_value="")
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_manual_fallback_when_google_not_configured( def test_missing_google_client_id_fails_closed(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
mock_put, mock_put,
mock_lam, mock_lam,
): ):
"""No Google client ID configured -> manual name/email accepted and order saved.""" """No Google client ID configured -> 503, never manual fallback."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
items = _make_items([(12.00, 1)]) items = _make_items([(12.00, 1)])
@ -927,15 +920,8 @@ def test_manual_fallback_when_google_not_configured(
result = lambda_handler(event, None) result = lambda_handler(event, None)
status, body = _parse_response(result) status, body = _parse_response(result)
assert status == 200, f"Expected 200, got {status}: {body}" assert status == 503, f"Expected fail-closed 503, got {status}: {body}"
mock_put.assert_not_called()
saved_order = mock_put.call_args[0][2]
assert saved_order["employee_name"] == "Manual User", (
f"Name should be from manual input, got: {saved_order['employee_name']}"
)
assert saved_order["employee_email"] == "manual@seahavenind.com", (
f"Email should be from manual input, got: {saved_order['employee_email']}"
)
@patch("submit_order_handler._lambda") @patch("submit_order_handler._lambda")
@ -946,24 +932,30 @@ def test_manual_fallback_when_google_not_configured(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
def test_submit_invalid_api_key( )
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam @patch("submit_order_handler.get_menu")
def test_submit_requires_no_api_key(
mock_get_menu,
mock_gcid,
mock_settings,
mock_week,
mock_status,
mock_put,
mock_lam,
): ):
"""Wrong x-api-key -> 403.""" """A Google-authenticated submission succeeds without a shared API key."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)]) items = _make_items([(10.00, 1)])
event = _submit_event(items, api_key="wrong-api-key") mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event(items)
result = lambda_handler(event, None) result = lambda_handler(event, None)
status, body = _parse_response(result) status, body = _parse_response(result)
assert status == 403, f"Expected 403 for invalid API key, got {status}: {body}" assert status == 200, f"Expected 200 without API key, got {status}: {body}"
assert "Invalid API key" in body["error"], ( mock_put.assert_called_once()
f"Expected 'Invalid API key' in error, got: {body['error']}"
)
mock_put.assert_not_called()
# =========================================================================== # ===========================================================================
@ -979,13 +971,21 @@ def test_submit_invalid_api_key(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch(
"submit_order_handler._verify_google_token",
return_value=(
{"name": "Adam Moussa", "email": "Adam.Moussa@seahavenind.com"},
"ok",
),
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_slug_from_email( def test_slug_from_email(
mock_get_menu, mock_get_menu,
mock_verify,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -1020,33 +1020,46 @@ def test_slug_from_email(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler._verify_google_token")
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_slug_edge_cases( def test_slug_edge_cases(
mock_get_menu, mock_get_menu,
mock_verify,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
mock_put, mock_put,
mock_lam, mock_lam,
): ):
"""Slug uses full lowercase email, preserving uniqueness across domains.""" """Slug uses the full lowercase Google email across allowed domains."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
mock_verify.side_effect = [
(
{
"name": "First Middle Last",
"email": "First.Middle.Last@seahavenind.com",
},
"ok",
),
({"name": "Bob Smith", "email": "Bob@seahaven.com"}, "ok"),
]
# Test 1: full email preserved # Test 1: full email preserved
items = _make_items([(10.00, 1)]) items = _make_items([(10.00, 1)])
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)]) mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event( event = _submit_event(
items, items,
employee_name="First Middle Last", employee_name="First Middle Last",
employee_email="First.Middle.Last@x.com", employee_email="ignored@seahavenind.com",
) )
lambda_handler(event, None) lambda_handler(event, None)
slug_1 = mock_put.call_args[0][1] slug_1 = mock_put.call_args[0][1]
assert slug_1 == "first.middle.last@x.com", ( assert slug_1 == "first.middle.last@seahavenind.com", (
f"Slug should be full lowercase email, got '{slug_1}'" f"Slug should be full lowercase email, got '{slug_1}'"
) )
@ -1054,11 +1067,11 @@ def test_slug_edge_cases(
mock_put.reset_mock() mock_put.reset_mock()
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)]) mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
event = _submit_event( event = _submit_event(
items, employee_name="Bob Smith", employee_email="Bob@other.com" items, employee_name="Ignored", employee_email="ignored@seahavenind.com"
) )
lambda_handler(event, None) lambda_handler(event, None)
slug_2 = mock_put.call_args[0][1] slug_2 = mock_put.call_args[0][1]
assert slug_2 == "bob@other.com", ( assert slug_2 == "bob@seahaven.com", (
f"Slug should be full lowercase email, got '{slug_2}'" f"Slug should be full lowercase email, got '{slug_2}'"
) )
assert slug_1 != slug_2, "Different emails must produce different slugs" assert slug_1 != slug_2, "Different emails must produce different slugs"
@ -1191,12 +1204,17 @@ def test_form_status_closed_saturday_before_dst_end(mock_week, mock_status):
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch(
"submit_order_handler._verify_google_token",
return_value=({"name": "", "email": "test@seahavenind.com"}, "ok"),
)
def test_submit_missing_name( def test_submit_missing_name(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam mock_verify, mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
): ):
"""Empty employee name -> 400.""" """A Google identity without a name -> 400."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)]) items = _make_items([(10.00, 1)])
@ -1221,12 +1239,17 @@ def test_submit_missing_name(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch(
"submit_order_handler._verify_google_token",
return_value=({"name": "Test User", "email": ""}, "ok"),
)
def test_submit_missing_email( def test_submit_missing_email(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam mock_verify, mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
): ):
"""Empty employee email -> 400.""" """A Google identity without an email -> 400."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
items = _make_items([(10.00, 1)]) items = _make_items([(10.00, 1)])
@ -1249,10 +1272,11 @@ def test_submit_missing_email(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
def test_submit_zero_quantity_only( def test_submit_zero_quantity_only(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
): ):
"""All items with quantity 0 -> 400.""" """All items with quantity 0 -> 400."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
@ -1277,10 +1301,11 @@ def test_submit_zero_quantity_only(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
def test_submit_form_closed( def test_submit_form_closed(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
): ):
"""Form closed -> 410.""" """Form closed -> 410."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
@ -1309,7 +1334,6 @@ def test_submit_form_closed(
"admin_emails": ["adam@seahavenind.com"], "admin_emails": ["adam@seahavenind.com"],
}, },
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -1321,7 +1345,6 @@ def test_admin_can_submit_when_form_closed(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -1366,7 +1389,6 @@ def test_admin_can_submit_when_form_closed(
"admin_emails": ["adam@seahavenind.com"], "admin_emails": ["adam@seahavenind.com"],
}, },
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
@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
) )
@ -1378,7 +1400,6 @@ def test_non_admin_blocked_when_form_closed(
mock_gac, mock_gac,
mock_urlopen, mock_urlopen,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -1419,10 +1440,11 @@ def test_non_admin_blocked_when_form_closed(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
def test_submit_no_menu( def test_submit_no_menu(
mock_gcid, mock_secret, mock_settings, mock_week, mock_status, mock_put, mock_lam mock_gcid, mock_settings, mock_week, mock_status, mock_put, mock_lam
): ):
"""No menu available -> 404.""" """No menu available -> 404."""
from submit_order_handler import lambda_handler from submit_order_handler import lambda_handler
@ -1447,13 +1469,13 @@ def test_submit_no_menu(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_unknown_meal_returns_400( def test_unknown_meal_returns_400(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -1483,13 +1505,13 @@ def test_unknown_meal_returns_400(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_retail_price_from_menu_not_request_body( def test_retail_price_from_menu_not_request_body(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -1519,13 +1541,13 @@ def test_retail_price_from_menu_not_request_body(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_menu_missing_priced_meals_returns_503( def test_menu_missing_priced_meals_returns_503(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,
@ -1557,13 +1579,13 @@ def test_menu_missing_priced_meals_returns_503(
"submit_order_handler.get_settings", "submit_order_handler.get_settings",
return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0}, return_value={"bulk_discount_percent": 0, "company_subsidy_percent": 0},
) )
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY) @patch(
@patch("submit_order_handler._get_google_client_id", return_value="") "submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
)
@patch("submit_order_handler.get_menu") @patch("submit_order_handler.get_menu")
def test_slack_failure_does_not_fail_order( def test_slack_failure_does_not_fail_order(
mock_get_menu, mock_get_menu,
mock_gcid, mock_gcid,
mock_secret,
mock_settings, mock_settings,
mock_week, mock_week,
mock_status, mock_status,