Adds ~800 new tests (incl. 27 StreamData properties) across 22 modules that previously had little or no coverage. Highlights: - Pure utility modules (LogSanitizer, PacketFieldWhitelist, PacketSanitizer, DeviceParser, CoordinateUtils, BoundsUtils, ParamUtils, Convert, WeatherUnits) get thorough unit + property coverage including UTF-8 truncation boundaries, antimeridian longitude wrap, Haversine symmetry, and unit-conversion inverses. - JSON view modules (CallsignJSON, ErrorJSON, ChangesetJSON) verified for envelope shape and error templating. - Plugs (HealthCheck, RateLimiter, ApiCSRF) exercised for happy-path and halt paths. - GenServer modules (RegexCache, CleanupScheduler, DeploymentNotifier) tested without touching the supervised singleton where possible. - Drops the orphaned map_live/map_helpers_test.exs whose module name collided with the new live/shared/coordinate_utils_test.exs.
155 lines
5.2 KiB
Elixir
155 lines
5.2 KiB
Elixir
defmodule AprsmeWeb.Plugs.RateLimiterTest do
|
|
use AprsmeWeb.ConnCase, async: true
|
|
|
|
alias AprsmeWeb.Plugs.RateLimiter
|
|
|
|
# Use a unique key per test to avoid cross-test interference with the
|
|
# process-wide ETS rate-limit table. Each test gets a string key that
|
|
# hasn't been seen before.
|
|
defp unique_key, do: "test-#{System.unique_integer([:positive])}"
|
|
|
|
describe "init/1" do
|
|
test "applies default options" do
|
|
opts = RateLimiter.init([])
|
|
assert opts[:scale] == 60_000
|
|
assert opts[:limit] == 100
|
|
assert opts[:key] == :ip
|
|
assert opts[:error_message] == "Too many requests"
|
|
end
|
|
|
|
test "user options override defaults" do
|
|
opts = RateLimiter.init(limit: 5, scale: 1_000, error_message: "nope")
|
|
assert opts[:limit] == 5
|
|
assert opts[:scale] == 1_000
|
|
assert opts[:error_message] == "nope"
|
|
# Unspecified options retain their defaults
|
|
assert opts[:key] == :ip
|
|
end
|
|
end
|
|
|
|
describe "call/2 with :ip key" do
|
|
test "allows requests under the limit", %{conn: conn} do
|
|
opts = RateLimiter.init(key: unique_key(), limit: 5, scale: 60_000)
|
|
result = RateLimiter.call(conn, opts)
|
|
refute result.halted
|
|
end
|
|
|
|
test "denies once the limit is exceeded", %{conn: conn} do
|
|
opts = RateLimiter.init(key: unique_key(), limit: 2, scale: 60_000)
|
|
|
|
# Two under-limit calls succeed
|
|
assert refute_halted(RateLimiter.call(conn, opts))
|
|
assert refute_halted(RateLimiter.call(conn, opts))
|
|
|
|
# Third call exceeds the limit and halts with a 429
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
assert result.status == 429
|
|
assert result.resp_body =~ "Too many requests"
|
|
end
|
|
|
|
test "uses the configured error_message in the body", %{conn: conn} do
|
|
opts =
|
|
RateLimiter.init(
|
|
key: unique_key(),
|
|
limit: 1,
|
|
error_message: "Custom limit message"
|
|
)
|
|
|
|
RateLimiter.call(conn, opts)
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
body = Jason.decode!(result.resp_body)
|
|
assert body["error"] == "Custom limit message"
|
|
end
|
|
end
|
|
|
|
describe "call/2 with Cloudflare/X-Forwarded-For/X-Real-IP headers" do
|
|
test "honors CF-Connecting-IP header", %{conn: conn} do
|
|
key_name = unique_key()
|
|
conn = Plug.Conn.put_req_header(conn, "cf-connecting-ip", "203.0.113.5")
|
|
opts = RateLimiter.init(limit: 1, key: :ip, error_message: "m")
|
|
# Prefix the key with a known distinct IP to avoid cross-test interference
|
|
_ = run_once(conn, opts)
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
# Second unique-IP request should be allowed
|
|
other = Plug.Conn.put_req_header(conn, "cf-connecting-ip", "203.0.113.99-#{key_name}")
|
|
result2 = RateLimiter.call(other, opts)
|
|
refute result2.halted
|
|
end
|
|
|
|
test "splits X-Forwarded-For on comma", %{conn: conn} do
|
|
conn = Plug.Conn.put_req_header(conn, "x-forwarded-for", "198.51.100.1, 10.0.0.1")
|
|
opts = RateLimiter.init(limit: 1, key: :ip)
|
|
run_once(conn, opts)
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
end
|
|
|
|
test "honors X-Real-IP when other headers absent", %{conn: conn} do
|
|
conn = Plug.Conn.put_req_header(conn, "x-real-ip", "192.0.2.1")
|
|
opts = RateLimiter.init(limit: 1, key: :ip)
|
|
run_once(conn, opts)
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
end
|
|
|
|
test "falls back to remote_ip when no headers present", %{conn: conn} do
|
|
# Bare build_conn has {127, 0, 0, 1} remote_ip; just make sure call doesn't crash.
|
|
opts = RateLimiter.init(limit: 100, key: :ip)
|
|
refute_halted(RateLimiter.call(conn, opts))
|
|
end
|
|
end
|
|
|
|
describe "call/2 with :user_agent key" do
|
|
test "uses the user-agent header as the key", %{conn: conn} do
|
|
opts = RateLimiter.init(limit: 1, key: :user_agent)
|
|
conn = Plug.Conn.put_req_header(conn, "user-agent", "TestBot/#{unique_key()}")
|
|
run_once(conn, opts)
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
end
|
|
|
|
test "uses 'unknown' key when no user-agent header", %{conn: _conn} do
|
|
# Run this test with a fresh conn that has no user-agent. Only make sure
|
|
# the code path doesn't crash and eventually halts after hitting the limit.
|
|
conn = Phoenix.ConnTest.build_conn()
|
|
opts = RateLimiter.init(limit: 1_000_000, key: :user_agent)
|
|
refute_halted(RateLimiter.call(conn, opts))
|
|
end
|
|
end
|
|
|
|
describe "call/2 with function key" do
|
|
test "invokes the function to derive the key", %{conn: conn} do
|
|
unique = unique_key()
|
|
fun = fn _conn -> unique end
|
|
opts = RateLimiter.init(limit: 1, key: fun)
|
|
run_once(conn, opts)
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
end
|
|
end
|
|
|
|
describe "call/2 with binary key" do
|
|
test "uses the string directly as the key", %{conn: conn} do
|
|
opts = RateLimiter.init(limit: 1, key: unique_key())
|
|
run_once(conn, opts)
|
|
result = RateLimiter.call(conn, opts)
|
|
assert result.halted
|
|
end
|
|
end
|
|
|
|
# Helpers
|
|
|
|
defp run_once(conn, opts) do
|
|
result = RateLimiter.call(conn, opts)
|
|
refute result.halted
|
|
result
|
|
end
|
|
|
|
defp refute_halted(conn) do
|
|
refute conn.halted
|
|
conn
|
|
end
|
|
end
|