defmodule ToweropsWeb.Plugs.RateLimitTest do use ToweropsWeb.ConnCase, async: false alias ToweropsWeb.Plugs.RateLimit # Use async: false because we're testing shared rate-limiter state setup do # Enable rate limiting for this test module (disabled by default in test env) Application.put_env(:towerops, :rate_limiting_enabled, true) on_exit(fn -> # Restore default test config (disabled) Application.put_env(:towerops, :rate_limiting_enabled, false) end) :ok end describe "init/1" do test "accepts :auth type" do assert :auth = RateLimit.init(type: :auth) end test "accepts :api type" do assert :api = RateLimit.init(type: :api) end test "defaults to :api type" do assert :api = RateLimit.init([]) end test "raises on invalid type" do assert_raise ArgumentError, ~r/must be :auth, :api, or :admin/, fn -> RateLimit.init(type: :invalid) end end end describe "call/2 with :auth type" do test "allows requests under the limit", %{conn: conn} do # Use a unique IP for this test unique_ip = "10.0.0.#{:rand.uniform(255)}" conn = conn |> put_req_header("x-forwarded-for", unique_ip) |> RateLimit.call(:auth) refute conn.halted end test "blocks requests over the limit", %{conn: conn} do # Use a unique IP for this test unique_ip = "10.1.0.#{:rand.uniform(255)}" # Make 10 allowed requests (auth limit is 10) Enum.each(1..10, fn _ -> conn = build_conn() |> put_req_header("x-forwarded-for", unique_ip) |> RateLimit.call(:auth) refute conn.halted end) # 11th request should be blocked conn = conn |> put_req_header("x-forwarded-for", unique_ip) |> RateLimit.call(:auth) assert conn.halted assert conn.status == 429 assert get_resp_header(conn, "retry-after") != [] body = Jason.decode!(conn.resp_body) assert body["error"] =~ "Too many requests" end end describe "call/2 with :api type" do test "allows requests under the limit", %{conn: conn} do unique_ip = "10.2.0.#{:rand.uniform(255)}" conn = conn |> put_req_header("x-forwarded-for", unique_ip) |> RateLimit.call(:api) refute conn.halted end test "allows higher limit than auth", %{conn: _conn} do unique_ip = "10.3.0.#{:rand.uniform(255)}" # Make 50 requests - should all be allowed (API limit is 100) Enum.each(1..50, fn _ -> conn = build_conn() |> put_req_header("x-forwarded-for", unique_ip) |> RateLimit.call(:api) refute conn.halted end) end end describe "IP extraction" do test "uses X-Forwarded-For header first", %{conn: conn} do unique_ip = "192.168.1.#{:rand.uniform(255)}" # Exhaust the limit for this IP Enum.each(1..10, fn _ -> build_conn() |> put_req_header("x-forwarded-for", unique_ip) |> RateLimit.call(:auth) end) # Different IP should still work different_ip = "192.168.2.#{:rand.uniform(255)}" conn = conn |> put_req_header("x-forwarded-for", different_ip) |> RateLimit.call(:auth) refute conn.halted end test "handles comma-separated X-Forwarded-For", %{conn: conn} do unique_ip = "203.0.113.#{:rand.uniform(255)}" # X-Forwarded-For can have multiple IPs (client, proxy1, proxy2) conn = conn |> put_req_header("x-forwarded-for", "#{unique_ip}, 10.0.0.1, 10.0.0.2") |> RateLimit.call(:auth) refute conn.halted end test "falls back to X-Real-IP", %{conn: conn} do unique_ip = "198.51.100.#{:rand.uniform(255)}" conn = conn |> put_req_header("x-real-ip", unique_ip) |> RateLimit.call(:auth) refute conn.halted end test "falls back to remote_ip when no headers", %{conn: conn} do # No forwarding headers, uses conn.remote_ip (127.0.0.1) # Since other tests might have used this IP, we just verify the plug works # by checking it doesn't crash - actual rate limiting is tested elsewhere result = RateLimit.call(conn, :auth) # Either allowed or blocked is fine - we're testing the IP extraction path assert is_struct(result, Plug.Conn) end end end