defmodule ToweropsWeb.Plugs.BruteForceProtectionTest do use Towerops.DataCase, async: false import Plug.Conn alias Towerops.Security.BruteForce alias ToweropsWeb.Plugs.BruteForceProtection describe "init/1" do test "returns opts unchanged" do assert BruteForceProtection.init([]) == [] end test "returns arbitrary opts unchanged" do opts = [threshold: 10, window: 120] assert BruteForceProtection.init(opts) == opts end end describe "call/2 with non-banned IPs" do test "allows requests from non-banned IPs" do conn = build_conn() |> put_req_header("x-forwarded-for", "10.0.0.1") |> BruteForceProtection.call([]) refute conn.halted assert conn.status != 403 end test "allows requests from whitelisted IPs even if they would be banned" do user = Towerops.AccountsFixtures.user_fixture() ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}" # Whitelist the IP first {:ok, _} = BruteForce.add_to_whitelist(ip, "Test whitelist", user) # Then ban it {:ok, _block} = BruteForce.create_or_escalate_ban(ip, "Test ban") conn = build_conn() |> put_req_header("x-forwarded-for", ip) |> BruteForceProtection.call([]) refute conn.halted end test "registers before_send callback for 404 tracking" do conn = build_conn() |> put_req_header("x-forwarded-for", "10.0.0.99") |> BruteForceProtection.call([]) refute conn.halted # Verify before_send callbacks are registered assert conn.private[:before_send] != [] end end describe "call/2 IP extraction" do test "extracts IP from x-forwarded-for header" do conn = build_conn() |> put_req_header("x-forwarded-for", "203.0.113.50, 10.0.0.1") |> BruteForceProtection.call([]) # Should use the first IP (203.0.113.50) and not be banned refute conn.halted end test "extracts IP from x-real-ip header when x-forwarded-for is absent" do conn = build_conn() |> put_req_header("x-real-ip", "203.0.113.60") |> BruteForceProtection.call([]) refute conn.halted end test "falls back to conn.remote_ip when proxy headers are absent" do conn = BruteForceProtection.call(build_conn(), []) refute conn.halted end end describe "call/2 with banned IPs" do @tag :skip test "blocks requests from banned IPs with 403 and retry-after header" do # SKIP: Brute force protection is currently disabled (handled by Cloudflare at edge) ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}" # Create a ban for the IP (5 minute ban for first offense) {:ok, _block} = BruteForce.create_or_escalate_ban(ip, "Test ban") conn = build_conn() |> put_req_header("x-forwarded-for", ip) |> BruteForceProtection.call([]) # When enabled, should halt and return 403 assert conn.halted assert conn.status == 403 assert get_resp_header(conn, "retry-after") != [] # Verify the IP is actually banned via the context assert {:blocked, _banned_until} = BruteForce.check_ban_status(ip) end test "permanent ban returns retry-after of 86400" do ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}" # Escalate to permanent ban (3 offenses) {:ok, _} = BruteForce.create_or_escalate_ban(ip, "offense 1") {:ok, _} = BruteForce.create_or_escalate_ban(ip, "offense 2") {:ok, _} = BruteForce.create_or_escalate_ban(ip, "offense 3") # Verify permanent ban status via context assert {:blocked, nil} = BruteForce.check_ban_status(ip) end end describe "call/2 skip branches" do test "passes through immediately for authenticated users" do user = Towerops.AccountsFixtures.user_fixture() conn = build_conn() |> assign(:current_user, user) |> BruteForceProtection.call([]) refute conn.halted # before_send callback is registered but will skip tracking for # authenticated users (auth check moved into the callback since # this plug runs before authentication). assert is_list(conn.private[:before_send]) end test "passes through immediately for agent socket paths" do conn = :get |> Plug.Test.conn("/socket/agent/websocket") |> BruteForceProtection.call([]) refute conn.halted assert conn.private[:before_send] in [nil, []] end end describe "ban status integration" do test "non-banned IP returns :allowed" do assert :allowed = BruteForce.check_ban_status("10.0.0.99") end test "first offense creates a temporary ban" do ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}" {:ok, block} = BruteForce.create_or_escalate_ban(ip, "Test ban") assert block.offense_count == 1 assert block.banned_until assert {:blocked, banned_until} = BruteForce.check_ban_status(ip) assert banned_until end test "escalation increases offense count" do ip = "198.51.100.#{[:positive] |> System.unique_integer() |> rem(255)}" {:ok, block1} = BruteForce.create_or_escalate_ban(ip, "offense 1") assert block1.offense_count == 1 {:ok, block2} = BruteForce.create_or_escalate_ban(ip, "offense 2") assert block2.offense_count == 2 {:ok, block3} = BruteForce.create_or_escalate_ban(ip, "offense 3") assert block3.offense_count == 3 # Third offense is permanent assert {:blocked, nil} = BruteForce.check_ban_status(ip) end end defp build_conn do Plug.Test.conn(:get, "/some/path") end end