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