From ca589545a648211a22065cd55d93afe09f5793f3 Mon Sep 17 00:00:00 2001 From: Emi Griffith Date: Fri, 24 Jul 2026 15:40:07 -0700 Subject: [PATCH] Return 422 for malformed date query param in grade/day endpoints api_grade and api_day parsed the `date` query param with an unguarded datetime.date.fromisoformat, so a malformed or non-calendar value (notadate, 2026-13-40, 2026-02-30) raised ValueError and surfaced as a 500. Route the parse through a _parse_target_date helper that maps the ValueError to HTTPException(422); absent/empty and valid dates behave exactly as before. Add a route-level test asserting 422 on bad dates. --- backend/tests/web/test_api.py | 12 ++++++++++++ backend/web/app.py | 18 ++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/tests/web/test_api.py b/backend/tests/web/test_api.py index f6893fa..1048001 100644 --- a/backend/tests/web/test_api.py +++ b/backend/tests/web/test_api.py @@ -71,6 +71,18 @@ def test_api_version_reports_backend_contract(client): assert body["backend_version"] == "2" +def test_malformed_date_is_rejected(client): + # A malformed or non-calendar date must be a 422, not a 500: fromisoformat + # raises ValueError on these and the handler used to leak it as a crash. + for bad in ("notadate", "2026-13-40", "2026-02-30"): + for route in ("grade", "day"): + r = client.get(f"/thermograph/api/v2/{route}", params={**Q, "date": bad}) + assert r.status_code == 422, (route, bad, r.status_code) + # A valid date still works. + assert client.get("/thermograph/api/v2/grade", + params={**Q, "date": "2026-07-11"}).status_code == 200 + + def test_day_detail_and_ladders(client, history): r = client.get("/thermograph/api/v2/day", params=Q) assert r.status_code == 200 diff --git a/backend/web/app.py b/backend/web/app.py index b8a5d35..5949033 100644 --- a/backend/web/app.py +++ b/backend/web/app.py @@ -79,6 +79,20 @@ def _weather_fetch_error(e) -> HTTPException: return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}") +def _parse_target_date(date: str | None, default: datetime.date) -> datetime.date: + """Parse the ``date`` query param (YYYY-MM-DD) into a date, falling back to + ``default`` when it's absent. A malformed or non-calendar date (e.g. 2026-13-40) + is a client error, not a crash: surface it as a 422 rather than let + fromisoformat's ValueError become a 500.""" + if not date: + return default + try: + return datetime.date.fromisoformat(date[:10]) + except ValueError: + raise HTTPException(status_code=422, + detail=f"invalid date: {date!r} (expected YYYY-MM-DD)") + + # --- background neighbor warming --------------------------------------------- # /cell?neighbors=1 asks the server to warm the 8 grid cells around the request # (the frontend used to fire 8 staggered prefetch requests, mirroring grid.py's @@ -492,7 +506,7 @@ def api_grade( description="days to grade after the target (observed, or forecast when future; " "the forecast reaches ~7 days out so future days cap there)"), ): - target = datetime.date.fromisoformat(date[:10]) if date else datetime.date.today() + target = _parse_target_date(date, datetime.date.today()) cell = grid.snap(lat, lon) with audit.RunAudit( @@ -580,7 +594,7 @@ def api_day( ) as run: history, cache_meta, _ = _fetch_history(run, cell) last = history["date"].max() - target = datetime.date.fromisoformat(date[:10]) if date else last + target = _parse_target_date(date, last) run.set(target_date=target.isoformat()) def build(place):