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