From 5b272f9b4a5a5b2f15a830e748627324ae888d4d Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 21 Sep 2026 14:48:59 -0400 Subject: [PATCH] fix(portal-api): keep CORS headers on unexpected 500s Portal SPA error handling needs Access-Control-Allow-Origin even when DynamoDB or other internals fail, otherwise the browser hides the 500. --- src/portal-api/app.py | 6 +++--- src/shared/shared/portal_http.py | 10 ++++++++++ tests/portal_api/test_handler.py | 14 ++++++++++++++ tests/server/test_app.py | 24 ++++++++++++++++++++++++ 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/portal-api/app.py b/src/portal-api/app.py index b6221c1..6a6095f 100644 --- a/src/portal-api/app.py +++ b/src/portal-api/app.py @@ -6,7 +6,7 @@ import base64 import logging import shared.sentry_init # noqa: F401 -from shared.portal_http import encode_body, handle +from shared.portal_http import cors_headers, encode_body, handle from shared.portal_ops import ActionError logger = logging.getLogger() @@ -79,14 +79,14 @@ def handler(event, context): logger.exception("portal api action error") return { "statusCode": exc.status, - "headers": {"Content-Type": "application/json"}, + "headers": cors_headers(_header(event, "origin")), "body": encode_body({"error": {"code": exc.code, "message": exc.message}}), } except Exception: logger.exception("portal api unexpected failure") return { "statusCode": 500, - "headers": {"Content-Type": "application/json"}, + "headers": cors_headers(_header(event, "origin")), "body": encode_body( {"error": {"code": "INTERNAL", "message": "Internal error"}} ), diff --git a/src/shared/shared/portal_http.py b/src/shared/shared/portal_http.py index d126bdc..1ee7108 100644 --- a/src/shared/shared/portal_http.py +++ b/src/shared/shared/portal_http.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from decimal import Decimal from typing import Any from urllib.parse import unquote @@ -12,6 +13,8 @@ from shared.portal_ops import ActionError, snapshot from shared.schedule import ShiftSchedule from shared import portal_ops as ops +logger = logging.getLogger(__name__) + CORS_ORIGINS = { "https://internal.seahaven.com", "https://internal.dev.seahaven.com", @@ -210,3 +213,10 @@ def handle( headers, {"error": {"code": exc.code, "message": exc.message}}, ) + except Exception: + logger.exception("portal http unexpected failure") + return ( + 500, + headers, + {"error": {"code": "INTERNAL", "message": "Internal error"}}, + ) diff --git a/tests/portal_api/test_handler.py b/tests/portal_api/test_handler.py index bb2f2ef..a751473 100644 --- a/tests/portal_api/test_handler.py +++ b/tests/portal_api/test_handler.py @@ -85,3 +85,17 @@ def test_cors_header_for_portal_origin(portalapi_app, schedule, identity): result["headers"]["Access-Control-Allow-Origin"] == "https://internal.seahaven.com" ) + + +def test_unexpected_failure_keeps_cors(portalapi_app, identity, monkeypatch): + monkeypatch.setattr( + "shared.portal_http.ShiftSchedule", + lambda: (_ for _ in ()).throw(RuntimeError("ddb down")), + ) + result = portalapi_app.handler(_event(origin="https://internal.seahaven.com"), None) + assert result["statusCode"] == 500 + assert ( + result["headers"]["Access-Control-Allow-Origin"] + == "https://internal.seahaven.com" + ) + assert json.loads(result["body"])["error"]["code"] == "INTERNAL" diff --git a/tests/server/test_app.py b/tests/server/test_app.py index 75f4ddf..4976111 100644 --- a/tests/server/test_app.py +++ b/tests/server/test_app.py @@ -50,6 +50,30 @@ def test_portal_missing_bearer_is_401(client): assert body["error"]["code"] == "UNAUTHORIZED" +def test_portal_unexpected_failure_keeps_cors(client): + with patch( + "shared.portal_http.verify_cognito_id_token", + return_value={"name": "Alice", "email": "alice@seahavenind.com"}, + ): + with patch( + "shared.portal_http.ShiftSchedule", + side_effect=RuntimeError("ddb down"), + ): + response = client.get( + "/api/shifts", + headers={ + "Authorization": "Bearer token", + "Origin": "https://internal.seahaven.com", + }, + ) + assert response.status_code == 500 + assert ( + response.headers["Access-Control-Allow-Origin"] + == "https://internal.seahaven.com" + ) + assert response.get_json()["error"]["code"] == "INTERNAL" + + def test_portal_invalid_token_is_401(client): with patch( "shared.portal_http.verify_cognito_id_token",