diff --git a/lib/microwaveprop_web/grid_bounds.ex b/lib/microwaveprop_web/grid_bounds.ex index b809a8c9..b6fd215d 100644 --- a/lib/microwaveprop_web/grid_bounds.ex +++ b/lib/microwaveprop_web/grid_bounds.ex @@ -22,11 +22,22 @@ defmodule MicrowavepropWeb.GridBounds do @lon_min -150.0 @lon_max -50.0 - # Comfortably above the HRDPS+HRRR union (~3150 sq deg), but tight - # enough that the fully-clamped extent (~4500 sq deg) still trips - # the cap — a "give me everything" request is exactly the DoS - # shape we want to reject. - @max_viewport_area_sq_deg 4000.0 + # Sits just above the largest area the clamp above can produce + # (45° lat × 100° lon = 4500 sq deg), so a fully zoomed-out client + # gets the whole grid rather than a 413. + # + # The clamp — not this cap — is what bounds the work: after clamping, + # no request can ask for more than the full grid (~92k cells, ~1.4 MB), + # which the pipeline already materializes once per valid_time and + # serves from cache. An earlier 4000.0 was tuned to reject the + # full-extent request as a DoS shape, but that is also exactly what + # the map requests at its own `minZoom: 4`, so it silently blanked + # the overlay whenever a user zoomed all the way out. + # + # Kept as defense-in-depth: if the clamp window is ever widened (e.g. + # extending coverage to Alaska), this catches the resulting blow-up + # instead of letting response size grow unbounded. + @max_viewport_area_sq_deg 4600.0 @type bounds :: %{optional(String.t()) => float()} | nil diff --git a/test/microwaveprop_web/controllers/scores_controller_test.exs b/test/microwaveprop_web/controllers/scores_controller_test.exs index 1a9981a7..af97da7f 100644 --- a/test/microwaveprop_web/controllers/scores_controller_test.exs +++ b/test/microwaveprop_web/controllers/scores_controller_test.exs @@ -101,10 +101,16 @@ defmodule MicrowavepropWeb.ScoresControllerTest do assert json_response(conn, 400) == %{"error" => "invalid params"} end - test "returns 413 for global / oversized viewport bounds", %{conn: conn} do + # Mirrors /weather/cells: a fully zoomed-out viewport clamps to the + # supported extent and is served rather than rejected. + test "serves global / oversized viewport bounds by clamping them", %{conn: conn} do + valid_time = ~U[2026-04-28 12:00:00Z] + ScoresFile.write!(10_000, valid_time, [%{lat: 33.0, lon: -97.0, score: 75}]) + conn = get(conn, ~p"/scores/cells?band=10000&south=-90&north=90&west=-180&east=180") - body = json_response(conn, 413) - assert body["error"] == "viewport_too_large" + + assert conn.status == 200 + assert <<"PSCR", 1::8, _reserved::24, 1::little-32, _rest::binary>> = conn.resp_body end end end diff --git a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs index c1dbefeb..b0dd59df 100644 --- a/test/microwaveprop_web/controllers/weather_tile_controller_test.exs +++ b/test/microwaveprop_web/controllers/weather_tile_controller_test.exs @@ -169,7 +169,10 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do File.rm_rf!(hrdps_dir) end - test "returns 413 for global / oversized viewport bounds", %{conn: conn} do + # A fully zoomed-out map (minZoom: 4) sends bounds well past the grid + # extent. Those clamp down to the supported region and are served — + # rejecting them left the overlay blank with no feedback. + test "serves global / oversized viewport bounds by clamping them", %{conn: conn} do time = DateTime.to_iso8601(~U[2026-04-28 12:00:00Z]) conn = @@ -178,8 +181,8 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do ~p"/weather/cells?time=#{time}&south=-90&north=90&west=-180&east=180" ) - body = json_response(conn, 413) - assert body["error"] == "viewport_too_large" + assert conn.status == 200 + assert <<"WCEL", 1::8, _rest::binary>> = conn.resp_body end end end diff --git a/test/microwaveprop_web/grid_bounds_test.exs b/test/microwaveprop_web/grid_bounds_test.exs index ba922a07..5b7c6430 100644 --- a/test/microwaveprop_web/grid_bounds_test.exs +++ b/test/microwaveprop_web/grid_bounds_test.exs @@ -15,12 +15,25 @@ defmodule MicrowavepropWeb.GridBoundsTest do test "clamps south/north/west/east to the supported extent" do bounds = %{"south" => -50.0, "north" => 90.0, "west" => -200.0, "east" => 50.0} - assert {:error, :viewport_too_large} = GridBounds.clamp(bounds) + + assert {:ok, %{"south" => 20.0, "north" => 65.0, "west" => -150.0, "east" => -50.0}} = + GridBounds.clamp(bounds) end - test "rejects global bounds with 413-equivalent error" do + # The map's own `minZoom: 4` produces a viewport that clamps to the + # full extent. Rejecting it blanked the overlay whenever a user + # zoomed all the way out, so the clamped full grid must be served. + test "serves global bounds by clamping them to the supported extent" do global = %{"south" => -90.0, "north" => 90.0, "west" => -180.0, "east" => 180.0} - assert GridBounds.clamp(global) == {:error, :viewport_too_large} + + assert {:ok, %{"south" => 20.0, "north" => 65.0, "west" => -150.0, "east" => -50.0}} = + GridBounds.clamp(global) + end + + # The cap is defense-in-depth against the clamp window being widened + # later; nothing the clamp can emit today exceeds it. + test "the clamped extent stays inside the area cap" do + assert (65.0 - 20.0) * (-50.0 - -150.0) <= GridBounds.max_viewport_area_sq_deg() end test "rejects inverted bounds" do