- Beacon detail endpoints (LiveView + REST API) now hide unapproved beacons from anonymous and unauthorized viewers; only the submitter and admins can see pending records before approval. Adds Beacons.get_visible_beacon/2 with scope-aware checks. - API contact pagination now honors per_page end-to-end. Radio.list_contacts/1 accepts :per_page and clamps to 200. - API rate limiter: ETS table is now owned by a long-lived Sweeper GenServer (won't die with a request task); Sweeper periodically prunes expired-window rows to bound memory; init_table/0 race is rescued. - /scores/cells and /weather/cells: add per-IP rate limiting and a shared GridBounds clamp/413 guard so global / oversized viewports no longer drive unbounded binary responses. - NEXRAD PNG unfilter (sub/up/average/paeth): replace acc++[byte] + Enum.at(acc, idx-bpp) with O(n) binary recursion. Decode time for the 12200x5400 n0q frame goes from quadratic to linear. - LiveTableFooter.parse_page and ScoresFile.fetch_bound: switch String.to_integer/String.to_float to Integer.parse/Float.parse, fall back to defaults instead of raising. - PathLive and MapLive band-event handlers: replace String.to_integer(params["band"]) with parse_int / parse_band_param so a non-numeric band parameter no longer crashes the LiveView.
132 lines
3.9 KiB
Elixir
132 lines
3.9 KiB
Elixir
defmodule MicrowavepropWeb.Api.RateLimiterTest do
|
|
use ExUnit.Case, async: false
|
|
|
|
import Plug.Conn
|
|
import Plug.Test
|
|
|
|
alias MicrowavepropWeb.Api.RateLimiter
|
|
|
|
setup do
|
|
RateLimiter.reset()
|
|
:ok
|
|
end
|
|
|
|
describe "init/1" do
|
|
test "passes options through unchanged" do
|
|
assert RateLimiter.init(anon_limit: 5) == [anon_limit: 5]
|
|
end
|
|
end
|
|
|
|
describe "call/2 anonymous" do
|
|
test "annotates RateLimit headers and lets requests through under the limit" do
|
|
conn = RateLimiter.call(conn(:get, "/"), anon_limit: 3)
|
|
|
|
refute conn.halted
|
|
assert ["3"] = get_resp_header(conn, "ratelimit-limit")
|
|
assert ["2"] = get_resp_header(conn, "ratelimit-remaining")
|
|
assert [reset] = get_resp_header(conn, "ratelimit-reset")
|
|
assert String.to_integer(reset) >= 1
|
|
end
|
|
|
|
test "halts with 429 when the bucket is exhausted" do
|
|
opts = [anon_limit: 2, window_ms: 60_000]
|
|
|
|
_ = RateLimiter.call(conn(:get, "/"), opts)
|
|
_ = RateLimiter.call(conn(:get, "/"), opts)
|
|
conn = RateLimiter.call(conn(:get, "/"), opts)
|
|
|
|
assert conn.halted
|
|
assert conn.status == 429
|
|
assert get_resp_header(conn, "retry-after") != []
|
|
assert ["0"] = get_resp_header(conn, "ratelimit-remaining")
|
|
end
|
|
end
|
|
|
|
describe "call/2 authenticated" do
|
|
test "uses the auth_limit when current_api_token is assigned" do
|
|
conn =
|
|
:get
|
|
|> conn("/")
|
|
|> assign(:current_api_token, %{id: "tok-1"})
|
|
|> RateLimiter.call(auth_limit: 5, anon_limit: 1)
|
|
|
|
refute conn.halted
|
|
assert ["5"] = get_resp_header(conn, "ratelimit-limit")
|
|
end
|
|
|
|
test "different tokens occupy different buckets" do
|
|
opts = [auth_limit: 1]
|
|
|
|
conn_a =
|
|
:get
|
|
|> conn("/")
|
|
|> assign(:current_api_token, %{id: "A"})
|
|
|> RateLimiter.call(opts)
|
|
|
|
conn_b =
|
|
:get
|
|
|> conn("/")
|
|
|> assign(:current_api_token, %{id: "B"})
|
|
|> RateLimiter.call(opts)
|
|
|
|
refute conn_a.halted
|
|
refute conn_b.halted
|
|
end
|
|
end
|
|
|
|
describe "reset/0" do
|
|
test "clears the table even before any call" do
|
|
assert RateLimiter.reset() == :ok
|
|
end
|
|
end
|
|
|
|
describe "init_table/0" do
|
|
test "is a no-op when the table already exists" do
|
|
RateLimiter.init_table()
|
|
assert RateLimiter.init_table() == :ok
|
|
end
|
|
|
|
test "tolerates many concurrent init_table calls" do
|
|
# `call/2` invokes `init_table/0` on every request; under heavy
|
|
# load many requests can fly through the whereis check before
|
|
# any of them call `:ets.new`. The rescue path makes the loser
|
|
# of that race return `:ok` instead of crashing. The Sweeper
|
|
# owns the canonical table from the application supervisor, so
|
|
# concurrent calls must never leave the system without one.
|
|
tasks =
|
|
for _ <- 1..16 do
|
|
Task.async(fn -> RateLimiter.init_table() end)
|
|
end
|
|
|
|
results = Task.await_many(tasks)
|
|
assert Enum.all?(results, &(&1 == :ok))
|
|
assert :ets.whereis(RateLimiter.table()) != :undefined
|
|
end
|
|
end
|
|
|
|
describe "Sweeper" do
|
|
alias MicrowavepropWeb.Api.RateLimiter.Sweeper
|
|
|
|
test "sweep_now removes expired window rows but keeps current ones" do
|
|
RateLimiter.reset()
|
|
table = RateLimiter.table()
|
|
|
|
now = System.system_time(:millisecond)
|
|
window_ms = RateLimiter.default_window_ms()
|
|
current = div(now, window_ms)
|
|
|
|
:ets.insert(table, {{{:ip, "stale-1"}, current - 5}, 1})
|
|
:ets.insert(table, {{{:ip, "stale-2"}, current - 1}, 4})
|
|
:ets.insert(table, {{{:ip, "current"}, current}, 2})
|
|
|
|
assert :ets.info(table, :size) == 3
|
|
|
|
{:ok, pid} = Sweeper.start_link(name: :rate_limiter_sweeper_test, interval_ms: 60_000_000)
|
|
deleted = Sweeper.sweep_now(pid)
|
|
|
|
assert deleted == 2
|
|
assert :ets.info(table, :size) == 1
|
|
assert :ets.lookup(table, {{:ip, "current"}, current}) != []
|
|
end
|
|
end
|
|
end
|