test: CloudflareClient unblock error + transport-failure branches

This commit is contained in:
Graham McIntire 2026-05-09 12:43:39 -05:00
parent 29c4bf5582
commit e5c6b32f32
2 changed files with 445 additions and 0 deletions

View file

@ -99,5 +99,60 @@ defmodule Towerops.Security.CloudflareClientTest do
assert {:error, :not_configured} = CloudflareClient.unblock_ip("1.2.3.4")
end
test "unblock returns API error string when delete fails with non-2xx" do
Req.Test.stub(CloudflareClient, fn conn ->
case {conn.method, conn.request_path} do
{"GET", "/client/v4/zones/" <> _} ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"result" => [%{"id" => "rule_xyz", "configuration" => %{"value" => "5.6.7.8"}}]
})
)
{"DELETE", "/client/v4/zones/" <> _} ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(500, Jason.encode!(%{"error" => "internal"}))
end
end)
assert {:error, "API returned status 500"} = CloudflareClient.unblock_ip("5.6.7.8")
end
test "unblock returns transport error when delete fails to connect" do
Req.Test.stub(CloudflareClient, fn conn ->
case {conn.method, conn.request_path} do
{"GET", "/client/v4/zones/" <> _} ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(
200,
Jason.encode!(%{
"result" => [%{"id" => "rule_xyz", "configuration" => %{"value" => "5.6.7.8"}}]
})
)
{"DELETE", _} ->
Req.Test.transport_error(conn, :econnrefused)
end
end)
assert {:error, _reason} = CloudflareClient.unblock_ip("5.6.7.8")
end
test "unblock returns :not_found when find_rule_by_ip surfaces a non-200 status" do
Req.Test.stub(CloudflareClient, fn conn ->
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(403, Jason.encode!(%{"error" => "forbidden"}))
end)
# The error from find_rule_by_ip is propagated as-is by the catch-all
assert {:error, _} = CloudflareClient.unblock_ip("5.6.7.8")
end
end
end

View file

