6.5 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRC.OperTest do
  @moduledoc """
  IRC operator authentication (`OPER`) and the operator-only `KILL`
  command. Opers are configured via `:opers` app env; we mutate that
  for the duration of each test.
  """

  use OhMyExOhIrcd.DataCase, async: false

  import OhMyExOhIrcd.IRCTestHelper

  @oper_password "operpass-#{System.unique_integer([:positive])}"

  setup do
    hash = Bcrypt.hash_pwd_salt(@oper_password)
    prev = Application.get_env(:oh_my_ex_oh_ircd, :opers, %{})

    Application.put_env(:oh_my_ex_oh_ircd, :opers, %{
      "admin" => %{password_hash: hash, host_mask: "*@*"},
      "tightbound" => %{password_hash: hash, host_mask: "noone@nowhere"}
    })

    on_exit(fn -> Application.put_env(:oh_my_ex_oh_ircd, :opers, prev) end)
    :ok
  end

  defp drain_after_event(sock, pattern, timeout_ms \\ 600) do
    deadline = System.monotonic_time(:millisecond) + timeout_ms

    Stream.repeatedly(fn ->
      remaining = max(deadline - System.monotonic_time(:millisecond), 0)
      :gen_tcp.recv(sock, 0, min(remaining, 200))
    end)
    |> Enum.find_value(fn
      {:ok, line} ->
        line = String.trim_trailing(line)
        if line =~ pattern, do: line, else: nil

      _ ->
        nil
    end)
  end

  test "OPER with correct credentials grants operator status" do
    port = start_listener()
    sock = register(port, unique_nick("oper"))
    send_line(sock, "OPER admin #{@oper_password}")

    line = drain_after_event(sock, ~r/ 381 /)
    assert line, "expected RPL_YOUREOPER (381)"
  end

  test "OPER with bad password returns 464" do
    port = start_listener()
    sock = register(port, unique_nick("oper"))
    send_line(sock, "OPER admin wrong-password")

    line = drain_after_event(sock, ~r/ 464 /)
    assert line
  end

  test "OPER with unknown name returns 464 (avoids leaking which is wrong)" do
    port = start_listener()
    sock = register(port, unique_nick("oper"))
    send_line(sock, "OPER nobody #{@oper_password}")

    line = drain_after_event(sock, ~r/ 464 /)
    assert line
  end

  test "OPER with mismatched host mask returns 491" do
    port = start_listener()
    sock = register(port, unique_nick("oper"))
    # `tightbound` mask is noone@nowhere — we connect from 127.0.0.1
    send_line(sock, "OPER tightbound #{@oper_password}")

    line = drain_after_event(sock, ~r/ 491 /)
    assert line
  end

  test "KILL by non-oper returns 481" do
    port = start_listener()
    a = register(port, unique_nick("attacker"))
    _b = register(port, unique_nick("victim"))

    send_line(a, "KILL victim :no reason")
    line = drain_after_event(a, ~r/ 481 /)
    assert line
  end

  test "KILL by oper forces the target to disconnect with ERROR :Killed" do
    port = start_listener()
    a = register(port, unique_nick("admin1"))
    victim_nick = unique_nick("victim")
    b = register(port, victim_nick)

    send_line(a, "OPER admin #{@oper_password}")
    drain_after_event(a, ~r/ 381 /)

    send_line(a, "KILL #{victim_nick} :you misbehaved")
    err = drain_after_event(b, ~r/ERROR :Closing link: Killed by/)

    assert err, "expected ERROR :Closing link: Killed by ..."
  end

  test "OPER with no args promotes a SASL-authed user with is_oper=true" do
    import OhMyExOhIrcd.AccountsFixtures

    alias OhMyExOhIrcd.Accounts

    password = "longenoughpass#{System.unique_integer([:positive])}"
    user = user_fixture()
    {:ok, {user, _}} = Accounts.update_user_password(user, %{password: password})
    {:ok, user} = Accounts.set_oper(user, true)

    port = start_listener()
    sock = register_sasl(port, unique_nick("authoper"), user.email, password)
    send_line(sock, "OPER")

    line = drain_after_event(sock, ~r/ 381 /)
    assert line, "expected RPL_YOUREOPER (381) for is_oper user"
  end

  test "OPER with no args rejected when account isn't flagged" do
    import OhMyExOhIrcd.AccountsFixtures

    alias OhMyExOhIrcd.Accounts

    password = "longenoughpass#{System.unique_integer([:positive])}"
    user = user_fixture()
    {:ok, {user, _}} = Accounts.update_user_password(user, %{password: password})

    port = start_listener()
    sock = register_sasl(port, unique_nick("plain"), user.email, password)
    send_line(sock, "OPER")
    assert drain_after_event(sock, ~r/ 464 /)
  end

  test "OPER name+password promotes a flagged SASL user (clients always send a password)" do
    import OhMyExOhIrcd.AccountsFixtures

    alias OhMyExOhIrcd.Accounts

    password = "longenoughpass#{System.unique_integer([:positive])}"
    user = user_fixture()
    {:ok, {user, _}} = Accounts.update_user_password(user, %{password: password})
    {:ok, user} = Accounts.set_oper(user, true)

    port = start_listener()
    sock = register_sasl(port, unique_nick("authoper"), user.email, password)
    # irssi/weechat `/oper` always sends a name + (here irrelevant) password,
    # never a bare OPER — the flagged account still gets promoted.
    send_line(sock, "OPER whatever ignored-password")

    line = drain_after_event(sock, ~r/ 381 /)
    assert line, "expected RPL_YOUREOPER (381) via name+password for is_oper user"
  end

  test "an is_oper grant issued after authentication takes effect without reconnecting" do
    import OhMyExOhIrcd.AccountsFixtures

    alias OhMyExOhIrcd.Accounts

    password = "longenoughpass#{System.unique_integer([:positive])}"
    user = user_fixture()
    {:ok, {user, _}} = Accounts.update_user_password(user, %{password: password})

    port = start_listener()
    sock = register_sasl(port, unique_nick("lateoper"), user.email, password)

    # Not flagged yet — refused.
    send_line(sock, "OPER")
    assert drain_after_event(sock, ~r/ 464 /)

    # Flag the account mid-session (mirrors `Release.grant_oper`).
    {:ok, _} = Accounts.set_oper(user, true)

    # Same connection, no reconnect: the flag is re-read from the DB, so
    # the stale cached struct doesn't block promotion.
    send_line(sock, "OPER")
    assert drain_after_event(sock, ~r/ 381 /),
           "expected RPL_YOUREOPER (381) after a mid-session grant"
  end

  test "WHOIS on an oper includes 313 RPL_WHOISOPERATOR" do
    port = start_listener()
    admin_nick = unique_nick("admin1")
    a = register(port, admin_nick)
    b = register(port, unique_nick("watcher"))

    send_line(a, "OPER admin #{@oper_password}")
    drain_after_event(a, ~r/ 381 /)

    send_line(b, "WHOIS #{admin_nick}")
    Process.sleep(200)
    drained = drain(b, 400)

    assert Enum.any?(drained, &String.contains?(&1, " 313 ")),
           "expected 313 RPL_WHOISOPERATOR in WHOIS reply: #{inspect(drained)}"
  end
end