3.5 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.FloodTest do
  @moduledoc """
  Flood control: a leaky bucket measured in seconds of penalty. Each
  parsed command adds a per-command penalty, the bucket drains at
  1 sec/sec real time, and overflow drops the connection with
  `ERROR :Excess Flood`.
  """

  use ExUnit.Case, async: true

  import OhMyExOhIrcd.IRCTestHelper

  test "rapid burst exceeds the threshold and gets dropped with Excess Flood" do
    # Threshold 8s leaves ~4s of headroom after the register+JOIN setup
    # (NICK 1.5 + USER 1.0 + JOIN 1.5 ≈ 4s). A burst of 30 PRIVMSGs at
    # penalty 1.0 each easily blows past.
    port =
      start_listener(%{
        flood_threshold_seconds: 8,
        flood_penalty_default: 1.0
      })

    chan = unique_channel("flood")
    sock = register(port, unique_nick("flooder"))
    send_line(sock, "JOIN #{chan}")
    drain(sock, 200)

    Enum.each(1..30, fn n ->
      # `send_line` can hit {:error, :closed} once the server has dropped
      # us — just keep going; the test only cares that the ERROR line lands.
      try do
        send_line(sock, "PRIVMSG #{chan} :spam #{n}")
      rescue
        _ -> :ok
      end
    end)

    collected = drain(sock, 800)

    assert Enum.any?(collected, &String.contains?(&1, "Excess Flood")),
           "expected ERROR :Excess Flood, got: #{inspect(collected)}"
  end

  test "PRIVMSG to a +f channel bypasses flood control" do
    port =
      start_listener(%{
        flood_threshold_seconds: 5,
        flood_penalty_default: 1.0
      })

    chan = unique_channel("broker")
    nick = unique_nick("publisher")
    sock = register(port, nick)
    send_line(sock, "JOIN #{chan}")
    drain(sock, 200)

    # Founder bypass via the test back door; then set +f on the channel.
    alias OhMyExOhIrcd.IRC.Channel
    :ok = Channel.__seed_access__(chan, nick, :founder)
    drain(sock, 100)

    send_line(sock, "MODE #{chan} +f")
    drain(sock, 200)

    # 50 PRIVMSGs that would otherwise hit the ~5s threshold many times over.
    Enum.each(1..50, fn n -> send_line(sock, "PRIVMSG #{chan} :broadcast #{n}") end)

    collected = drain(sock, 800)

    refute Enum.any?(collected, &String.contains?(&1, "Excess Flood")),
           "expected no Excess Flood on +f channel, but got: #{inspect(collected)}"
  end

  test "non-founder can't set +f" do
    port = start_listener()
    chan = unique_channel("nof")
    a_nick = unique_nick("a")
    b_nick = unique_nick("b")
    a = register(port, a_nick)
    b = register(port, b_nick)
    send_line(a, "JOIN #{chan}")
    send_line(b, "JOIN #{chan}")
    drain(a, 200)
    drain(b, 200)

    # Make B a halfop — sufficient for normal channel modes but NOT for +f.
    alias OhMyExOhIrcd.IRC.Channel
    :ok = Channel.__seed_access__(chan, b_nick, :halfop)
    drain(a, 100)
    drain(b, 100)

    send_line(b, "MODE #{chan} +f")
    assert Enum.any?(drain(b, 300), &String.contains?(&1, " 482 "))
  end

  test "paced traffic does NOT trip flood" do
    port =
      start_listener(%{
        flood_threshold_seconds: 5,
        flood_penalty_default: 1.0
      })

    chan = unique_channel("paced")
    sock = register(port, unique_nick("paced"))
    send_line(sock, "JOIN #{chan}")
    drain(sock, 200)

    # 5 messages spaced > 1s apart — the bucket drains as fast as we fill.
    Enum.each(1..5, fn n ->
      send_line(sock, "PRIVMSG #{chan} :ping #{n}")
      Process.sleep(1_100)
    end)

    collected = drain(sock, 300)
    refute Enum.any?(collected, &String.contains?(&1, "Excess Flood"))
  end
end