@ -0,0 +1,390 @@
defmodule Towerops.Workers.DevicePollerWorkerAirfiberTest do
@moduledoc """
Coverage for `DevicePollerWorker` AirFiber proprietary-counter detection
branches. Covers:
* `detect_airfiber_counter_overrides/2` returning `:airfiber`
* `detect_airfiber_counter_overrides/2` returning `:airfiber_ltu`
* `detect_airfiber_counter_overrides/2` returning `nil` for non-UBNT
* `get_airfiber_stats/3` skip-loopback branch (`if_descr in ["lo", "sit0"]`)
* `fetch_proprietary_stats/3` happy path
AirFiber traffic counters live on `1.3.6.1.4.1.41112.*` so we drive the
detection by setting the SNMP device's `sys_object_id` accordingly.
"""
use Towerops.DataCase, async: false
import Mox
import Towerops.AccountsFixtures
alias Towerops.Devices
alias Towerops.Organizations
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Snmp.SnmpMock
alias Towerops.Workers.DevicePollerWorker
setup :verify_on_exit!
setup do
old_adapter = Application.get_env(:towerops, :snmp_adapter)
Application.put_env(:towerops, :snmp_adapter, SnmpMock)
on_exit(fn ->
if old_adapter do
Application.put_env(:towerops, :snmp_adapter, old_adapter)
else
Application.delete_env(:towerops, :snmp_adapter)
end
end)
user = user_fixture()
{:ok, organization} = Organizations.create_organization(%{name: "AF Org"}, user.id)
{:ok, site} = Sites.create_site(%{name: "AF Site", organization_id: organization.id})
%{organization: organization, site: site}
end
defp create_polled_device(site) do
{:ok, device} =
Devices.create_device(%{
name: "AF Dev #{System.unique_integer([:positive])}",
ip_address: "10.5.5.#{Enum.random(2..250)}",
site_id: site.id,
organization_id: site.organization_id,
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
check_interval_seconds: 600
})
Repo.delete_all(Oban.Job)
device
end
describe "AirFiber detection - regular AirFiber" do
test "uses proprietary counters when LTU probe fails and AirFiber probe succeeds",
%{site: site} do
device = create_polled_device(site)
snmp =
%SnmpDevice{}
|> SnmpDevice.changeset(%{
device_id: device.id,
sys_name: "af11",
sys_descr: "Ubiquiti AirFiber AF11",
# UBNT enterprise OID - drives is_ubnt = true
sys_object_id: "1.3.6.1.4.1.41112.1.3"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp.id,
if_index: 1,
if_name: "eth0",
if_descr: "ethernet",
if_type: 6,
if_speed: 1_000_000_000,
if_admin_status: "up",
if_oper_status: "up",
monitored: true
})
|> Repo.insert!()
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
# LTU probe fails → falls into AirFiber detection
"1.3.6.1.4.1.41112.1.10.1.2.2.0" -> {:error, :no_such_object}
# AirFiber probe succeeds (val != nil)
"1.3.6.1.4.1.41112.1.3.3.1.1.1" -> {:ok, 1}
# AirFiber proprietary counters
"1.3.6.1.4.1.41112.1.3.3.1.7.1" -> {:ok, 12_345}
"1.3.6.1.4.1.41112.1.3.3.1.6.1" -> {:ok, 54_321}
"1.3.6.1.4.1.41112.1.3.3.1.10.1" -> {:ok, 1}
"1.3.6.1.4.1.41112.1.3.3.1.11.1" -> {:ok, 2}
# Generic interface change-detection OIDs
"1.3.6.1.2.1.2.2.1.5.1" -> {:ok, 1_000_000_000}
"1.3.6.1.2.1.2.2.1.6.1" -> {:ok, <<0, 17, 34, 51, 68, 85>>}
"1.3.6.1.2.1.2.2.1.7.1" -> {:ok, 1}
"1.3.6.1.2.1.2.2.1.8.1" -> {:ok, 1}
_ -> {:error, :no_such_object}
end
end)
stub(SnmpMock, :get_multiple, fn _, _oids, _ ->
{:ok,
%{
"1.3.6.1.2.1.2.2.1.5.1" => {:integer, 1_000_000_000},
"1.3.6.1.2.1.2.2.1.6.1" => {:"OCTET STRING", <<0, 17, 34, 51, 68, 85>>},
"1.3.6.1.2.1.2.2.1.7.1" => {:integer, 1},
"1.3.6.1.2.1.2.2.1.8.1" => {:integer, 1}
}}
end)
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
stat = Repo.one(Ecto.Query.where(InterfaceStat, interface_id: ^interface.id))
assert stat
# AirFiber counters: rxOctetsOK -> if_in_octets
assert stat.if_in_octets == 12_345
assert stat.if_out_octets == 54_321
end
end
describe "AirFiber detection - LTU variant" do
test "uses LTU counters when LTU probe succeeds", %{site: site} do
device = create_polled_device(site)
snmp =
%SnmpDevice{}
|> SnmpDevice.changeset(%{
device_id: device.id,
sys_name: "afltu",
sys_descr: "Ubiquiti AirFiber LTU",
sys_object_id: "1.3.6.1.4.1.41112.1.10"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp.id,
if_index: 1,
if_name: "eth0",
if_descr: "ethernet",
if_type: 6,
if_speed: 1_000_000_000,
if_admin_status: "up",
if_oper_status: "up",
monitored: true
})
|> Repo.insert!()
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
# LTU probe succeeds → :airfiber_ltu
"1.3.6.1.4.1.41112.1.10.1.2.2.0" -> {:ok, 100}
# LTU traffic counters
"1.3.6.1.4.1.41112.1.10.1.6.1.6.0" -> {:ok, 9_876}
"1.3.6.1.4.1.41112.1.10.1.6.1.4.0" -> {:ok, 6_789}
"1.3.6.1.2.1.2.2.1.5.1" -> {:ok, 1_000_000_000}
"1.3.6.1.2.1.2.2.1.6.1" -> {:ok, <<0, 17, 34, 51, 68, 85>>}
"1.3.6.1.2.1.2.2.1.7.1" -> {:ok, 1}
"1.3.6.1.2.1.2.2.1.8.1" -> {:ok, 1}
_ -> {:error, :no_such_object}
end
end)
stub(SnmpMock, :get_multiple, fn _, _, _ ->
{:ok,
%{
"1.3.6.1.2.1.2.2.1.5.1" => {:integer, 1_000_000_000},
"1.3.6.1.2.1.2.2.1.6.1" => {:"OCTET STRING", <<0, 17, 34, 51, 68, 85>>},
"1.3.6.1.2.1.2.2.1.7.1" => {:integer, 1},
"1.3.6.1.2.1.2.2.1.8.1" => {:integer, 1}
}}
end)
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
stat = Repo.one(Ecto.Query.where(InterfaceStat, interface_id: ^interface.id))
assert stat
assert stat.if_in_octets == 9_876
assert stat.if_out_octets == 6_789
end
end
describe "AirFiber detection - non-UBNT path" do
test "skips AirFiber counters and uses standard IF-MIB for non-UBNT device",
%{site: site} do
device = create_polled_device(site)
snmp =
%SnmpDevice{}
|> SnmpDevice.changeset(%{
device_id: device.id,
sys_name: "cisco01",
sys_descr: "Cisco IOS",
# Cisco enterprise OID, NOT UBNT
sys_object_id: "1.3.6.1.4.1.9.1.1"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp.id,
if_index: 1,
if_name: "Gi0/0",
if_descr: "ethernet",
if_type: 6,
if_speed: 1_000_000_000,
if_admin_status: "up",
if_oper_status: "up",
monitored: true
})
|> Repo.insert!()
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
"1.3.6.1.2.1.31.1.1.1.6.1" -> {:ok, 100}
"1.3.6.1.2.1.31.1.1.1.10.1" -> {:ok, 200}
"1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.5.1" -> {:ok, 1_000_000_000}
"1.3.6.1.2.1.2.2.1.6.1" -> {:ok, <<0, 17, 34, 51, 68, 85>>}
"1.3.6.1.2.1.2.2.1.7.1" -> {:ok, 1}
"1.3.6.1.2.1.2.2.1.8.1" -> {:ok, 1}
_ -> {:error, :no_such_object}
end
end)
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
stat = Repo.one(Ecto.Query.where(InterfaceStat, interface_id: ^interface.id))
assert stat
assert stat.if_in_octets == 100
assert stat.if_out_octets == 200
end
end
describe "AirFiber detection - LTU probe error then AirFiber probe nil" do
test "falls back to standard counters when both LTU and AirFiber probes fail",
%{site: site} do
device = create_polled_device(site)
snmp =
%SnmpDevice{}
|> SnmpDevice.changeset(%{
device_id: device.id,
sys_name: "ubnt-other",
sys_descr: "Ubiquiti something",
# Old UBNT enterprise OID
sys_object_id: "1.3.6.1.4.1.10002.1"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp.id,
if_index: 1,
if_name: "ath0",
if_descr: "wireless",
if_type: 6,
if_speed: 100_000_000,
if_admin_status: "up",
if_oper_status: "up",
monitored: true
})
|> Repo.insert!()
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
# LTU probe error
"1.3.6.1.4.1.41112.1.10.1.2.2.0" -> {:error, :no_such_object}
# AirFiber probe error
"1.3.6.1.4.1.41112.1.3.3.1.1.1" -> {:error, :no_such_object}
# Standard counters
"1.3.6.1.2.1.31.1.1.1.6.1" -> {:ok, 11}
"1.3.6.1.2.1.31.1.1.1.10.1" -> {:ok, 22}
"1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.5.1" -> {:ok, 100_000_000}
"1.3.6.1.2.1.2.2.1.6.1" -> {:ok, <<0, 17, 34, 51, 68, 85>>}
"1.3.6.1.2.1.2.2.1.7.1" -> {:ok, 1}
"1.3.6.1.2.1.2.2.1.8.1" -> {:ok, 1}
_ -> {:error, :no_such_object}
end
end)
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
stat = Repo.one(Ecto.Query.where(InterfaceStat, interface_id: ^interface.id))
assert stat
assert stat.if_in_octets == 11
assert stat.if_out_octets == 22
end
end
describe "AirFiber + loopback skip" do
test "loopback interface uses standard counters even on AirFiber device",
%{site: site} do
device = create_polled_device(site)
snmp =
%SnmpDevice{}
|> SnmpDevice.changeset(%{
device_id: device.id,
sys_name: "af-with-lo",
sys_descr: "Ubiquiti AirFiber",
sys_object_id: "1.3.6.1.4.1.41112.1.3"
})
|> Repo.insert!()
# Two interfaces: lo (skip airfiber) and air0 (use airfiber)
lo =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp.id,
if_index: 1,
if_name: "lo",
if_descr: "lo",
if_type: 24,
if_speed: 0,
if_admin_status: "up",
if_oper_status: "up",
monitored: true
})
|> Repo.insert!()
stub(SnmpMock, :get, fn _, oid, _ ->
case oid do
# AirFiber detection: LTU probe error, AirFiber probe ok
"1.3.6.1.4.1.41112.1.10.1.2.2.0" -> {:error, :no_such_object}
"1.3.6.1.4.1.41112.1.3.3.1.1.1" -> {:ok, 1}
# Loopback fallback: standard counters
"1.3.6.1.2.1.31.1.1.1.6.1" -> {:ok, 0}
"1.3.6.1.2.1.31.1.1.1.10.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.14.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.20.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.13.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.19.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.5.1" -> {:ok, 0}
"1.3.6.1.2.1.2.2.1.6.1" -> {:ok, ""}
"1.3.6.1.2.1.2.2.1.7.1" -> {:ok, 1}
"1.3.6.1.2.1.2.2.1.8.1" -> {:ok, 1}
_ -> {:error, :no_such_object}
end
end)
stub(SnmpMock, :get_multiple, fn _, _, _ -> {:error, :timeout} end)
stub(SnmpMock, :walk, fn _, _, _ -> {:ok, []} end)
assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}})
# The loopback interface should have a stat (from standard counters,
# since AirFiber proprietary counters are skipped for "lo").
stat = Repo.one(Ecto.Query.where(InterfaceStat, interface_id: ^lo.id))
assert stat
end
end
end