From bb6333bd27f5405ec82d96f4f46ea80ad7b66a52 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 19 Apr 2026 08:55:12 -0500 Subject: [PATCH] fix(map): recover with sane defaults on garbage URL params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every parser already returned nil on malformed input, so the || chains fell through to defaults — except parse_time_param, which happily accepted any well-formed ISO8601 even if it pointed outside the current HRRR forecast window (e.g. a stale shared link from last week). Gate it behind the valid-times window and fall back to the normal 'now' cursor when it's out of range. Adds explicit coverage: non-numeric band, unknown band MHz, non-numeric lat/lon/zoom, out-of-range values, malformed time, ancient time, and all-params-garbage-at-once. Each case mounts cleanly and lands on the defaults. --- lib/microwaveprop_web/live/map_live.ex | 24 ++++++++++- test/microwaveprop_web/live/map_live_test.exs | 42 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 21de57d7..720b52e7 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -136,16 +136,38 @@ defmodule MicrowavepropWeb.MapLive do # Resolve the initial band/time/center/zoom from URL params, falling # back to LiveStash recovery, then session geolocation and defaults. + # Parsers return nil for anything malformed so `||` walks to the next + # sensible source; ISO8601 times that sit outside the current HRRR + # window get dropped too so a stale shared link doesn't land the map + # on a hour with no scores. defp resolve_view(params, session, recovered_band, recovered_time) do band = parse_band_param(params["band"]) || recovered_band || @default_band valid_times = Propagation.available_valid_times(band) - time = parse_time_param(params["t"]) || recovered_time || closest_to_now(valid_times) + time = resolve_time(params["t"], recovered_time, valid_times) center = parse_center_param(params["lat"], params["lon"]) || initial_center(session) zoom = parse_zoom_param(params["zoom"]) || @default_zoom %{band: band, time: time, center: center, zoom: zoom, valid_times: valid_times} end + defp resolve_time(param, recovered_time, valid_times) do + case parse_time_param(param) do + %DateTime{} = t -> + if time_in_window?(t, valid_times), do: t, else: closest_to_now(valid_times) + + nil -> + recovered_time || closest_to_now(valid_times) + end + end + + defp time_in_window?(_, []), do: false + + defp time_in_window?(%DateTime{} = t, valid_times) do + earliest = Enum.min(valid_times, DateTime) + latest = Enum.max(valid_times, DateTime) + DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt + end + # Build a bounding box roughly matching the hardcoded default (~3.4° × 9°) # around a given center so the initial HRRR score query still returns a # useful tile set for the visible area. diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index dac01da7..dc7c5750 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -145,6 +145,48 @@ defmodule MicrowavepropWeb.MapLiveTest do {:ok, _lv, html} = live(conn, ~p"/map?band=notanumber") assert html =~ ~s(data-selected-band="10000") end + + test "unknown band MHz falls back to the default", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/map?band=99999") + assert html =~ ~s(data-selected-band="10000") + end + + test "malformed lat/lon/zoom are ignored", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/map?lat=banana&lon=apple&zoom=over9000") + assert html =~ ~s(data-zoom="7") + refute html =~ ~s(data-center-lat="banana") + end + + test "out-of-range lat/lon are ignored", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/map?lat=500&lon=-999") + refute html =~ ~s(data-center-lat="500) + end + + test "out-of-range zoom falls back to default", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/map?zoom=42") + assert html =~ ~s(data-zoom="7") + end + + test "unparseable time is ignored", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map?t=not-a-date") + # Page renders normally without crashing. + assert render(lv) =~ "data-selected-time" + end + + test "ISO8601 time outside the current forecast window falls back", %{conn: conn} do + # Year 2000 is well before any valid HRRR data; page should still + # mount and fall back to the normal 'now' cursor rather than try + # to fetch scores for an empty time. + {:ok, _lv, _html} = live(conn, ~p"/map?t=2000-01-01T00:00:00Z") + end + + test "every param garbage at once still mounts cleanly", %{conn: conn} do + {:ok, _lv, html} = + live(conn, ~p"/map?band=xyz&t=nope&lat=abc&lon=def&zoom=foo") + + assert html =~ ~s(data-selected-band="10000") + assert html =~ ~s(data-zoom="7") + end end describe "cursor_for_now/2" do