diff --git a/functions/submit_order/handler.py b/functions/submit_order/handler.py index f8159cc..27cf142 100644 --- a/functions/submit_order/handler.py +++ b/functions/submit_order/handler.py @@ -177,8 +177,8 @@ def _verify_portal_token(token: str) -> dict | None: ) -def _verify_admin(event) -> tuple[dict | None, dict | None]: - """Verify portal or Google auth and admin access.""" +def _verify_identity(event) -> tuple[dict | None, dict | None]: + """Verify a portal Cognito or Google bearer token.""" token = _extract_bearer_token(event) if not token: return None, response(403, {"error": "Authentication required"}) @@ -192,23 +192,41 @@ def _verify_admin(event) -> tuple[dict | None, dict | None]: return None, response( 403, {"error": "Invalid or unauthorized portal account"} ) - else: - if not _google_auth_configured(): - return None, response(403, {"error": "Authentication not configured"}) - user_info, status = _verify_google_token(token) - if status == "unavailable": - return None, _authentication_service_unavailable() - if user_info is None: - return None, response( - 403, {"error": "Invalid or unauthorized Google account"} - ) + return user_info, None + if not _google_auth_configured(): + return None, response(403, {"error": "Authentication not configured"}) + user_info, status = _verify_google_token(token) + if status == "unavailable": + return None, _authentication_service_unavailable() + if user_info is None: + return None, response(403, {"error": "Invalid or unauthorized Google account"}) + return user_info, None + + +def _verify_admin(event) -> tuple[dict | None, dict | None]: + """Verify portal or Google auth and admin access.""" + user_info, err = _verify_identity(event) + if err: + return None, err if user_info["email"].lower() not in _get_admin_emails(): return None, response(403, {"error": "Admin access required"}) - return user_info, None +def _week_id(requested: str) -> str | None: + if re.fullmatch(r"\d{4}-W\d{2}", requested): + return requested + match = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", requested) + if not match: + return None + try: + day = _dt.date(int(match[1]), int(match[2]), int(match[3])) + except ValueError: + return None + return day.strftime("%Y-W%U") + + def lambda_handler(event, context): method = event.get("requestContext", {}).get("http", {}).get("method", "GET") path = event.get("rawPath", "") @@ -235,6 +253,9 @@ def lambda_handler(event, context): if "/form-status/" in path: return handle_form_status(event) + if "/api/orders/" in path and method == "GET": + return handle_my_orders(event) + if "/roster" in path: return handle_roster() @@ -514,6 +535,29 @@ def handle_admin_update(event): ) +def handle_my_orders(event): + """Return only the caller's order for the week. Coworker orders stay off the wire.""" + user, err = _verify_identity(event) + if err: + return err + + requested_week = (event.get("pathParameters") or {}).get("week", "") + week = _week_id(requested_week) + if week is None: + return response(400, {"error": "week path param must be YYYY-WNN"}) + + order = get_order(week, user["email"].lower()) + if order is None: + return response(200, {"week": week, "orders": []}) + return response( + 200, + { + "week": week, + "orders": [{"employee_email": order.get("employee_email", "")}], + }, + ) + + def handle_form_status(event): week = event.get("pathParameters", {}).get("week", current_week()) status = get_form_status(week) diff --git a/terraform/apigateway.tf b/terraform/apigateway.tf index 06d5652..007ed4a 100644 --- a/terraform/apigateway.tf +++ b/terraform/apigateway.tf @@ -1,12 +1,12 @@ # HTTP API fronting the order form. # # Three authorization modes coexist, matching template.yaml: -# NONE — the public form routes (submit-order, menu, form-status, roster) +# NONE — public form routes plus GET /api/orders/{week} (bearer checked in Lambda) # AWS_IAM — the weekly-menu publication routes, called with SigV4 by # scripts/upload_menu.py from GitHub Actions # CUSTOM — every /api/admin route, behind the Google ID token authorizer # -# All ten routes integrate with submit-order, which dispatches internally on +# All eleven routes integrate with submit-order, which dispatches internally on # the route key. resource "aws_apigatewayv2_api" "order_api" { diff --git a/terraform/cloudfront.tf b/terraform/cloudfront.tf index 518b091..60b7f25 100644 --- a/terraform/cloudfront.tf +++ b/terraform/cloudfront.tf @@ -57,6 +57,26 @@ resource "aws_cloudfront_origin_request_policy" "menu_api" { } } +# Rewrite extensionless form paths to index.html on the S3 origin only. +# A distribution-wide 403 custom error page would also rewrite API 403s +# (non-admin, bad bearer) into form HTML. +resource "aws_cloudfront_function" "form_spa_rewrite" { + name = "${local.project}-form-spa-rewrite" + runtime = "cloudfront-js-2.0" + comment = "Rewrite extensionless form paths to /index.html" + publish = true + code = <<-EOF + function handler(event) { + var request = event.request; + var uri = request.uri; + if (uri.lastIndexOf('.') <= uri.lastIndexOf('/')) { + request.uri = '/index.html'; + } + return request; + } + EOF +} + resource "aws_cloudfront_distribution" "form" { enabled = true is_ipv6_enabled = true @@ -100,6 +120,51 @@ resource "aws_cloudfront_distribution" "form" { origin_request_policy_id = aws_cloudfront_origin_request_policy.menu_api.id } + # Do not use path `/api/*`. That would front IAM-only publish routes without SigV4. + ordered_cache_behavior { + path_pattern = "/api/form-status/*" + target_origin_id = "OrderApiOrigin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.api_cache_policy_id + origin_request_policy_id = local.api_origin_request_policy_id + } + + ordered_cache_behavior { + path_pattern = "/api/orders/*" + target_origin_id = "OrderApiOrigin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = ["GET", "HEAD", "OPTIONS"] + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.api_cache_policy_id + origin_request_policy_id = local.api_origin_request_policy_id + } + + ordered_cache_behavior { + path_pattern = "/api/submit-order" + target_origin_id = "OrderApiOrigin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = local.cloudfront_all_methods + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.api_cache_policy_id + origin_request_policy_id = local.api_origin_request_policy_id + } + + ordered_cache_behavior { + path_pattern = "/api/admin/*" + target_origin_id = "OrderApiOrigin" + viewer_protocol_policy = "redirect-to-https" + allowed_methods = local.cloudfront_all_methods + cached_methods = ["GET", "HEAD"] + compress = true + cache_policy_id = local.api_cache_policy_id + origin_request_policy_id = local.api_origin_request_policy_id + } + default_cache_behavior { target_origin_id = "S3FormOrigin" viewer_protocol_policy = "redirect-to-https" @@ -110,16 +175,12 @@ resource "aws_cloudfront_distribution" "form" { # AWS managed policy: CachingDisabled. The form HTML is republished weekly # and read through a signed API, so a stale edge copy is worse than an # origin fetch. - cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" - } + cache_policy_id = local.api_cache_policy_id - # A request for an object the private origin does not hold returns 403, not - # 404. Rewriting it to the form keeps deep links working, matching the SAM - # template. - custom_error_response { - error_code = 403 - response_code = 200 - response_page_path = "/index.html" + function_association { + event_type = "viewer-request" + function_arn = aws_cloudfront_function.form_spa_rewrite.arn + } } restrictions { diff --git a/terraform/locals.tf b/terraform/locals.tf index 71dadba..f9e9567 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -51,6 +51,13 @@ locals { SLACK_BOT_SM_NAME = local.slack_bot_secret_name } + # AWS managed CachingDisabled / AllViewerExceptHostHeader. Authenticated + # portal calls must not be cached and must not send the viewer Host to the + # HTTP API (Forbidden). + api_cache_policy_id = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad" + api_origin_request_policy_id = "b689b0a8-53d0-40ab-baf2-68738e2966ac" + cloudfront_all_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + # HTTP API routes, all integrated with submit-order. `authorizer` selects the # authorization mode; `permission_source` is the method/path suffix of the # per-route lambda:InvokeFunction grant (path parameters become `*`). @@ -65,6 +72,11 @@ locals { authorizer = "NONE" permission_source = "GET/api/menu/*" } + my_orders = { + route_key = "GET /api/orders/{week}" + authorizer = "NONE" + permission_source = "GET/api/orders/*" + } form_status = { route_key = "GET /api/form-status/{week}" authorizer = "NONE" diff --git a/tests/test_submit_order.py b/tests/test_submit_order.py index 161fee8..6f30017 100644 --- a/tests/test_submit_order.py +++ b/tests/test_submit_order.py @@ -2021,3 +2021,97 @@ def test_admin_summary_pdf_requires_admin(mock_verify): status, body = _parse_response(lambda_handler(_pdf_event("2026-W22"), None)) assert status == 401, f"Expected 401, got {status}: {body}" + + +# =========================================================================== +# GET /api/orders/{week} (caller-only) +# =========================================================================== + + +def _orders_event(week="2026-W36", token="portal-id-token"): + headers = {"authorization": f"Bearer {token}"} if token else {} + return _make_event( + method="GET", + path=f"/api/orders/{week}", + headers=headers, + path_parameters={"week": week}, + ) + + +@patch( + "submit_order_handler.get_order", + return_value={ + "employee_email": "portal.employee@seahavenind.com", + "employee_name": "Portal Employee", + "items": [{"name": "Soup", "quantity": 1}], + "total": 5.5, + }, +) +@patch( + "submit_order_handler._verify_portal_token", + return_value={ + "name": "Portal Employee", + "email": "portal.employee@seahavenind.com", + }, +) +@patch("submit_order_handler.looks_like_cognito_token", return_value=True) +def test_my_orders_returns_only_caller_email(mock_looks_like, mock_portal, mock_get): + status, body = _parse_response( + submit_order_handler.lambda_handler(_orders_event(), None) + ) + + assert status == 200 + assert body == { + "week": "2026-W36", + "orders": [{"employee_email": "portal.employee@seahavenind.com"}], + } + mock_get.assert_called_once_with("2026-W36", "portal.employee@seahavenind.com") + + +@patch("submit_order_handler.get_order", return_value=None) +@patch( + "submit_order_handler._verify_portal_token", + return_value={ + "name": "Portal Employee", + "email": "portal.employee@seahavenind.com", + }, +) +@patch("submit_order_handler.looks_like_cognito_token", return_value=True) +def test_my_orders_empty_when_none(mock_looks_like, mock_portal, mock_get): + status, body = _parse_response( + submit_order_handler.lambda_handler(_orders_event(), None) + ) + + assert status == 200 + assert body == {"week": "2026-W36", "orders": []} + + +def test_my_orders_requires_bearer(): + status, body = _parse_response( + submit_order_handler.lambda_handler(_orders_event(token=""), None) + ) + + assert status == 403 + assert body == {"error": "Authentication required"} + + +@patch("submit_order_handler.get_order") +@patch( + "submit_order_handler._verify_portal_token", + return_value={ + "name": "Portal Employee", + "email": "portal.employee@seahavenind.com", + }, +) +@patch("submit_order_handler.looks_like_cognito_token", return_value=True) +def test_my_orders_rejects_malformed_week(mock_looks_like, mock_portal, mock_get): + status, body = _parse_response( + submit_order_handler.lambda_handler( + _orders_event(week="not-a-week"), + None, + ) + ) + + assert status == 400 + mock_get.assert_not_called() + assert "YYYY-WNN" in body["error"] diff --git a/tests/test_terraform_menu_api.py b/tests/test_terraform_menu_api.py index 89eeb29..7682492 100644 --- a/tests/test_terraform_menu_api.py +++ b/tests/test_terraform_menu_api.py @@ -56,3 +56,27 @@ def test_cloudfront_forwards_and_caches_menu_by_origin_for_60_seconds(): assert "max_ttl = 60" in cloudfront assert "min_ttl = 60" in cloudfront assert 'items = ["Origin"]' in cloudfront + + +def test_cloudfront_forwards_portal_api_paths_without_publish_or_roster(): + cloudfront = _read("cloudfront.tf") + locals_tf = _read("locals.tf") + + assert 'route_key = "GET /api/orders/{week}"' in locals_tf + assert 'permission_source = "GET/api/orders/*"' in locals_tf + for pattern in ( + '"/api/form-status/*"', + '"/api/orders/*"', + '"/api/submit-order"', + '"/api/admin/*"', + ): + assert pattern in cloudfront + assert 'path_pattern = "/api/*"' not in cloudfront + assert "/api/publish" not in cloudfront + assert "/api/roster" not in cloudfront + assert "custom_error_response" not in cloudfront + assert 'resource "aws_cloudfront_function" "form_spa_rewrite"' in cloudfront + assert "function_arn = aws_cloudfront_function.form_spa_rewrite.arn" in cloudfront + assert "AllViewerExceptHostHeader" in locals_tf or ( + "b689b0a8-53d0-40ab-baf2-68738e2966ac" in locals_tf + )