mirror of
https://github.com/Sea-Haven-Industries/meal-order-manager.git
synced 2026-09-30 03:03:12 +00:00
fix(auth): require Google authentication in cloud mode (#90)
* 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. * fix(auth): address review follow-ups Fail closed on whitespace-only Google configuration and centralize shared authentication behavior. * test(auth): use non-secret Google client fixture Make the public test identifier explicit so secret scanning does not misclassify it as an API key. * test(auth): avoid OAuth-shaped fixture Use a format-neutral audience value so secret scanning can distinguish the fixture from a real client identifier. * chore(security): suppress public OAuth fixture Document the scanner false positive without suppressing any runtime credential flow.
This commit is contained in:
parent
6968a0299b
commit
311eab35c0
9 changed files with 275 additions and 216 deletions
18
.github/workflows/weekly-menu.yml
vendored
18
.github/workflows/weekly-menu.yml
vendored
|
|
@ -88,16 +88,6 @@ jobs:
|
|||
echo "dist_id=$DIST_ID" >> $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
|
||||
if: env.SKIP_RUN != 'true'
|
||||
id: discount
|
||||
|
|
@ -126,9 +116,10 @@ jobs:
|
|||
GOOGLE_CLIENT_ID=$(aws ssm get-parameter \
|
||||
--name /meal-order-manager/google-client-id \
|
||||
--query 'Parameter.Value' \
|
||||
--output text 2>/dev/null || echo "")
|
||||
--output text)
|
||||
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
|
||||
echo "client_id=$GOOGLE_CLIENT_ID" >> $GITHUB_OUTPUT
|
||||
|
||||
|
|
@ -137,10 +128,9 @@ jobs:
|
|||
run: |
|
||||
python3 src/server/generate_form.py \
|
||||
--api-url "${{ steps.stack.outputs.api_url }}" \
|
||||
--api-key "${{ steps.apikey.outputs.api_key }}" \
|
||||
--bulk-discount "${{ steps.discount.outputs.bulk_discount }}" \
|
||||
--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
|
||||
if: env.SKIP_RUN != 'true'
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"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."
|
||||
"justification": "False positive. tests/test_submit_order.py defines a synthetic Google OAuth audience used only for mocked token verification. OAuth client IDs are public identifiers, this fixture is not a credential, and the tests make no real Google or AWS calls."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -59,7 +59,8 @@ Stack name: `meal-order-manager` (us-east-1)
|
|||
- **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
|
||||
- **EventBridge** — scheduled rules (dual EST/EDT) for close, reminders, payroll email
|
||||
- **Secrets Manager** — Slack bot token, form API key
|
||||
- **Secrets Manager** — Slack bot token
|
||||
- Migration note: the existing `meal-order-manager/form-api-key` secret remains until this change is deployed and verified, then must be deleted during post-deploy cleanup.
|
||||
- **SES** — payroll deduction emails
|
||||
- **CloudWatch Alarms** — coverage across the stack, all notifying the shared `site-alerts` SNS topic (see Monitoring)
|
||||
|
||||
|
|
@ -80,11 +81,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.
|
||||
- **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
|
||||
(~3000ms).
|
||||
(~3000ms). The submit route is limited to 5 requests/second with a burst of 10.
|
||||
|
||||
## 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
|
||||
|
||||
|
|
@ -137,7 +138,7 @@ sam deploy
|
|||
### Post-deploy
|
||||
|
||||
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`
|
||||
4. Verify SES sender identity for `adam@seahavenind.com`
|
||||
5. Set up DNS: CNAME `orders.seahaven.com` → CloudFront distribution domain
|
||||
|
|
@ -160,6 +161,7 @@ python3 src/aggregator/aggregate.py # generate CSV reports
|
|||
- `menu_url` — Redefine Meals menu URL
|
||||
- `order_deadline` — displayed on the form
|
||||
- `roster` — employee list (name, email, slack_user_id)
|
||||
- `google_client_id` — optional locally; required for cloud generation
|
||||
- `output_dir` / `orders_dir` — local output paths
|
||||
|
||||
## Project Structure
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from shared.db import (
|
|||
list_weeks,
|
||||
put_order,
|
||||
)
|
||||
from shared.secrets import get_parameter, get_secret
|
||||
from shared.secrets import get_parameter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
|
@ -44,7 +44,6 @@ def _eastern_now() -> _dt.datetime:
|
|||
return _dt.datetime.now(EASTERN)
|
||||
|
||||
|
||||
_api_key = None
|
||||
_settings = None
|
||||
_settings_ts = 0.0
|
||||
_google_client_id = None
|
||||
|
|
@ -53,13 +52,6 @@ _lambda = boto3.client("lambda")
|
|||
_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]:
|
||||
global _settings, _settings_ts
|
||||
now = time.monotonic()
|
||||
|
|
@ -73,6 +65,10 @@ def _get_discount_settings() -> tuple[Decimal, Decimal]:
|
|||
return _settings
|
||||
|
||||
|
||||
def _get_admin_emails() -> set[str]:
|
||||
return {email.lower() for email in get_settings().get("admin_emails", [])}
|
||||
|
||||
|
||||
def _google_auth_configured() -> bool:
|
||||
return bool(os.environ.get("GOOGLE_CLIENT_ID_PARAM", ""))
|
||||
|
||||
|
|
@ -155,6 +151,10 @@ def _verify_google_token(token: str) -> tuple[dict | None, str]:
|
|||
return None, "invalid"
|
||||
|
||||
|
||||
def _authentication_service_unavailable() -> dict:
|
||||
return response(503, {"error": "Authentication service temporarily unavailable"})
|
||||
|
||||
|
||||
def _verify_admin(event) -> tuple[dict | None, dict | None]:
|
||||
"""Verify Google auth and admin access. Returns (user_info, error_response)."""
|
||||
if not _google_auth_configured():
|
||||
|
|
@ -171,14 +171,11 @@ def _verify_admin(event) -> tuple[dict | None, dict | None]:
|
|||
|
||||
user_info, status = _verify_google_token(token)
|
||||
if status == "unavailable":
|
||||
return None, response(
|
||||
503, {"error": "Authentication service temporarily unavailable"}
|
||||
)
|
||||
return None, _authentication_service_unavailable()
|
||||
if user_info is None:
|
||||
return None, response(403, {"error": "Invalid or unauthorized Google account"})
|
||||
|
||||
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
|
||||
if user_info["email"].lower() not in admin_emails:
|
||||
if user_info["email"].lower() not in _get_admin_emails():
|
||||
return None, response(403, {"error": "Admin access required"})
|
||||
|
||||
return user_info, None
|
||||
|
|
@ -417,48 +414,32 @@ def handle_roster():
|
|||
|
||||
|
||||
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:
|
||||
body = json.loads(event.get("body", "{}"))
|
||||
except json.JSONDecodeError:
|
||||
return response(400, {"error": "Invalid JSON"})
|
||||
|
||||
# --- Authentication ---
|
||||
# If Google auth is configured (SSM param contains a client ID), require a valid
|
||||
# google_id_token. Manual fallback is only allowed when auth is NOT configured.
|
||||
# If SSM fetch fails for any other reason, fail closed (503).
|
||||
# The Lambda is the cloud submission path, so Google authentication is always
|
||||
# required. Local/manual submissions are handled only by src/server/app.py.
|
||||
google_token = body.get("google_id_token")
|
||||
|
||||
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:
|
||||
return response(403, {"error": "Google authentication is required"})
|
||||
user_info, verify_status = _verify_google_token(google_token)
|
||||
if verify_status == "unavailable":
|
||||
return response(
|
||||
503, {"error": "Authentication service temporarily unavailable"}
|
||||
)
|
||||
if user_info is None:
|
||||
return response(403, {"error": "Invalid or unauthorized Google account"})
|
||||
name = user_info["name"]
|
||||
email = user_info["email"]
|
||||
else:
|
||||
name = body.get("employee_name", "").strip()
|
||||
email = body.get("employee_email", "").strip()
|
||||
try:
|
||||
client_id = _get_google_client_id()
|
||||
except Exception as exc:
|
||||
logger.error("SSM fetch failed for Google client ID: %s", exc)
|
||||
return _authentication_service_unavailable()
|
||||
if not client_id:
|
||||
logger.error("Google client ID is unavailable")
|
||||
return _authentication_service_unavailable()
|
||||
if not google_token:
|
||||
return response(403, {"error": "Google authentication is required"})
|
||||
user_info, verify_status = _verify_google_token(google_token)
|
||||
if verify_status == "unavailable":
|
||||
return _authentication_service_unavailable()
|
||||
if user_info is None:
|
||||
return response(403, {"error": "Invalid or unauthorized Google account"})
|
||||
name = user_info["name"]
|
||||
email = user_info["email"]
|
||||
|
||||
items = body.get("items", [])
|
||||
|
||||
|
|
@ -471,10 +452,7 @@ def handle_submit(event):
|
|||
|
||||
week = current_week()
|
||||
status = get_form_status(week)
|
||||
is_admin_user = False
|
||||
if _google_auth_configured():
|
||||
admin_emails = {e.lower() for e in get_settings().get("admin_emails", [])}
|
||||
is_admin_user = email.lower() in admin_emails
|
||||
is_admin_user = email.lower() in _get_admin_emails()
|
||||
if status == "closed" and not is_admin_user:
|
||||
return response(410, {"error": "Orders are closed for this week"})
|
||||
if status == "not_found":
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ and submits orders to the backend (local Flask or cloud API Gateway).
|
|||
|
||||
Usage:
|
||||
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
|
||||
|
|
@ -51,11 +51,14 @@ def generate_form(
|
|||
menu: dict,
|
||||
config: dict,
|
||||
api_url: str = "",
|
||||
api_key: str = "",
|
||||
bulk_discount: float = 0,
|
||||
company_subsidy: float = 0,
|
||||
google_client_id: 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")
|
||||
scraped_at = menu.get("scraped_at", "unknown")
|
||||
deadline = config.get("order_deadline", "Thursday 11:59 PM")
|
||||
|
|
@ -79,7 +82,6 @@ def generate_form(
|
|||
"adminPdfUrl": (
|
||||
f"{api_url}/api/admin/summary-pdf" if api_url else "/api/admin/summary-pdf"
|
||||
),
|
||||
"apiKey": api_key,
|
||||
"bulkDiscount": bulk_discount,
|
||||
"companySubsidy": company_subsidy,
|
||||
"googleClientId": google_client_id if use_google_auth else "",
|
||||
|
|
@ -116,9 +118,6 @@ def main():
|
|||
parser.add_argument(
|
||||
"--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(
|
||||
"--bulk-discount",
|
||||
type=float,
|
||||
|
|
@ -151,22 +150,28 @@ def main():
|
|||
if args.company_subsidy is not None
|
||||
else config.get("company_subsidy_percent", 0)
|
||||
)
|
||||
google_client_id = args.google_client_id or config.get("google_client_id", "")
|
||||
if not google_client_id:
|
||||
google_client_id = (
|
||||
args.google_client_id or config.get("google_client_id", "")
|
||||
).strip()
|
||||
if args.api_url and not google_client_id:
|
||||
try:
|
||||
import boto3
|
||||
|
||||
ssm = boto3.client("ssm")
|
||||
resp = ssm.get_parameter(Name="/meal-order-manager/google-client-id")
|
||||
google_client_id = resp["Parameter"]["Value"]
|
||||
except Exception:
|
||||
pass
|
||||
google_client_id = resp["Parameter"]["Value"].strip()
|
||||
except Exception as exc:
|
||||
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(
|
||||
menu,
|
||||
config,
|
||||
api_url=args.api_url,
|
||||
api_key=args.api_key,
|
||||
bulk_discount=bulk_discount,
|
||||
company_subsidy=company_subsidy,
|
||||
google_client_id=google_client_id,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const STATUS_URL = CONFIG.statusUrl;
|
|||
const ROSTER_URL = CONFIG.rosterUrl;
|
||||
const ADMIN_URL = CONFIG.adminUrl;
|
||||
const ADMIN_PDF_URL = CONFIG.adminPdfUrl;
|
||||
const API_KEY = CONFIG.apiKey;
|
||||
const WEEK = CONFIG.week;
|
||||
const BULK_DISCOUNT = CONFIG.bulkDiscount;
|
||||
const COMPANY_SUBSIDY = CONFIG.companySubsidy;
|
||||
|
|
@ -428,11 +427,9 @@ async function submitOrder() {
|
|||
btn.textContent = 'Submitting...';
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (API_KEY) headers['x-api-key'] = API_KEY;
|
||||
const res = await fetch(SUBMIT_URL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
|
|
|||
|
|
@ -244,6 +244,10 @@ Resources:
|
|||
DefaultRouteSettings:
|
||||
ThrottlingBurstLimit: 50
|
||||
ThrottlingRateLimit: 100
|
||||
RouteSettings:
|
||||
'POST /api/submit-order':
|
||||
ThrottlingBurstLimit: 10
|
||||
ThrottlingRateLimit: 5
|
||||
# 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.
|
||||
CorsConfiguration:
|
||||
|
|
@ -260,7 +264,6 @@ Resources:
|
|||
- OPTIONS
|
||||
AllowHeaders:
|
||||
- Content-Type
|
||||
- x-api-key
|
||||
- Authorization
|
||||
MaxAge: 3600
|
||||
|
||||
|
|
@ -276,7 +279,6 @@ Resources:
|
|||
Timeout: 10
|
||||
Environment:
|
||||
Variables:
|
||||
FORM_APIKEY_SM_NAME: meal-order-manager/form-api-key
|
||||
SLACK_NOTIFIER_ARN: !GetAtt SlackNotifierFunction.Arn
|
||||
GOOGLE_CLIENT_ID_PARAM: /meal-order-manager/google-client-id
|
||||
REPORTS_BUCKET: !Ref ReportsBucket
|
||||
|
|
@ -284,9 +286,6 @@ Resources:
|
|||
- DynamoDBCrudPolicy:
|
||||
TableName: !Ref OrdersTable
|
||||
- Statement:
|
||||
- Effect: Allow
|
||||
Action: secretsmanager:GetSecretValue
|
||||
Resource: !Sub 'arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:meal-order-manager/*'
|
||||
- Effect: Allow
|
||||
Action: lambda:InvokeFunction
|
||||
Resource: !GetAtt SlackNotifierFunction.Arn
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import json
|
|||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -16,7 +17,9 @@ FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
|||
|
||||
sys.path.insert(0, str(SERVER_DIR))
|
||||
|
||||
from generate_form import generate_form # noqa: E402
|
||||
import generate_form as generate_form_module # noqa: E402
|
||||
|
||||
generate_form = generate_form_module.generate_form
|
||||
|
||||
EXPECTED_CONFIG_KEYS = {
|
||||
"authMode",
|
||||
|
|
@ -28,7 +31,6 @@ EXPECTED_CONFIG_KEYS = {
|
|||
"rosterUrl",
|
||||
"adminUrl",
|
||||
"adminPdfUrl",
|
||||
"apiKey",
|
||||
"bulkDiscount",
|
||||
"companySubsidy",
|
||||
"googleClientId",
|
||||
|
|
@ -54,19 +56,25 @@ def _extract_config(html: str) -> dict:
|
|||
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()
|
||||
return generate_form(
|
||||
menu,
|
||||
config,
|
||||
api_url="https://api.example.com",
|
||||
api_key=api_key,
|
||||
api_url="https://api.example.com" if google else "",
|
||||
bulk_discount=10,
|
||||
company_subsidy=50,
|
||||
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:
|
||||
payload = json.dumps(
|
||||
{
|
||||
|
|
@ -112,6 +120,64 @@ def _mock_form_routes(page):
|
|||
|
||||
|
||||
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_cloud_cli_rejects_whitespace_ssm_client_id(self, monkeypatch, capsys):
|
||||
menu, config = _load_fixtures()
|
||||
ssm = SimpleNamespace(get_parameter=lambda **_: {"Parameter": {"Value": " "}})
|
||||
monkeypatch.setitem(sys.modules, "boto3", SimpleNamespace(client=lambda _: ssm))
|
||||
monkeypatch.setattr(generate_form_module, "load_config", lambda: config)
|
||||
monkeypatch.setattr(generate_form_module, "latest_menu", lambda: menu)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
["generate_form.py", "--api-url", "https://api.example.com"],
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
generate_form_module.main()
|
||||
|
||||
assert exc_info.value.code == 2
|
||||
assert "Google auth is required in cloud mode" in capsys.readouterr().err
|
||||
|
||||
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):
|
||||
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
|
||||
assert (
|
||||
"`meal-order-manager/form-api-key` secret remains until this change "
|
||||
"is deployed and verified"
|
||||
) in readme
|
||||
|
||||
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):
|
||||
local = _render(google=False)
|
||||
google = _render(google=True)
|
||||
|
|
@ -291,7 +357,7 @@ class TestGenerateFormPlaywright:
|
|||
sync_api = pytest.importorskip("playwright.sync_api")
|
||||
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
|
||||
# in loadRoster still applies if fetch fails — intercept to be deterministic.
|
||||
out = tmp_path_factory.mktemp("form") / "order-form.html"
|
||||
|
|
@ -321,7 +387,7 @@ class TestGenerateFormPlaywright:
|
|||
sync_playwright = sync_api.sync_playwright
|
||||
|
||||
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:
|
||||
try:
|
||||
|
|
@ -600,7 +666,7 @@ class TestGenerateFormPlaywright:
|
|||
|
||||
@pytest.fixture
|
||||
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.write_text(html)
|
||||
hostile_name = """O'Reilly "<b>Ops</b>\""""
|
||||
|
|
@ -763,7 +829,7 @@ class TestGenerateFormPlaywright:
|
|||
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
|
||||
page.wait_for_function(
|
||||
"() => 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]["total"] == 41.0
|
||||
|
|
@ -845,7 +911,7 @@ class TestGenerateFormGooglePlaywright:
|
|||
) from exc
|
||||
|
||||
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:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""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.
|
||||
"""
|
||||
|
||||
|
|
@ -18,7 +18,6 @@ import pytest
|
|||
# Environment variables required by the handler at import time
|
||||
# ---------------------------------------------------------------------------
|
||||
os.environ.setdefault("TABLE_NAME", "test-orders-table")
|
||||
os.environ.setdefault("FORM_APIKEY_SM_NAME", "test/form-api-key")
|
||||
os.environ.setdefault(
|
||||
"SLACK_NOTIFIER_ARN",
|
||||
"arn:aws:lambda:us-east-1:000000000000:function:test-slack-notifier",
|
||||
|
|
@ -42,8 +41,7 @@ _spec.loader.exec_module(submit_order_handler)
|
|||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
EASTERN = ZoneInfo("America/New_York")
|
||||
TEST_API_KEY = "test-api-key-12345"
|
||||
VALID_GOOGLE_CLIENT_ID = "123456789.apps.googleusercontent.com"
|
||||
VALID_GOOGLE_CLIENT_ID = "test-google-client-id"
|
||||
|
||||
|
||||
def _make_event(
|
||||
|
|
@ -71,9 +69,9 @@ def _make_event(
|
|||
|
||||
def _submit_event(
|
||||
items,
|
||||
api_key=TEST_API_KEY,
|
||||
employee_name="Test User",
|
||||
employee_email="test.user@seahavenind.com",
|
||||
google_token="valid-google-token",
|
||||
extra_body=None,
|
||||
):
|
||||
"""Shortcut for a typical POST /submit event with items."""
|
||||
|
|
@ -82,13 +80,14 @@ def _submit_event(
|
|||
"employee_email": employee_email,
|
||||
"items": items,
|
||||
}
|
||||
if google_token is not None:
|
||||
body["google_id_token"] = google_token
|
||||
if extra_body:
|
||||
body.update(extra_body)
|
||||
return _make_event(
|
||||
method="POST",
|
||||
path="/submit",
|
||||
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
|
||||
# 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)
|
||||
def _reset_handler_caches():
|
||||
"""Reset handler module-level caches before each test."""
|
||||
submit_order_handler._api_key = None
|
||||
submit_order_handler._settings = None
|
||||
submit_order_handler._settings_ts = 0.0
|
||||
submit_order_handler._google_client_id = None
|
||||
|
|
@ -153,6 +151,24 @@ def _reset_shared_caches():
|
|||
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)
|
||||
# ===========================================================================
|
||||
|
|
@ -166,13 +182,13 @@ def _reset_shared_caches():
|
|||
"submit_order_handler.get_settings",
|
||||
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 50},
|
||||
)
|
||||
@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=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_discount_two_step_rounding(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -217,13 +233,13 @@ def test_discount_two_step_rounding(
|
|||
"submit_order_handler.get_settings",
|
||||
return_value={"bulk_discount_percent": 50, "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=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_discount_rounding_half_up_boundary(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -263,13 +279,13 @@ def test_discount_rounding_half_up_boundary(
|
|||
"submit_order_handler.get_settings",
|
||||
return_value={"bulk_discount_percent": -5, "company_subsidy_percent": 150},
|
||||
)
|
||||
@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=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_discount_clamping(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -309,13 +325,13 @@ def test_discount_clamping(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_discount_both_zero(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -358,13 +374,13 @@ def test_discount_both_zero(
|
|||
"submit_order_handler.get_settings",
|
||||
return_value={"bulk_discount_percent": 10, "company_subsidy_percent": 25},
|
||||
)
|
||||
@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=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_total_summation_multiple_items(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -417,13 +433,11 @@ def test_total_summation_multiple_items(
|
|||
"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_parameter")
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_missing_ssm_param_with_env_var_fails_closed(
|
||||
mock_get_menu,
|
||||
mock_get_parameter,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -460,7 +474,6 @@ def test_missing_ssm_param_with_env_var_fails_closed(
|
|||
"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", 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(
|
||||
mock_gac,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -480,7 +492,7 @@ def test_google_auth_required_when_configured(
|
|||
|
||||
items = _make_items([(10.00, 1)])
|
||||
# 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)
|
||||
status, body = _parse_response(result)
|
||||
|
||||
|
|
@ -499,7 +511,6 @@ def test_google_auth_required_when_configured(
|
|||
"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", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
|
|
@ -507,7 +518,6 @@ def test_google_auth_required_when_configured(
|
|||
def test_google_auth_bypass_prevention(
|
||||
mock_gac,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -527,7 +537,6 @@ def test_google_auth_bypass_prevention(
|
|||
method="POST",
|
||||
path="/submit",
|
||||
body=body,
|
||||
headers={"x-api-key": TEST_API_KEY},
|
||||
)
|
||||
result = lambda_handler(event, None)
|
||||
status, body_resp = _parse_response(result)
|
||||
|
|
@ -544,7 +553,6 @@ def test_google_auth_bypass_prevention(
|
|||
"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", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
|
|
@ -554,7 +562,6 @@ def test_google_token_audience_mismatch(
|
|||
mock_gac,
|
||||
mock_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -595,7 +602,6 @@ def test_google_token_audience_mismatch(
|
|||
"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", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
|
|
@ -605,7 +611,6 @@ def test_google_token_domain_mismatch(
|
|||
mock_gac,
|
||||
mock_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -645,7 +650,6 @@ def test_google_token_domain_mismatch(
|
|||
"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", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
|
|
@ -658,7 +662,6 @@ def test_google_token_service_unavailable(
|
|||
mock_gac,
|
||||
mock_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -688,7 +691,6 @@ def test_google_token_service_unavailable(
|
|||
"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", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
|
|
@ -707,7 +709,6 @@ def test_google_token_http_error_returns_403(
|
|||
mock_gac,
|
||||
mock_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -739,7 +740,6 @@ def test_google_token_http_error_returns_403(
|
|||
"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"),
|
||||
|
|
@ -748,7 +748,6 @@ def test_google_token_http_error_returns_403(
|
|||
def test_ssm_failure_fails_closed(
|
||||
mock_gac,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -780,7 +779,6 @@ def test_ssm_failure_fails_closed(
|
|||
"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", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
|
|
@ -792,7 +790,6 @@ def test_google_token_valid_seahaven_com_domain(
|
|||
mock_gac,
|
||||
mock_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -837,7 +834,6 @@ def test_google_token_valid_seahaven_com_domain(
|
|||
"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", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
|
|
@ -849,7 +845,6 @@ def test_google_token_valid_success(
|
|||
mock_gac,
|
||||
mock_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -903,20 +898,18 @@ def test_google_token_valid_success(
|
|||
"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", return_value="")
|
||||
@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_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
mock_put,
|
||||
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
|
||||
|
||||
items = _make_items([(12.00, 1)])
|
||||
|
|
@ -927,15 +920,8 @@ def test_manual_fallback_when_google_not_configured(
|
|||
result = lambda_handler(event, None)
|
||||
status, body = _parse_response(result)
|
||||
|
||||
assert status == 200, f"Expected 200, got {status}: {body}"
|
||||
|
||||
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']}"
|
||||
)
|
||||
assert status == 503, f"Expected fail-closed 503, got {status}: {body}"
|
||||
mock_put.assert_not_called()
|
||||
|
||||
|
||||
@patch("submit_order_handler._lambda")
|
||||
|
|
@ -946,24 +932,30 @@ def test_manual_fallback_when_google_not_configured(
|
|||
"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", return_value="")
|
||||
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_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@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
|
||||
|
||||
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)
|
||||
status, body = _parse_response(result)
|
||||
|
||||
assert status == 403, f"Expected 403 for invalid API key, got {status}: {body}"
|
||||
assert "Invalid API key" in body["error"], (
|
||||
f"Expected 'Invalid API key' in error, got: {body['error']}"
|
||||
)
|
||||
mock_put.assert_not_called()
|
||||
assert status == 200, f"Expected 200 without API key, got {status}: {body}"
|
||||
mock_put.assert_called_once()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
|
@ -979,13 +971,21 @@ def test_submit_invalid_api_key(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"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")
|
||||
def test_slug_from_email(
|
||||
mock_get_menu,
|
||||
mock_verify,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -1020,33 +1020,46 @@ def test_slug_from_email(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"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")
|
||||
def test_slug_edge_cases(
|
||||
mock_get_menu,
|
||||
mock_verify,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
mock_put,
|
||||
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
|
||||
|
||||
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
|
||||
items = _make_items([(10.00, 1)])
|
||||
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
|
||||
event = _submit_event(
|
||||
items,
|
||||
employee_name="First Middle Last",
|
||||
employee_email="First.Middle.Last@x.com",
|
||||
employee_email="ignored@seahavenind.com",
|
||||
)
|
||||
lambda_handler(event, None)
|
||||
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}'"
|
||||
)
|
||||
|
||||
|
|
@ -1054,11 +1067,11 @@ def test_slug_edge_cases(
|
|||
mock_put.reset_mock()
|
||||
mock_get_menu.return_value = _menu_doc_from_retail_pairs([(10.00, 1)])
|
||||
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)
|
||||
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}'"
|
||||
)
|
||||
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",
|
||||
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=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch(
|
||||
"submit_order_handler._verify_google_token",
|
||||
return_value=({"name": "", "email": "test@seahavenind.com"}, "ok"),
|
||||
)
|
||||
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
|
||||
|
||||
items = _make_items([(10.00, 1)])
|
||||
|
|
@ -1221,12 +1239,17 @@ def test_submit_missing_name(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"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(
|
||||
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
|
||||
|
||||
items = _make_items([(10.00, 1)])
|
||||
|
|
@ -1249,10 +1272,11 @@ def test_submit_missing_email(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
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."""
|
||||
from submit_order_handler import lambda_handler
|
||||
|
|
@ -1277,10 +1301,11 @@ def test_submit_zero_quantity_only(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
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."""
|
||||
from submit_order_handler import lambda_handler
|
||||
|
|
@ -1309,7 +1334,6 @@ def test_submit_form_closed(
|
|||
"admin_emails": ["adam@seahavenind.com"],
|
||||
},
|
||||
)
|
||||
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
|
||||
@patch(
|
||||
"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_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -1366,7 +1389,6 @@ def test_admin_can_submit_when_form_closed(
|
|||
"admin_emails": ["adam@seahavenind.com"],
|
||||
},
|
||||
)
|
||||
@patch("submit_order_handler.get_secret", return_value=TEST_API_KEY)
|
||||
@patch(
|
||||
"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_urlopen,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -1419,10 +1440,11 @@ def test_non_admin_blocked_when_form_closed(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
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."""
|
||||
from submit_order_handler import lambda_handler
|
||||
|
|
@ -1447,13 +1469,13 @@ def test_submit_no_menu(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_unknown_meal_returns_400(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -1483,13 +1505,13 @@ def test_unknown_meal_returns_400(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_retail_price_from_menu_not_request_body(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -1519,13 +1541,13 @@ def test_retail_price_from_menu_not_request_body(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_menu_missing_priced_meals_returns_503(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
@ -1557,13 +1579,13 @@ def test_menu_missing_priced_meals_returns_503(
|
|||
"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", return_value="")
|
||||
@patch(
|
||||
"submit_order_handler._get_google_client_id", return_value=VALID_GOOGLE_CLIENT_ID
|
||||
)
|
||||
@patch("submit_order_handler.get_menu")
|
||||
def test_slack_failure_does_not_fail_order(
|
||||
mock_get_menu,
|
||||
mock_gcid,
|
||||
mock_secret,
|
||||
mock_settings,
|
||||
mock_week,
|
||||
mock_status,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue