towerops/test/towerops_web/plugs/rate_limit_test.exs
Graham McIntire 8f52d87854
feat: add rate limiting to admin endpoints (100 req/min)
- Add :admin type to RateLimit plug with 100 requests per minute per IP
- Apply rate limiting to all /admin routes (LiveView and controller actions)
- Protects superuser endpoints from abuse and brute force attempts
- Aligns with existing auth (10 req/min) and API (1000 req/min) limits
2026-03-05 14:11:19 -06:00

163 lines
4.3 KiB
Elixir

defmodule ToweropsWeb.Plugs.RateLimitTest do
use ToweropsWeb.ConnCase, async: false
alias ToweropsWeb.Plugs.RateLimit
# Use async: false because we're testing Hammer 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