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 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"}}
),

View file

@ -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"}},
)

View file

@ -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"

View file

@ -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",