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.
This commit is contained in:
Adam Moussa 2026-09-21 14:48:59 -04:00
parent 7c5d44e9cf
commit 5b272f9b4a
No known key found for this signature in database
4 changed files with 51 additions and 3 deletions

View file

@ -6,7 +6,7 @@ import base64
import logging import logging
import shared.sentry_init # noqa: F401 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 from shared.portal_ops import ActionError
logger = logging.getLogger() logger = logging.getLogger()
@ -79,14 +79,14 @@ def handler(event, context):
logger.exception("portal api action error") logger.exception("portal api action error")
return { return {
"statusCode": exc.status, "statusCode": exc.status,
"headers": {"Content-Type": "application/json"}, "headers": cors_headers(_header(event, "origin")),
"body": encode_body({"error": {"code": exc.code, "message": exc.message}}), "body": encode_body({"error": {"code": exc.code, "message": exc.message}}),
} }
except Exception: except Exception:
logger.exception("portal api unexpected failure") logger.exception("portal api unexpected failure")
return { return {
"statusCode": 500, "statusCode": 500,
"headers": {"Content-Type": "application/json"}, "headers": cors_headers(_header(event, "origin")),
"body": encode_body( "body": encode_body(
{"error": {"code": "INTERNAL", "message": "Internal error"}} {"error": {"code": "INTERNAL", "message": "Internal error"}}
), ),

View file

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from urllib.parse import unquote from urllib.parse import unquote
@ -12,6 +13,8 @@ from shared.portal_ops import ActionError, snapshot
from shared.schedule import ShiftSchedule from shared.schedule import ShiftSchedule
from shared import portal_ops as ops from shared import portal_ops as ops
logger = logging.getLogger(__name__)
CORS_ORIGINS = { CORS_ORIGINS = {
"https://internal.seahaven.com", "https://internal.seahaven.com",
"https://internal.dev.seahaven.com", "https://internal.dev.seahaven.com",
@ -210,3 +213,10 @@ def handle(
headers, headers,
{"error": {"code": exc.code, "message": exc.message}}, {"error": {"code": exc.code, "message": exc.message}},
) )
except Exception:
logger.exception("portal http unexpected failure")
return (
500,
headers,
{"error": {"code": "INTERNAL", "message": "Internal error"}},
)

View file

@ -85,3 +85,17 @@ def test_cors_header_for_portal_origin(portalapi_app, schedule, identity):
result["headers"]["Access-Control-Allow-Origin"] result["headers"]["Access-Control-Allow-Origin"]
== "https://internal.seahaven.com" == "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"

View file

@ -50,6 +50,30 @@ def test_portal_missing_bearer_is_401(client):
assert body["error"]["code"] == "UNAUTHORIZED" 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): def test_portal_invalid_token_is_401(client):
with patch( with patch(
"shared.portal_http.verify_cognito_id_token", "shared.portal_http.verify_cognito_id_token",