718 lines
23 KiB
Elixir
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
|