prop/test/microwaveprop_web/api/rate_limiter_test.exs
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -05:00

131 lines
3.9 KiB
Elixir

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