diff --git a/test/towerops/security/cloudflare_client_test.exs b/test/towerops/security/cloudflare_client_test.exs index 7c1a77a0..41b35041 100644 --- a/test/towerops/security/cloudflare_client_test.exs +++ b/test/towerops/security/cloudflare_client_test.exs @@ -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 diff --git a/test/towerops/workers/device_poller_worker_airfiber_test.exs b/test/towerops/workers/device_poller_worker_airfiber_test.exs new file mode 100644 index 00000000..c44cfa72 --- /dev/null +++ b/test/towerops/workers/device_poller_worker_airfiber_test.exs @@ -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