- 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
221 lines
7.3 KiB
Elixir
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
|