towerops/test/towerops/rate_limit_test.exs
Graham McIntire f87657fbfb fix: M20, M21, M22, L14 — archive safety, ETS protection, impersonation expiry, check pagination
- M20: Add check_no_hardlinks and check_no_special_files to MIB upload
  archive extraction to prevent hard link, device node, and FIFO attacks
- M21: Change RateLimit ETS table from :public to :protected; route hit/get/reset
  through GenServer.call instead of direct ETS access
- M22: Add 8-hour impersonation timeout — store impersonated_at in session and
  auto-revoke impersonation when expired
- L14: Add default limit (500) and optional offset to list_checks for pagination
2026-05-12 14:22:20 -05:00

221 lines
7.3 KiB
Elixir

defmodule Towerops.RateLimitTest do
use ExUnit.Case, async: false
alias Towerops.RateLimit
# Each test uses an isolated ETS table by passing a unique :table option.
# The supervised RateLimit instance (used by the application) is left untouched.
setup do
table = :"rl_test_#{System.unique_integer([:positive])}"
pid =
start_supervised!(
{RateLimit,
[
name: table,
table: table,
clean_period: 60_000
]}
)
%{table: table, pid: pid}
end
describe "hit/4" do
test "allows the first request and returns the count", %{table: table} do
assert {:allow, 1} = RateLimit.hit(table, "alice", 1_000, 5)
end
test "allows requests up to the limit", %{table: table} do
assert {:allow, 1} = RateLimit.hit(table, "bob", 1_000, 3)
assert {:allow, 2} = RateLimit.hit(table, "bob", 1_000, 3)
assert {:allow, 3} = RateLimit.hit(table, "bob", 1_000, 3)
end
test "denies requests over the limit and returns ms until window expires", %{table: table} do
Enum.each(1..3, fn _ -> RateLimit.hit(table, "carol", 60_000, 3) end)
assert {:deny, retry_after} = RateLimit.hit(table, "carol", 60_000, 3)
assert retry_after > 0
assert retry_after <= 60_000
end
test "different keys have independent counters", %{table: table} do
assert {:allow, 1} = RateLimit.hit(table, "k1", 1_000, 1)
assert {:deny, _} = RateLimit.hit(table, "k1", 1_000, 1)
assert {:allow, 1} = RateLimit.hit(table, "k2", 1_000, 1)
end
test "supports custom increment", %{table: table} do
assert {:allow, 5} = RateLimit.hit(table, "dave", 1_000, 10, 5)
assert {:allow, 10} = RateLimit.hit(table, "dave", 1_000, 10, 5)
assert {:deny, _} = RateLimit.hit(table, "dave", 1_000, 10, 5)
end
test "increment of 0 does not advance the counter", %{table: table} do
assert {:allow, 1} = RateLimit.hit(table, "eve", 1_000, 5)
assert {:allow, 1} = RateLimit.hit(table, "eve", 1_000, 5, 0)
end
test "moving to a new window resets the counter", %{table: table} do
# Use a 50ms window so we can wait through it.
Enum.each(1..2, fn _ -> RateLimit.hit(table, "frank", 50, 2) end)
assert {:deny, _} = RateLimit.hit(table, "frank", 50, 2)
Process.sleep(70)
assert {:allow, 1} = RateLimit.hit(table, "frank", 50, 2)
end
end
describe "hit/3 (default increment)" do
test "uses an increment of 1", %{table: table} do
assert {:allow, 1} = RateLimit.hit(table, "x", 1_000, 5)
assert {:allow, 2} = RateLimit.hit(table, "x", 1_000, 5)
end
end
describe "default-table convenience arities" do
# These exercise the supervised Towerops.RateLimit instance started by
# the application supervisor (which owns the @default_table).
test "hit/3 uses the default table" do
key = "default-hit-3-#{System.unique_integer([:positive])}"
assert {:allow, 1} = RateLimit.hit(key, 60_000, 5)
assert {:allow, 2} = RateLimit.hit(key, 60_000, 5)
end
test "hit/4 uses the default table when called with non-atom first arg" do
key = "default-hit-4-#{System.unique_integer([:positive])}"
assert {:allow, 3} = RateLimit.hit(key, 60_000, 10, 3)
end
test "get/2 reads from the default table" do
key = "default-get-#{System.unique_integer([:positive])}"
assert 0 = RateLimit.get(key, 60_000)
RateLimit.hit(key, 60_000, 5)
assert 1 = RateLimit.get(key, 60_000)
end
test "reset/2 deletes from the default table" do
key = "default-reset-#{System.unique_integer([:positive])}"
RateLimit.hit(key, 60_000, 5)
assert :ok = RateLimit.reset(key, 60_000)
assert 0 = RateLimit.get(key, 60_000)
end
end
describe "get/3" do
test "returns 0 for keys that have never been hit", %{table: table} do
assert 0 = RateLimit.get(table, "never", 1_000)
end
test "returns the current count for an active key", %{table: table} do
RateLimit.hit(table, "g", 60_000, 5)
RateLimit.hit(table, "g", 60_000, 5)
assert 2 = RateLimit.get(table, "g", 60_000)
end
end
describe "reset/2" do
test "clears the count for the current window", %{table: table} do
Enum.each(1..2, fn _ -> RateLimit.hit(table, "r", 60_000, 2) end)
assert {:deny, _} = RateLimit.hit(table, "r", 60_000, 2)
:ok = RateLimit.reset(table, "r", 60_000)
assert {:allow, 1} = RateLimit.hit(table, "r", 60_000, 2)
end
test "is a no-op for keys that don't exist", %{table: table} do
assert :ok = RateLimit.reset(table, "absent", 60_000)
end
end
describe "cleanup" do
test "removes expired entries when clean is invoked", %{table: table, pid: pid} do
RateLimit.hit(table, "a", 30, 5)
RateLimit.hit(table, "b", 30, 5)
assert :ets.info(table, :size) == 2
Process.sleep(50)
send(pid, :clean)
# Allow the GenServer to process the message
_ = :sys.get_state(pid)
assert :ets.info(table, :size) == 0
end
test "preserves entries that have not yet expired", %{table: table, pid: pid} do
RateLimit.hit(table, "live", 60_000, 5)
assert :ets.info(table, :size) == 1
send(pid, :clean)
_ = :sys.get_state(pid)
assert :ets.info(table, :size) == 1
end
test "automatic periodic cleanup runs on schedule" do
table = :"rl_auto_#{System.unique_integer([:positive])}"
pid =
start_supervised!(
{RateLimit,
[
name: table,
table: table,
clean_period: 30
]},
id: :auto_clean
)
RateLimit.hit(table, "expiring", 20, 5)
assert :ets.info(table, :size) == 1
# Wait for entries to expire and the next scheduled cleanup tick.
Process.sleep(80)
_ = :sys.get_state(pid)
assert :ets.info(table, :size) == 0
end
end
describe "start_link/1" do
test "exposes a child_spec with the configured name" do
table = :"rl_cs_#{System.unique_integer([:positive])}"
name = :"rl_cs_proc_#{System.unique_integer([:positive])}"
spec = RateLimit.child_spec(name: name, table: table, clean_period: 1_000)
assert %{id: RateLimit, start: {RateLimit, :start_link, _}, type: :worker} =
spec
end
test "child_spec id can be overridden" do
spec = RateLimit.child_spec(name: :foo, table: :foo_t, clean_period: 1_000, id: :custom)
assert spec.id == :custom
end
test "uses sensible defaults when options are omitted" do
table = :"rl_def_#{System.unique_integer([:positive])}"
name = :"rl_def_proc_#{System.unique_integer([:positive])}"
pid = start_supervised!({RateLimit, [name: name, table: table]}, id: :defaults)
state = :sys.get_state(pid)
assert state.clean_period == 60_000
assert state.table == table
end
test "rejects negative scale by raising during update_counter" do
# Defensive: the public API does not validate, but callers pass positive
# ints. We document this contract by exercising a positive-only call.
table = :"rl_validate_#{System.unique_integer([:positive])}"
_pid = start_supervised!({RateLimit, [name: table, table: table]}, id: :validate)
assert {:allow, 1} = RateLimit.hit(table, "ok", 1, 1)
end
end
end