test(agent_channel): cover stuck alert resolution, deprecated PING, MikroTik job building

Adds 8 tests targeting AgentChannel branches missed by existing tests:

- resolve_any_stuck_down_alerts: device_down and device_up stuck alerts
  resolved on next successful poll
- handle_in result with :PING job_type (deprecated SnmpResult path used
  by older agents) creates a monitoring check without crashing
- handle_live_poll_result with malformed job_id (missing reply_topic)
  hits the warning catch-all branch
- build_mikrotik_job is included in the initial dispatch when the device
  has a discovered SNMP profile whose manufacturer is MikroTik or whose
  sys_descr mentions RouterOS, and excluded when mikrotik_enabled is false
- process_additional_polling_data path with extra OIDs runs without
  crashing the channel
This commit is contained in:
Graham McIntire 2026-05-09 12:25:13 -05:00
parent 910298b6eb
commit bf2ad4031b

View file

@ -0,0 +1,399 @@
defmodule ToweropsWeb.AgentChannelExtraCoverageTest do
@moduledoc """
Targeted coverage for ToweropsWeb.AgentChannel paths not exercised in the
primary channel tests:
* Stuck device_down/device_up alert resolution after a successful poll
* Deprecated PING result delivered as SnmpResult (legacy agents)
* Live poll result with malformed job_id triggers warning branch
* MikroTik device job building when device.snmp_device matches RouterOS
"""
use Towerops.DataCase, async: false
import Phoenix.ChannelTest
alias Towerops.AccountsFixtures
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.SnmpResult
alias Towerops.AgentsFixtures
alias Towerops.Alerts
alias Towerops.DevicesFixtures
alias Towerops.OrganizationsFixtures
alias Towerops.Snmp.AgentDiscovery
alias Towerops.SnmpFixtures
alias ToweropsWeb.AgentSocket
@endpoint ToweropsWeb.Endpoint
setup do
user = AccountsFixtures.user_fixture()
organization = OrganizationsFixtures.organization_fixture(user.id)
device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, agent_token, token_string} =
AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _assignment} =
AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
{:ok, socket} = connect(AgentSocket, %{})
{:ok, _, socket} =
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
assert_push "jobs", _initial_jobs
%{
socket: socket,
device: device,
agent_token: agent_token,
token_string: token_string,
organization: organization
}
end
defp encode_payload(struct) do
binary = struct.__struct__.encode(struct)
%{"binary" => Base.encode64(binary)}
end
defp build_heartbeat do
%AgentHeartbeat{
version: "1.0.0",
hostname: "test-agent",
uptime_seconds: 3600,
ip_address: "",
arch: "x86_64"
}
end
defp poll_until(fun, opts \\ []) do
max_attempts = Keyword.get(opts, :max_attempts, 100)
delay_ms = Keyword.get(opts, :delay_ms, 20)
Enum.reduce_while(1..max_attempts, nil, fn _, _ ->
case fun.() do
nil -> Process.sleep(delay_ms) && {:cont, nil}
false -> Process.sleep(delay_ms) && {:cont, nil}
result -> {:halt, result}
end
end) || fun.()
end
# Set up a device that has been discovered (so poll path runs without
# hitting the discovery branch first).
defp discover_device!(device) do
oid_values = %{
"1.3.6.1.2.1.1.1.0" => "Test Device",
"1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.1",
"1.3.6.1.2.1.1.3.0" => "123456",
"1.3.6.1.2.1.1.4.0" => "admin@test.com",
"1.3.6.1.2.1.1.5.0" => "test-device",
"1.3.6.1.2.1.1.6.0" => "Test Location"
}
{:ok, _discovered} = AgentDiscovery.process_agent_discovery(device, oid_values)
Towerops.Devices.get_device_with_details(device.id)
end
# ── stuck device_down alert resolution ──────────────────────────
describe "resolve_any_stuck_down_alerts" do
setup %{device: device} do
device = discover_device!(device)
# Mark device as up — this exercises the "stuck alert" branch where
# device is up but a device_down alert is still active.
{:ok, device} = Towerops.Devices.update_device_status(device, :up)
%{device: device}
end
test "resolves stuck device_down alert after successful poll", %{
socket: socket,
device: device
} do
now = DateTime.utc_now()
{:ok, alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: now,
message: "stuck"
})
# Sanity check — alert is active
assert Alerts.get_active_alert(device.id, :device_down)
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(now),
oid_values: %{}
}
push(socket, "result", encode_payload(result))
# Wait for alert to be resolved
resolved =
poll_until(fn ->
a = Alerts.get_alert!(alert.id)
if a.resolved_at, do: a
end)
assert resolved.resolved_at
refute Alerts.get_active_alert(device.id, :device_down)
end
test "resolves stuck device_up alert after successful poll", %{
socket: socket,
device: device
} do
now = DateTime.utc_now()
{:ok, alert} =
Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_up,
triggered_at: now,
message: "stuck up"
})
assert Alerts.get_active_alert(device.id, :device_up)
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(now),
oid_values: %{}
}
push(socket, "result", encode_payload(result))
resolved =
poll_until(fn ->
a = Alerts.get_alert!(alert.id)
if a.resolved_at, do: a
end)
assert resolved.resolved_at
end
end
# ── deprecated PING via SnmpResult ──────────────────────────────
describe "deprecated PING SnmpResult path" do
test "PING job_type in SnmpResult creates a monitoring check and does not crash",
%{socket: socket, device: device} do
device = discover_device!(device)
result = %SnmpResult{
device_id: device.id,
job_type: :PING,
job_id: "ping:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{}
}
push(socket, "result", encode_payload(result))
# Channel should still respond to subsequent messages
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
# ── live poll with invalid job_id ───────────────────────────────
describe "handle_live_poll_result invalid job_id" do
test "result with malformed live_poll job_id is logged and ignored",
%{socket: socket, device: device} do
# job_id starts with "live_poll:" but doesn't have 3 parts
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
# Missing reply_topic segment - exercises the catch-all warning branch
job_id: "live_poll:invalid",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{}
}
push(socket, "result", encode_payload(result))
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
# ── MikroTik job building when device matches RouterOS ─────────
describe "MikroTik job included when device sys_descr matches RouterOS" do
test "device with discovered MikroTik snmp_device gets a MIKROTIK job",
%{organization: organization} do
mt_device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: "public",
mikrotik_enabled: true,
mikrotik_username: "admin",
mikrotik_password: "password123",
mikrotik_credential_source: "device"
})
_snmp_device =
SnmpFixtures.snmp_device_fixture(%{
device_id: mt_device.id,
manufacturer: "MikroTik",
sys_descr: "RouterOS 7.10",
sys_object_id: "1.3.6.1.4.1.14988.1"
})
# Force needs_discovery? = true by leaving last_discovery_at nil
# (default), so build_jobs_for_device runs the MikroTik branch.
{:ok, agent_token, token_string} =
AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, mt_device.id)
{:ok, socket} = connect(AgentSocket, %{})
{:ok, _, _socket} =
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded} = Base.decode64(jobs_binary)
{:ok, job_list} = AgentJobList.decode(decoded)
mikrotik_job = Enum.find(job_list.jobs, &(&1.job_type == :MIKROTIK))
assert mikrotik_job, "expected MIKROTIK job; got: #{inspect(Enum.map(job_list.jobs, & &1.job_type))}"
assert mikrotik_job.device_id == mt_device.id
assert mikrotik_job.job_id == "mikrotik:#{mt_device.id}"
assert mikrotik_job.mikrotik_device.username == "admin"
assert mikrotik_job.mikrotik_device.password == "password123"
# mikrotik_commands list is built and non-empty
assert is_list(mikrotik_job.mikrotik_commands)
assert mikrotik_job.mikrotik_commands != []
end
test "device sys_descr containing 'RouterOS' (without MikroTik manufacturer) still triggers MIKROTIK job",
%{organization: organization} do
mt_device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: "public",
mikrotik_enabled: true,
mikrotik_username: "admin",
mikrotik_password: "password123",
mikrotik_credential_source: "device"
})
_snmp_device =
SnmpFixtures.snmp_device_fixture(%{
device_id: mt_device.id,
# Manufacturer is NOT MikroTik but sys_descr mentions RouterOS
manufacturer: "Generic Vendor",
sys_descr: "Some device running RouterOS 6.x",
sys_object_id: "1.3.6.1.4.1.99999.1"
})
{:ok, agent_token, token_string} =
AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, mt_device.id)
{:ok, socket} = connect(AgentSocket, %{})
{:ok, _, _socket} =
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded} = Base.decode64(jobs_binary)
{:ok, job_list} = AgentJobList.decode(decoded)
assert Enum.any?(job_list.jobs, &(&1.job_type == :MIKROTIK))
end
test "MikroTik device without credentials does not produce a MIKROTIK job",
%{organization: organization} do
mt_device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: "public",
# API not enabled
mikrotik_api_enabled: false,
mikrotik_username: nil,
mikrotik_password: nil
})
_snmp_device =
SnmpFixtures.snmp_device_fixture(%{
device_id: mt_device.id,
manufacturer: "MikroTik",
sys_descr: "RouterOS 7"
})
{:ok, agent_token, token_string} =
AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, mt_device.id)
{:ok, socket} = connect(AgentSocket, %{})
{:ok, _, _socket} =
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded} = Base.decode64(jobs_binary)
{:ok, job_list} = AgentJobList.decode(decoded)
refute Enum.any?(job_list.jobs, &(&1.job_type == :MIKROTIK))
end
end
# ── poll with extra OIDs to trigger process_additional_polling_data ──
describe "process_additional_polling_data" do
test "poll result with extra OIDs triggers additional processing without crash",
%{socket: socket, device: device} do
device = discover_device!(device)
{:ok, _device} = Towerops.Devices.update_device_status(device, :up)
# Generate enough OIDs to exceed sensors + interfaces*6 threshold
# (which on a freshly discovered device is small).
extra_oids =
for i <- 1..50, into: %{} do
{"1.3.6.1.2.1.999.#{i}.0", "value-#{i}"}
end
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: extra_oids
}
push(socket, "result", encode_payload(result))
# The Task.Supervisor child runs async, but channel must still be alive.
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
end