defmodule PlugLimit.Test do @moduledoc """ Conveniences for testing endpoints protected with PlugLimit rate limiters. Unit testing endpoints protected with rate limiters might provide unexpected results because after exceeding requests limit during consecutive tests given endpoint will respond with `429` status code. `PlugLimit.Test` can help to avoid unexpected `429` responses by adding following code to your test cases: ```elixir use PlugLimit.Test, redis_flushdb?: true ``` Using this module with `:redis_flushdb?` set to `true` will flush Redis database with `redis_flushdb/1` function executed inside `ExUnit.Callbacks.setup/1`. Required by `redis_flushdb/1` Redis command function is taken from `:plug_limit` `:cmd` key defined for testing environment, for example: ```elixir # config/test.exs config :plug_limit, cmd: {MyApp.Redis, :command, []} ``` Please refer to `PlugLimit` module documentation for detailed `:cmd` setting description. `PlugLimit.Test` has one configuration setting `:redis_flushdb?`, set to `false` by default. When using `:redis_flushdb?` you might consider setting separate Redis database (e.g.: `redis://localhost:6379/15`) for PlugLimit to avoid interference with other parts of your application during testing. Alternatively to `:redis_flushdb?` you can use `redis_del_keys/2`. """ use ExUnit.CaseTemplate using opts do quote bind_quoted: [opts: opts] do @redis_flushdb Keyword.get(opts, :redis_flushdb?, false) setup do if @redis_flushdb, do: :plug_limit |> Application.fetch_env!(:cmd) |> PlugLimit.Test.redis_flushdb(), else: :ok end end end @doc """ Gets `x-ratelimit-remaining` header from `conn` and parses it to integer. Returns `x-ratelimit-remaining` header as an integer or string message if invalid or missing header. Example: ```elixir test "x-ratelimit-remaining is correct", %{conn: conn} do conn = get(conn, "/") assert PlugLimit.Test.get_remaining(conn) == 60 end ``` ```elixir iex> PlugLimit.Test.get_remaining(conn_invalid_header) "Invalid, non-standard or missing x-ratelimit-remaining header." ``` """ @spec get_remaining(conn :: Plug.Conn.t()) :: integer() | String.t() def get_remaining(conn), do: hdr_to_integer(conn, "x-ratelimit-remaining") @doc """ Same as `get_remaining/1` but for `x-ratelimit-reset` header. """ @spec get_reset(conn :: Plug.Conn.t()) :: integer() def get_reset(conn), do: hdr_to_integer(conn, "x-ratelimit-reset") @doc """ Checks for IETF recommended `x-ratelimit-*` http response headers in `conn`. Checks if basic rate limiting http response headers complying with ["RateLimit Fields for HTTP"](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/) IETF specification are added to `Plug.Conn.t()`. Required rate limiting http response headers: * `x-ratelimit-limit`, * `x-ratelimit-remaining`, * `x-ratelimit-reset`. Returns `true` if above headers are present and `false` otherwise. Example: ```elixir test "it is protected by rate limiter", %{conn: conn} do conn = get(conn, "/") assert PlugLimit.Test.headers_exist?(conn) end ``` """ @spec headers_exist?(conn :: Plug.Conn.t()) :: boolean() def headers_exist?(conn) do ["x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset"] |> Enum.map(fn hdr -> conn |> Plug.Conn.get_resp_header(hdr) |> Enum.empty?() |> Kernel.not() end) |> Enum.reduce(true, fn v, acc -> acc && v end) end @redis_del_keys_script """ return redis.call('DEL', 'default:not:existing:key:*', unpack(redis.call('KEYS', ARGV[1]))) """ @doc """ Deletes all keys with the names matching pattern given by `key` from the Redis DB selected with `{m, f, a}` Redis command function. `{m, f, a}` usually should be consistent with a value given in `:plug_limit` `:cmd` configuration key. Returns `:ok` on success or `any()` on error. Function should not be used to delete large numbers of Redis keys. Example: ```elixir setup do :ok = PlugLimit.Test.redis_del_keys({MyApp.Redis, :command, []}, "my_key:*") end ``` """ @spec redis_del_keys({m :: module(), f :: atom(), [a :: any()]}, key :: String.t()) :: :ok | any() def redis_del_keys({m, f, a} = _redis_command_function, key) do case apply(m, f, [["EVAL", @redis_del_keys_script, "0", [key]]] ++ a) do {:ok, _key_count} -> :ok err -> err end end @doc """ Deletes all keys from the Redis DB selected with `{m, f, a}` Redis command function. `{m, f, a}` usually should be consistent with a value given in `:plug_limit` `:cmd` configuration key. Returns `:ok` on success or `any()` on error. Redis command which is used to delete keys: [`FLUSHDB SYNC`](https://redis.io/commands/flushdb/). Example: ```elixir iex> PlugLimit.Test.redis_flushdb({MyApp.Redis, :command, []}) :ok ``` """ @spec redis_flushdb({m :: module(), f :: atom(), [a :: any()]}) :: :ok | any() def redis_flushdb({m, f, a} = _redis_command_function) do case apply(m, f, [["FLUSHDB"]] ++ a) do {:ok, "OK"} -> :ok err -> err end end defp hdr_to_integer(conn, hdr) do with [hdr] <- Plug.Conn.get_resp_header(conn, hdr), {hdr, ""} <- Integer.parse(hdr) do hdr else _ -> "Invalid, non-standard or missing #{hdr} header." end end end