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
399 lines
13 KiB
Elixir
399 lines
13 KiB
Elixir
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
|