diff --git a/copi.owasp.org/lib/copi/rate_limiter.ex b/copi.owasp.org/lib/copi/rate_limiter.ex index c12834520..074e725ad 100644 --- a/copi.owasp.org/lib/copi/rate_limiter.ex +++ b/copi.owasp.org/lib/copi/rate_limiter.ex @@ -6,6 +6,7 @@ defmodule Copi.RateLimiter do - Game creation - Player creation - WebSocket connections + - API actions Rate limits are configured via environment variables and automatically clean up expired entries. """ @@ -30,21 +31,22 @@ defmodule Copi.RateLimiter do ## Parameters - ip: IP address as a tuple (e.g., {127, 0, 0, 1}) or string - - action: atom representing the action (:game_creation, :player_creation, :connection) + - action: atom representing the action (:game_creation, :player_creation, :connection, :api_action) ## Examples iex> Copi.RateLimiter.check_rate("127.0.0.1", :game_creation) {:ok, 9} - iex> Copi.RateLimiter.check_rate({127, 0, 0, 1}, :connection) - {:error, :rate_limit_exceeded} + iex> Copi.RateLimiter.check_rate({127, 0, 0, 1}, :api_action) + {:ok, 9} """ - def check_rate(ip, action) when action in [:game_creation, :player_creation, :connection] do + def check_rate(ip, action) when action in [:game_creation, :player_creation, :connection, :api_action] do normalized_ip = normalize_ip(ip) # In production, don't rate limit localhost to prevent DoS'ing ourselves + # Only bypass rate limiting if the actual connection IP is loopback, not X-Forwarded-For Logger.debug("check_rate: Checking rate limit for IP #{inspect(normalized_ip)} on action #{action}") - if Application.get_env(:copi, :env) == :prod and normalized_ip == {127, 0, 0, 1} do + if Application.get_env(:copi, :env) == :prod and normalized_ip == {127, 0, 0, 1} and ip == {127, 0, 0, 1} do {:ok, :unlimited} else GenServer.call(__MODULE__, {:check_rate, normalized_ip, action}) @@ -58,6 +60,13 @@ defmodule Copi.RateLimiter do GenServer.cast(__MODULE__, {:clear_ip, normalize_ip(ip)}) end + @doc """ + Synchronously clears all rate limit data for a specific IP address (useful for testing). + """ + def clear_ip_sync(ip) do + GenServer.call(__MODULE__, {:clear_ip, normalize_ip(ip)}) + end + @doc """ Gets the current configuration for rate limits. """ @@ -76,12 +85,14 @@ defmodule Copi.RateLimiter do limits: %{ game_creation: get_env_config(:game_creation_limit, 20), player_creation: get_env_config(:player_creation_limit, 60), - connection: get_env_config(:connection_limit, 133) + connection: get_env_config(:connection_limit, 133), + api_action: get_env_config(:api_action_limit, 133) }, windows: %{ game_creation: get_env_config(:game_creation_window, 3600), player_creation: get_env_config(:player_creation_window, 3600), - connection: get_env_config(:connection_window, 1) + connection: get_env_config(:connection_window, 1), + api_action: get_env_config(:api_action_window, 133) }, requests: %{} } @@ -127,6 +138,17 @@ defmodule Copi.RateLimiter do {:reply, config, state} end + @impl true + def handle_call({:clear_ip, ip}, _from, state) do + new_requests = + state.requests + |> Enum.reject(fn {{request_ip, _action}, _timestamps} -> request_ip == ip end) + |> Enum.into(%{}) + + new_state = %{state | requests: new_requests} + {:reply, :ok, new_state} + end + @impl true def handle_cast({:clear_ip, ip}, state) do new_requests = diff --git a/copi.owasp.org/lib/copi_web/controllers/api_controller.ex b/copi.owasp.org/lib/copi_web/controllers/api_controller.ex index f181b8e41..607b238c0 100644 --- a/copi.owasp.org/lib/copi_web/controllers/api_controller.ex +++ b/copi.owasp.org/lib/copi_web/controllers/api_controller.ex @@ -1,47 +1,80 @@ defmodule CopiWeb.ApiController do use CopiWeb, :controller alias Copi.Cornucopia.Game - + alias Copi.Repo + import Ecto.Query def play_card(conn, %{"game_id" => game_id, "player_id" => player_id, "dealt_card_id" => dealt_card_id}) do with {:ok, game} <- Game.find(game_id) do - player = Enum.find(game.players, fn player -> player.id == player_id end) - dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end) - - if player && dealt_card do - current_round = game.rounds_played + 1 + + if player do + dealt_card = Enum.find(player.dealt_cards, fn dealt_card -> Integer.to_string(dealt_card.id) == dealt_card_id end) - cond do - dealt_card.played_in_round -> - conn |> put_status(:not_acceptable) |> json(%{"error" => "Card already played"}) - Enum.find(player.dealt_cards, fn dealt_card -> dealt_card.played_in_round == current_round end) -> - conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"}) - true -> - dealt_card = Ecto.Changeset.change dealt_card, played_in_round: current_round + if dealt_card do + current_round = game.rounds_played + 1 - case Copi.Repo.update dealt_card do - {:ok, dealt_card} -> - with {:ok, updated_game} <- Game.find(game.id) do - CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game) - else - {:error, _reason} -> - conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"}) - end - - conn |> json(%{"id" => dealt_card.id}) - {:error, _changeset} -> - conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update dealt card"}) - end + # Atomic update to prevent race conditions and enforce one-card-per-round invariant + case play_card_atomically(dealt_card, player.id, current_round) do + {:ok, updated_card} -> + with {:ok, updated_game} <- Game.find(game.id) do + CopiWeb.Endpoint.broadcast(topic(game.id), "game:updated", updated_game) + conn |> json(%{"id" => updated_card.id}) + else + {:error, _reason} -> + conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not find updated game"}) + end + {:error, :already_played} -> + conn |> put_status(:conflict) |> json(%{"error" => "Card was already played by another request"}) + {:error, :player_already_played} -> + conn |> put_status(:forbidden) |> json(%{"error" => "Player already played a card in this round"}) + {:error, _changeset} -> + conn |> put_status(:internal_server_error) |> json(%{"error" => "Could not update card"}) + end + else + conn |> put_status(:not_found) |> json(%{"error" => "Could not find dealt card"}) end else - conn |> put_status(:not_found) |> json(%{"error" => "Could not find player and dealt card"}) + conn |> put_status(:not_found) |> json(%{"error" => "Could not find player"}) end else {:error, _reason} -> conn |> put_status(:not_found) |> json(%{"error" => "Could not find game"}) end end + # Atomic card play operation to prevent race conditions and enforce one-card-per-round invariant + defp play_card_atomically(dealt_card, player_id, current_round) do + # Use database transaction to ensure atomicity and prevent race conditions + result = Repo.transaction(fn -> + # First, try to lock player's cards for this round by checking existing plays + existing_cards = Repo.all( + from(dc in Copi.Cornucopia.DealtCard, + where: dc.player_id == ^player_id and dc.played_in_round == ^current_round) + ) + + if length(existing_cards) > 0 do + {:error, :player_already_played} + else + # Now atomically update the card with played_in_round + from(dc in Copi.Cornucopia.DealtCard, + where: dc.id == ^dealt_card.id and is_nil(dc.played_in_round)) + |> Repo.update_all([set: [played_in_round: current_round]], returning: true) + |> case do + {1, [updated_card]} -> {:ok, updated_card} + {0, []} -> {:error, :already_played} + end + end + end) + + # Unwrap transaction result to return flat tuple + case result do + {:ok, {:ok, val}} -> {:ok, val} + {:ok, {:error, reason}} -> {:error, reason} + {:error, reason} -> {:error, reason} + other -> other + end + end + def topic(game_id) do "game:#{game_id}" end diff --git a/copi.owasp.org/lib/copi_web/plugs/rate_limiter_plug.ex b/copi.owasp.org/lib/copi_web/plugs/rate_limiter_plug.ex index 9d3a29f52..eec898184 100644 --- a/copi.owasp.org/lib/copi_web/plugs/rate_limiter_plug.ex +++ b/copi.owasp.org/lib/copi_web/plugs/rate_limiter_plug.ex @@ -9,28 +9,38 @@ defmodule CopiWeb.Plugs.RateLimiterPlug do def call(conn, _opts) do case IPHelper.get_ip_source(conn) do {:forwarded, ip} -> + # Determine action type based on request path and method + action = determine_action(conn) + # Only enforce connection rate limits when we have a forwarded # client IP (left-most value from X-Forwarded-For). This avoids # rate-limiting on internal/transport addresses injected by the # reverse proxy or adapter. - case RateLimiter.check_rate(ip, :connection) do + case RateLimiter.check_rate(ip, action) do {:ok, _remaining} -> # Persist the chosen client IP into the session so LiveView # receives it on websocket connect (connect_info.session). - put_session(conn, "client_ip", IPHelper.ip_to_string(ip)) + # Only write to session if it's available (stateless API requests won't have sessions) + if Map.has_key?(conn.private, :plug_session) do + put_session(conn, "client_ip", IPHelper.ip_to_string(ip)) + else + conn + end {:error, :rate_limit_exceeded} -> - Logger.warning("HTTP connection rate limit exceeded for IP: #{inspect(ip)}") + # Anonymize IP for logging to avoid PII in logs + anonymized_ip = ip |> IPHelper.ip_to_string() |> :crypto.hash(:sha256) |> Base.encode16() + Logger.warning("HTTP #{action} rate limit exceeded for IP: #{anonymized_ip}") conn |> put_resp_content_type("text/plain") - |> send_resp(429, "Too many connections, try again later.") + |> send_resp(429, "Too many requests, try again later.") |> halt() end {:remote, _ip} -> # We only have a transport (remote) IP; skip connection rate limiting # to avoid false positives caused by proxies or adapter internals. - Logger.debug("Skipping connection rate limiting: only transport IP available") + Logger.debug("Skipping rate limiting: only transport IP available") conn {:none, _} -> @@ -39,4 +49,18 @@ defmodule CopiWeb.Plugs.RateLimiterPlug do conn end end + + # Determine the action type for rate limiting based on the request + defp determine_action(conn) do + case conn.method do + "PUT" -> + if String.ends_with?(conn.request_path, "/card") do + :api_action + else + :connection + end + _ -> + :connection + end + end end diff --git a/copi.owasp.org/lib/copi_web/router.ex b/copi.owasp.org/lib/copi_web/router.ex index 8ff3ffaa0..f65ba8636 100644 --- a/copi.owasp.org/lib/copi_web/router.ex +++ b/copi.owasp.org/lib/copi_web/router.ex @@ -13,6 +13,7 @@ defmodule CopiWeb.Router do pipeline :api do plug :accepts, ["json"] + plug CopiWeb.Plugs.RateLimiterPlug end scope "/", CopiWeb do diff --git a/copi.owasp.org/test/copi/rate_limiter_integration_test.exs b/copi.owasp.org/test/copi/rate_limiter_integration_test.exs new file mode 100644 index 000000000..164f3c4bb --- /dev/null +++ b/copi.owasp.org/test/copi/rate_limiter_integration_test.exs @@ -0,0 +1,233 @@ +defmodule Copi.RateLimiterIntegrationTest do + use ExUnit.Case, async: false + use Plug.Test + alias Copi.RateLimiter + alias CopiWeb.Plugs.RateLimiterPlug + + setup do + # Start the RateLimiter GenServer for testing if not already started + case RateLimiter.start_link([]) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + end + + # Clear all rate limiting data before each test (synchronous) + RateLimiter.clear_ip_sync({192, 168, 1, 100}) + RateLimiter.clear_ip_sync({10, 0, 0, 1}) + RateLimiter.clear_ip_sync({127, 0, 0, 1}) + :ok + end + + describe "Attack Scenario Tests" do + test "prevents 100 concurrent requests like in the vulnerability report" do + ip = "192.168.1.100" + + # Simulate attack scenario from vulnerability report + tasks = for i <- 1..100 do + Task.async(fn -> + # Create a realistic request body + body = Jason.encode!(%{"dealt_card_id" => "12345"}) + + conn = conn(:put, "/api/games/01KK4ANZFV6XMR14VX7R1X6T55/players/01KK4APC64N6Z5B346S960XTQJ/card", body) + |> put_req_header("x-forwarded-for", ip) + |> put_req_header("content-type", "application/json") + |> CopiWeb.Router.call([]) + + {i, conn.status} + end) + end + + results = Task.await_many(tasks, 10_000) + + # Count different response types + rate_limited = Enum.count(results, fn {_, status} -> status == 429 end) + success_200 = Enum.count(results, fn {_, status} -> status == 200 end) + conflict_409 = Enum.count(results, fn {_, status} -> status == 409 end) + + # With rate limiting of 10 requests per minute, most should be blocked + config = RateLimiter.get_config() + limit = config.limits.api_action + + assert rate_limited >= 90 # At least 90 should be rate limited + assert success_200 + conflict_409 <= limit # Only limit should be allowed + + IO.puts("\nAttack Simulation Results:") + IO.puts("Rate limited (429): #{rate_limited}") + IO.puts("Allowed: #{success_200 + conflict_409}") + IO.puts("Total: #{length(results)}") + end + + test "rate limiting window resets after time passes" do + ip = "10.0.0.1" + original_config = RateLimiter.get_config() + + # Use a short test window (1 second instead of 60) + test_window = 1 + test_config = %{ + original_config | + windows: %{original_config.windows | api_action: test_window} + } + + # Temporarily update config for test + Application.put_env(:copi, :rate_limiter, test_config) + + # Restart rate limiter with new config + RateLimiter.stop() + RateLimiter.start_link([]) + + limit = test_config.limits.api_action + + # Exhaust the rate limit + for _ <- 1..limit do + RateLimiter.check_rate(ip, :api_action) + end + + # Should be rate limited now + assert {:error, :rate_limit_exceeded} = RateLimiter.check_rate(ip, :api_action) + + # Wait for window to pass plus a small buffer + ms_window = test_window * 1000 # Convert seconds to milliseconds + :timer.sleep(ms_window + 100) + + # Should be allowed again after window expires + assert {:ok, _remaining} = RateLimiter.check_rate(ip, :api_action) + + # Restore original config + Application.put_env(:copi, :rate_limiter, original_config) + RateLimiter.stop() + RateLimiter.start_link([]) + end + + test "different IPs have independent rate limits" do + ip1 = "192.168.1.100" + ip2 = "192.168.1.101" + config = RateLimiter.get_config() + limit = config.limits.api_action + + # Exhaust limit for IP1 + for _ <- 1..limit do + assert {:ok, _} = RateLimiter.check_rate(ip1, :api_action) + end + + # IP1 should be rate limited + assert {:error, :rate_limit_exceeded} = RateLimiter.check_rate(ip1, :api_action) + + # IP2 should still be able to make requests + assert {:ok, _} = RateLimiter.check_rate(ip2, :api_action) + end + + test "rate limiting works across different action types" do + ip = "10.0.0.1" + config = RateLimiter.get_config() + + # Test that different action types have separate limits + api_limit = config.limits.api_action + connection_limit = config.limits.connection + + # Exhaust API action limit + for _ <- 1..api_limit do + assert {:ok, _} = RateLimiter.check_rate(ip, :api_action) + end + + # API actions should be rate limited + assert {:error, :rate_limit_exceeded} = RateLimiter.check_rate(ip, :api_action) + + # But connection limit should still work independently + assert {:ok, _} = RateLimiter.check_rate(ip, :connection) + end + + test "rate limiter handles high concurrency safely" do + ip = "192.168.1.200" + + # Test with many concurrent requests to ensure no race conditions in rate limiter + tasks = for _ <- 1..50 do + Task.async(fn -> + RateLimiter.check_rate(ip, :api_action) + end) + end + + results = Task.await_many(tasks, 5000) + + # Count successes vs failures + successes = Enum.count(results, fn result -> match?({:ok, _}, result) end) + failures = Enum.count(results, fn result -> match?({:error, _}, result) end) + + config = RateLimiter.get_config() + limit = config.limits.api_action + + # Should not exceed the limit + assert successes <= limit + assert failures >= 50 - limit + + # Ensure no crashes or unexpected results + assert length(results) == 50 + end + end + + describe "Rate Limiter Configuration" do + test "respects environment variable configuration" do + # Test that the rate limiter reads from environment variables + config = RateLimiter.get_config() + + # Verify default values are set + assert config.limits.api_action == 10 + assert config.windows.api_action == 60 + assert config.limits.connection == 133 + assert config.windows.connection == 1 + end + + test "handles localhost differently in production" do + # This test would need to be run in production mode to fully test + # For now, we verify the logic exists + ip = {127, 0, 0, 1} + + # In test mode, localhost should be rate limited + result = RateLimiter.check_rate(ip, :api_action) + assert match?({:ok, _}, result) + end + end + + describe "Error Handling" do + test "handles invalid IP addresses gracefully" do + # Test with various invalid IP formats + invalid_ips = ["invalid", "", "not-an-ip", "999.999.999.999"] + + for ip <- invalid_ips do + # Should not crash, should handle gracefully + result = RateLimiter.check_rate(ip, :api_action) + case result do + {:ok, _} -> :ok + {:error, :rate_limit_exceeded} -> :ok + end + end + end + + test "rate limiter is thread-safe" do + ip = "10.0.0.100" + + # Test concurrent access to the same IP + tasks = for _ <- 1..20 do + Task.async(fn -> + # Mix of different operations + case :rand.uniform(3) do + 1 -> RateLimiter.check_rate(ip, :api_action) + 2 -> RateLimiter.check_rate(ip, :connection) + 3 -> RateLimiter.get_config() + end + end) + end + + results = Task.await_many(tasks, 5000) + + # All operations should complete successfully + assert length(results) == 20 + Enum.each(results, fn result -> + case result do + {:ok, _} -> :ok + {:error, :rate_limit_exceeded} -> :ok + %{} -> :ok + end + end) + end + end +end diff --git a/copi.owasp.org/test/copi_web/plugs/rate_limiter_plug_api_test.exs b/copi.owasp.org/test/copi_web/plugs/rate_limiter_plug_api_test.exs new file mode 100644 index 000000000..53429f779 --- /dev/null +++ b/copi.owasp.org/test/copi_web/plugs/rate_limiter_plug_api_test.exs @@ -0,0 +1,239 @@ +defmodule CopiWeb.Plugs.RateLimiterPlugTest do + use ExUnit.Case, async: false + use Plug.Test + + alias CopiWeb.Plugs.RateLimiterPlug + alias Copi.RateLimiter + + setup do + # Start the RateLimiter GenServer for testing if not already started + case RateLimiter.start_link([]) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + end + + RateLimiter.clear_ip_sync({10, 0, 0, 1}) + RateLimiter.clear_ip_sync({192, 168, 1, 100}) + RateLimiter.clear_ip_sync({127, 0, 0, 1}) + :ok + end + + describe "API endpoint rate limiting" do + test "allows API requests under the rate limit" do + conn = + conn(:put, "/api/games/test/players/test/card") + |> put_req_header("x-forwarded-for", "10.0.0.1") + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status != 429 + assert get_session(conn, "client_ip") == "10.0.0.1" + end + + test "applies api_action rate limit to PUT /card requests" do + ip = "192.168.1.100" + config = RateLimiter.get_config() + limit = config.limits.api_action + + # Exhaust the API action limit first + for _ <- 1..limit do + RateLimiter.check_rate(ip, :api_action) + end + + conn = + conn(:put, "/api/games/test/players/test/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status == 429 + assert conn.resp_body == "Too many requests, try again later." + assert conn.halted + end + + test "applies connection rate limit to non-card API requests" do + ip = "192.168.1.100" + config = RateLimiter.get_config() + limit = config.limits.connection + + # Exhaust the connection limit first + for _ <- 1..limit do + RateLimiter.check_rate(ip, :connection) + end + + conn = + conn(:get, "/api/some-other-endpoint") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status == 429 + assert conn.halted + end + + test "uses api_action for PUT requests containing /card" do + ip = "192.168.1.100" + + # First request should pass + conn = + conn(:put, "/api/games/123/players/456/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + refute conn.halted + + # Exhaust the api_action rate limit dynamically + config = RateLimiter.get_config() + api_limit = config.limits.api_action + + for _ <- 1..(api_limit - 1) do + conn(:put, "/api/games/123/players/456/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + end + + # Next request should be rate limited + conn = + conn(:put, "/api/games/123/players/456/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status == 429 + assert conn.halted + end + + test "uses connection for PUT requests not containing /card" do + ip = "192.168.1.100" + + # Exhaust the connection rate limit dynamically + config = RateLimiter.get_config() + conn_limit = config.limits.connection + + for _ <- 1..(conn_limit + 2) do + conn(:put, "/api/games/123/players/456/some-other-action") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + end + + # Next request should be rate limited + conn = + conn(:put, "/api/games/123/players/456/some-other-action") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status == 429 + assert conn.halted + end + + test "uses connection for GET requests" do + ip = "192.168.1.100" + + # Exhaust the connection rate limit dynamically + config = RateLimiter.get_config() + conn_limit = config.limits.connection + + for _ <- 1..(conn_limit + 2) do + conn(:get, "/api/games/123/players/456/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + end + + # Next request should be rate limited + conn = + conn(:get, "/api/games/123/players/456/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status == 429 + assert conn.halted + end + + test "uses connection for POST requests" do + ip = "192.168.1.100" + + # Exhaust the connection rate limit dynamically + config = RateLimiter.get_config() + conn_limit = config.limits.connection + + for _ <- 1..(conn_limit + 2) do + conn(:post, "/api/games/123/players/456/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + end + + # Next request should be rate limited + conn = + conn(:post, "/api/games/123/players/456/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status == 429 + assert conn.halted + end + end + + describe "Rate limiting behavior" do + test "skips rate limiting when only remote IP is available" do + ip = {127, 0, 0, 1} + config = RateLimiter.get_config() + limit = config.limits.api_action + + # Exhaust the limit on RateLimiter manually + for _ <- 1..limit do + RateLimiter.check_rate(ip, :api_action) + end + + # The plug should still let it through because it skips non-forwarded IPs + conn = + conn(:put, "/api/games/test/players/test/card") + |> Map.put(:remote_ip, ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status != 429 + refute conn.halted + end + + test "skips rate limiting when no IP info is available" do + # No headers, no remote_ip + conn = + conn(:put, "/api/games/test/players/test/card") + |> RateLimiterPlug.call([]) + + assert conn.status != 429 + refute conn.halted + end + end + + describe "Error messages" do + test "returns appropriate error message for API rate limiting" do + ip = "192.168.1.100" + config = RateLimiter.get_config() + limit = config.limits.api_action + + # Exhaust the API action limit + for _ <- 1..limit do + RateLimiter.check_rate(ip, :api_action) + end + + conn = + conn(:put, "/api/games/test/players/test/card") + |> put_req_header("x-forwarded-for", ip) + |> init_test_session(%{}) + |> RateLimiterPlug.call([]) + + assert conn.status == 429 + assert conn.resp_body == "Too many requests, try again later." + assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"] + end + end +end