defmodule MicrowavepropWeb.Api.RateLimiterTest do use ExUnit.Case, async: false import Plug.Conn import Plug.Test alias MicrowavepropWeb.Api.RateLimiter alias MicrowavepropWeb.Api.RateLimiter.Sweeper 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 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