towerops/test/towerops_web/channels/agent_channel_builders_test.exs
2026-06-15 11:15:46 -05:00

718 lines
23 KiB
Elixir

defmodule ToweropsWeb.AgentChannelBuildersTest do
@moduledoc """
Coverage for ToweropsWeb.AgentChannel internal builders exercised through
the public channel API:
- `build_polling_queries/1` — exercised via :send_jobs after discovery
- `build_discovery_queries/0` — exercised via initial dispatch
- `build_v2c_snmp_device/2` — v1/v2c SNMP variants
- `build_v3_snmp_device/2` — v3 credentials
- `resolve_snmp_credentials/1` / `credentials_present?/1`
- `process_sensor_readings_batch` / `resolve_sensor_value` for missing OIDs,
leading-dot normalization and bad values
- `process_interface_stats_batch` for HC vs 32-bit fallback
- `handle_info(:check_heartbeat)` — recent vs stale heartbeats
- `handle_info(:send_jobs)` for SNMPv1 / SNMPv3 / MikroTik combinations
"""
use Towerops.DataCase, async: false
import Phoenix.ChannelTest
alias Towerops.AccountsFixtures
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.SnmpResult
alias Towerops.AgentsFixtures
alias Towerops.DevicesFixtures
alias Towerops.OrganizationsFixtures
alias Towerops.Snmp.AgentDiscovery
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
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 poll_until(fun, opts \\ []) do
max_attempts = Keyword.get(opts, :max_attempts, 10)
delay_ms = Keyword.get(opts, :delay_ms, 20)
Enum.reduce_while(1..max_attempts, nil, fn _, _ ->
case fun.() do
nil ->
receive do
after
delay_ms -> :ok
end
{:cont, nil}
false ->
receive do
after
delay_ms -> :ok
end
{:cont, nil}
result ->
{:halt, result}
end
end) || fun.()
end
defp run_discovery(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",
# Interface 1
"1.3.6.1.2.1.2.2.1.1.1" => "1",
"1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1",
"1.3.6.1.2.1.2.2.1.3.1" => "6",
"1.3.6.1.2.1.2.2.1.5.1" => "1000000000",
"1.3.6.1.2.1.2.2.1.6.1" => "aa:bb:cc:dd:ee:ff",
"1.3.6.1.2.1.2.2.1.7.1" => "1",
"1.3.6.1.2.1.2.2.1.8.1" => "1"
}
{:ok, _} = AgentDiscovery.process_agent_discovery(device, oid_values)
Towerops.Devices.get_device_with_details(device.id)
end
defp connect_and_join(agent_token, token_string) do
{:ok, socket} = connect(AgentSocket, %{})
{:ok, _, socket} =
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
socket
end
defp decode_jobs(binary) do
{:ok, decoded} = Base.decode64(binary)
{:ok, job_list} = AgentJobList.decode(decoded)
job_list.jobs
end
# ── build_v2c_snmp_device variants ────────────────────────────────
describe "build_v2c_snmp_device variants" do
test "snmp_version=1 device produces an SnmpDevice with version=1", %{
organization: organization
} do
v1_device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "1",
snmp_community: "public-v1"
})
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, v1_device.id)
socket = connect_and_join(agent_token, token_string)
assert_push "jobs", %{binary: jobs_binary}
jobs = decode_jobs(jobs_binary)
job = Enum.find(jobs, &(&1.device_id == v1_device.id))
assert job.snmp_device.version == "1"
assert job.snmp_device.community == "public-v1"
assert job.snmp_device.port == 161
Process.flag(:trap_exit, true)
leave(socket)
end
test "snmp_version=2c device defaults port to 161 and uses community", %{
socket: socket,
device: device
} do
send(socket.channel_pid, :send_jobs)
assert_push "jobs", %{binary: jobs_binary}, 500
jobs = decode_jobs(jobs_binary)
job = Enum.find(jobs, &(&1.device_id == device.id))
assert job.snmp_device.version == "2c"
assert job.snmp_device.community == "public"
assert job.snmp_device.port == 161
assert job.snmp_device.v3_username == ""
end
test "device with custom snmp_port preserves port in built SnmpDevice", %{
organization: organization
} do
device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 1161
})
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
socket = connect_and_join(agent_token, token_string)
assert_push "jobs", %{binary: jobs_binary}
jobs = decode_jobs(jobs_binary)
job = Enum.find(jobs, &(&1.device_id == device.id))
assert job.snmp_device.port == 1161
Process.flag(:trap_exit, true)
leave(socket)
end
end
# ── build_v3_snmp_device variants ─────────────────────────────────
describe "build_v3_snmp_device variants" do
test "v3 authPriv device populates auth and priv protocols/passwords", %{
organization: organization
} do
device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "3",
snmp_community: "",
snmpv3_username: "alice",
snmpv3_auth_protocol: "SHA-256",
snmpv3_auth_password: "auth_pw_1234",
snmpv3_priv_protocol: "AES",
snmpv3_priv_password: "priv_pw_5678",
snmpv3_security_level: "authPriv",
snmpv3_credential_source: "device"
})
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
socket = connect_and_join(agent_token, token_string)
assert_push "jobs", %{binary: jobs_binary}
jobs = decode_jobs(jobs_binary)
job = Enum.find(jobs, &(&1.device_id == device.id))
assert job.snmp_device.version == "3"
assert job.snmp_device.v3_security_level == "authPriv"
assert job.snmp_device.v3_username == "alice"
assert job.snmp_device.v3_auth_protocol == "SHA-256"
assert job.snmp_device.v3_auth_password == "auth_pw_1234"
assert job.snmp_device.v3_priv_protocol == "AES"
assert job.snmp_device.v3_priv_password == "priv_pw_5678"
# v3 device should have empty community
assert job.snmp_device.community == ""
Process.flag(:trap_exit, true)
leave(socket)
end
test "v3 device with username but no auth password emits v3 job with empty auth", %{
organization: organization
} do
device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "3",
snmp_community: "",
snmpv3_username: "minimaluser",
snmpv3_credential_source: "device"
})
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
socket = connect_and_join(agent_token, token_string)
assert_push "jobs", %{binary: jobs_binary}
jobs = decode_jobs(jobs_binary)
job = Enum.find(jobs, &(&1.device_id == device.id))
assert job.snmp_device.version == "3"
assert job.snmp_device.v3_username == "minimaluser"
assert job.snmp_device.v3_auth_password == ""
assert job.snmp_device.v3_priv_password == ""
Process.flag(:trap_exit, true)
leave(socket)
end
end
# ── build_polling_queries (exercised via send_jobs) ────────────────
describe "build_polling_queries via send_jobs" do
setup %{device: device, organization: organization, agent_token: agent_token} do
device = run_discovery(device)
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
{:ok, sensor1} =
Towerops.Repo.insert(%Sensor{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1001",
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.1001",
sensor_descr: "CPU Temp",
sensor_unit: "celsius",
sensor_divisor: 1
})
{:ok, sensor2} =
Towerops.Repo.insert(%Sensor{
snmp_device_id: snmp_device.id,
sensor_type: "voltage",
sensor_index: "2001",
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.2.1.3.2001",
sensor_descr: "Voltage",
sensor_unit: "volts",
sensor_divisor: 10
})
device = Towerops.Devices.get_device_with_details(device.id)
%{
device: device,
snmp_device: snmp_device,
sensor1: sensor1,
sensor2: sensor2,
organization: organization,
agent_token: agent_token
}
end
test "polling job includes sensor OIDs and per-interface octet/error/discard OIDs", %{
socket: socket,
device: device,
sensor1: sensor1,
sensor2: sensor2
} do
# mark discovery complete so we issue a POLL job, not DISCOVER
{:ok, _} =
Towerops.Devices.update_device(device, %{last_discovery_at: DateTime.utc_now()})
send(socket.channel_pid, :send_jobs)
assert_push "jobs", %{binary: jobs_binary}, 1_000
jobs = decode_jobs(jobs_binary)
poll_job = Enum.find(jobs, &(&1.job_type == :POLL and &1.device_id == device.id))
get_query = Enum.find(poll_job.queries, &(&1.query_type == :GET))
assert sensor1.sensor_oid in get_query.oids
assert sensor2.sensor_oid in get_query.oids
# Interface OIDs (HC + 32-bit fallback) for the discovered interface
device = Towerops.Devices.get_device_with_details(device.id)
idx = hd(device.snmp_device.interfaces).if_index
assert "1.3.6.1.2.1.31.1.1.1.6.#{idx}" in get_query.oids
assert "1.3.6.1.2.1.31.1.1.1.10.#{idx}" in get_query.oids
assert "1.3.6.1.2.1.2.2.1.10.#{idx}" in get_query.oids
assert "1.3.6.1.2.1.2.2.1.16.#{idx}" in get_query.oids
assert "1.3.6.1.2.1.2.2.1.14.#{idx}" in get_query.oids
assert "1.3.6.1.2.1.2.2.1.20.#{idx}" in get_query.oids
assert "1.3.6.1.2.1.2.2.1.13.#{idx}" in get_query.oids
assert "1.3.6.1.2.1.2.2.1.19.#{idx}" in get_query.oids
# Walk queries: neighbor, ARP, MAC, IP, host-resources
walks = Enum.filter(poll_job.queries, &(&1.query_type == :WALK))
flat = Enum.flat_map(walks, & &1.oids)
# LLDP
assert "1.0.8802.1.1.2.1.4.1.1" in flat
# ARP table
assert "1.3.6.1.2.1.4.22" in flat
# MAC table
assert "1.3.6.1.2.1.17.4.3" in flat
# IP address tables
assert "1.3.6.1.2.1.4.20" in flat
# hrProcessorTable
assert "1.3.6.1.2.1.25.3.3" in flat
end
end
# ── build_discovery_queries (exercised via send_jobs) ─────────────
describe "build_discovery_queries via send_jobs" do
test "initial discovery job contains expected OID groups", %{socket: socket, device: device} do
send(socket.channel_pid, :send_jobs)
assert_push "jobs", %{binary: jobs_binary}, 500
jobs = decode_jobs(jobs_binary)
discover_job = Enum.find(jobs, &(&1.job_type == :DISCOVER and &1.device_id == device.id))
flat = Enum.flat_map(discover_job.queries, & &1.oids)
# System info GET OIDs
assert "1.3.6.1.2.1.1.1.0" in flat
assert "1.3.6.1.2.1.1.5.0" in flat
# Interface tables walks
assert "1.3.6.1.2.1.2.2.1" in flat
assert "1.3.6.1.2.1.31.1.1.1" in flat
# Sensor / Entity walks
assert "1.3.6.1.2.1.99.1.1.1" in flat
assert "1.3.6.1.2.1.47.1.1.1.1.2" in flat
# Vendor MIBs - MikroTik / Ubiquiti / Juniper
assert "1.3.6.1.4.1.14988" in flat
assert "1.3.6.1.4.1.41112" in flat
assert "1.3.6.1.4.1.2636" in flat
# IP addresses
assert "1.3.6.1.2.1.4.20" in flat
assert "1.3.6.1.2.1.4.34" in flat
end
end
# ── credentials_present? + resolve_snmp_credentials edge cases ────
describe "credential resolution edge cases" do
test "v2c device with empty community still produces a job with empty community", %{
organization: organization
} do
device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: ""
})
{:ok, agent_token, token_string} = AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
socket = connect_and_join(agent_token, token_string)
assert_push "jobs", %{binary: jobs_binary}
jobs = decode_jobs(jobs_binary)
job = Enum.find(jobs, &(&1.device_id == device.id))
assert job.device_id == device.id
assert job.snmp_device.community == ""
Process.flag(:trap_exit, true)
leave(socket)
end
end
# ── process_sensor_readings_batch / resolve_sensor_value paths ────
describe "resolve_sensor_value edge cases" do
setup %{device: device} do
device = run_discovery(device)
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
{:ok, ok_sensor} =
Towerops.Repo.insert(%Sensor{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "100",
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.100",
sensor_descr: "OK Sensor",
sensor_unit: "celsius",
sensor_divisor: 1
})
{:ok, missing_sensor} =
Towerops.Repo.insert(%Sensor{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "200",
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.200",
sensor_descr: "Missing Sensor",
sensor_unit: "celsius",
sensor_divisor: 1
})
{:ok, bad_value_sensor} =
Towerops.Repo.insert(%Sensor{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "300",
sensor_oid: "1.3.6.1.4.1.9.9.13.1.3.1.3.300",
sensor_descr: "Bad Value Sensor",
sensor_unit: "celsius",
sensor_divisor: 1
})
device = Towerops.Devices.get_device_with_details(device.id)
%{
device: device,
ok_sensor: ok_sensor,
missing_sensor: missing_sensor,
bad_value_sensor: bad_value_sensor
}
end
test "missing OID does not produce a sensor reading or update last_value", %{
socket: socket,
device: device,
ok_sensor: ok_sensor,
missing_sensor: missing_sensor
} do
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
ok_sensor.sensor_oid => "37"
}
}
push(socket, "result", encode_payload(result))
# Ok sensor is updated
assert poll_until(fn ->
s = Towerops.Repo.get!(Sensor, ok_sensor.id)
if s.last_value == 37.0, do: s
end)
# Missing sensor remains nil
missing_after = Towerops.Repo.get!(Sensor, missing_sensor.id)
assert is_nil(missing_after.last_value)
end
test "non-numeric OID value is ignored (resolve_sensor_value returns nil)", %{
socket: socket,
device: device,
bad_value_sensor: bad_value_sensor
} do
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
bad_value_sensor.sensor_oid => "not-a-number"
}
}
push(socket, "result", encode_payload(result))
# Wait for the polling result to be processed (state_sensors_updated broadcast)
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device.id}")
assert_receive {:state_sensors_updated, _}, 2_000
sensor_after = Towerops.Repo.get!(Sensor, bad_value_sensor.id)
assert is_nil(sensor_after.last_value)
end
test "leading-dot sensor_oid is normalized when looking up OID values", %{
socket: socket,
device: device
} do
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
{:ok, dotted_sensor} =
Towerops.Repo.insert(%Sensor{
snmp_device_id: snmp_device.id,
sensor_type: "fan",
sensor_index: "999",
sensor_oid: ".1.3.6.1.4.1.9.9.13.1.4.1.3.999",
sensor_descr: "Dotted Sensor",
sensor_unit: "rpm",
sensor_divisor: 1
})
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
# Channel normalizes "." prefix on OID keys; sensor.sensor_oid also strips "."
"1.3.6.1.4.1.9.9.13.1.4.1.3.999" => "2400"
}
}
push(socket, "result", encode_payload(result))
assert poll_until(fn ->
s = Towerops.Repo.get!(Sensor, dotted_sensor.id)
if s.last_value == 2400.0, do: s
end)
end
end
# ── process_interface_stats_batch HC vs 32-bit fallback ───────────
describe "process_interface_stats_batch counter fallback" do
setup %{device: device} do
device = run_discovery(device)
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
# Add an extra interface so we can test multiple counter-fallback paths
{:ok, second_iface} =
Towerops.Repo.insert(%Interface{
snmp_device_id: snmp_device.id,
if_index: 2,
if_name: "GigabitEthernet0/2",
if_descr: "GigabitEthernet0/2",
if_admin_status: "up",
if_oper_status: "up"
})
device = Towerops.Devices.get_device_with_details(device.id)
%{device: device, snmp_device: snmp_device, second_iface: second_iface}
end
test "uses 32-bit fallback when HC counter OID is missing", %{
socket: socket,
device: device,
second_iface: iface
} do
idx = iface.if_index
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
# No HC counters; only 32-bit
"1.3.6.1.2.1.2.2.1.10.#{idx}" => "12345",
"1.3.6.1.2.1.2.2.1.16.#{idx}" => "67890"
}
}
push(socket, "result", encode_payload(result))
stats =
poll_until(fn ->
r = Towerops.Snmp.get_interface_stats(iface.id)
if r != [], do: r
end)
assert is_list(stats) and stats != []
stat = hd(stats)
assert stat.if_in_octets == 12_345
assert stat.if_out_octets == 67_890
end
test "prefers HC counters when both HC and 32-bit are present", %{
socket: socket,
device: device,
second_iface: iface
} do
idx = iface.if_index
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
"1.3.6.1.2.1.31.1.1.1.6.#{idx}" => "9999999999",
"1.3.6.1.2.1.31.1.1.1.10.#{idx}" => "8888888888",
"1.3.6.1.2.1.2.2.1.10.#{idx}" => "1",
"1.3.6.1.2.1.2.2.1.16.#{idx}" => "2"
}
}
push(socket, "result", encode_payload(result))
stats =
poll_until(fn ->
r = Towerops.Snmp.get_interface_stats(iface.id)
if r != [], do: r
end)
assert is_list(stats) and stats != []
stat = hd(stats)
assert stat.if_in_octets == 9_999_999_999
assert stat.if_out_octets == 8_888_888_888
end
end
# ── handle_info(:check_heartbeat) ─────────────────────────────────
describe "handle_info(:check_heartbeat)" do
test "channel survives :check_heartbeat when last_heartbeat_at is recent", %{socket: socket} do
send(socket.channel_pid, :check_heartbeat)
_ = :sys.get_state(socket.channel_pid)
assert Process.alive?(socket.channel_pid)
end
test "channel stops with :heartbeat_timeout when last_heartbeat_at is stale", %{
socket: socket
} do
Process.flag(:trap_exit, true)
ref = Process.monitor(socket.channel_pid)
stale = DateTime.add(DateTime.utc_now(), -10_000, :second)
:sys.replace_state(socket.channel_pid, fn s ->
Phoenix.Socket.assign(s, :last_heartbeat_at, stale)
end)
send(socket.channel_pid, :check_heartbeat)
assert_receive {:DOWN, ^ref, :process, _, {:shutdown, :heartbeat_timeout}}, 1_000
end
end
# ── handle_info({:update_requested, url, checksum}) does not stop ─
describe "handle_info(:update_requested) channel state" do
test "channel remains alive after update is requested", %{socket: socket} do
send(socket.channel_pid, {:update_requested, "https://example.com/agent.bin", "sha256:abc"})
assert_push "update", %{url: "https://example.com/agent.bin", checksum: "sha256:abc"}, 200
_ = :sys.get_state(socket.channel_pid)
assert Process.alive?(socket.channel_pid)
end
end
# ── handle_info({:live_poll_requested, ...}) for missing device ──
describe "handle_info(:live_poll_requested) error path" do
test "missing device does not crash channel", %{socket: socket} do
bogus_id = Ecto.UUID.generate()
send(socket.channel_pid, {:live_poll_requested, bogus_id, ["1.3.6.1.2.1.1.1.0"], "topic"})
_ = :sys.get_state(socket.channel_pid)
assert Process.alive?(socket.channel_pid)
end
end
end