towerops/test/towerops_web/channels/agent_channel_test.exs
Graham McIntire b650068923
perf(test): further optimize slow tests and fix CI failures
Test Performance Improvements:
- Reduce SNMP test timeouts from 2000ms to 100ms (walk and bulk tests)
- Reduce AgentChannelTest assert_push timeouts from 2000ms to 1000ms
- Reduce Process.sleep delays: 200ms → 50ms, 100ms → 25ms
- Results: Top 10 slowest 14.1s → 10.5s (26% faster)

CI Test Fixes:
- MIBStubsTest: Increase performance threshold from 10ms to 20ms for slower CI runners
- LoginHistoryCleanupWorkerTest: Fix boundary test using 364 days instead of 365
  to account for timing difference between test setup and worker execution

Overall: 7422 tests, 0 failures, suite runs in 57.4s
2026-03-05 14:42:02 -06:00

1301 lines
44 KiB
Elixir

defmodule ToweropsWeb.AgentChannelTest do
use Towerops.DataCase, async: false
import Phoenix.ChannelTest
alias Towerops.AccountsFixtures
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MikrotikSentence
alias Towerops.Agent.MonitoringCheck
alias Towerops.Agent.SnmpResult
alias Towerops.Agents
alias Towerops.AgentsFixtures
alias Towerops.DevicesFixtures
alias Towerops.OrganizationsFixtures
alias Towerops.Snmp.AgentDiscovery
alias Towerops.Snmp.Sensor
alias ToweropsWeb.AgentSocket
@endpoint ToweropsWeb.Endpoint
setup do
# Create organization, site, device, and agent token
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)
# Assign device to agent
{:ok, _assignment} =
AgentsFixtures.agent_assignment_fixture(agent_token.id, device.id)
# Connect socket and join channel
{:ok, socket} = connect(AgentSocket, %{})
{:ok, _, socket} =
subscribe_and_join(socket, "agent:#{agent_token.id}", %{"token" => token_string})
# Drain the initial :send_jobs message pushed on join
assert_push "jobs", _initial_jobs
%{
socket: socket,
device: device,
agent_token: agent_token,
token_string: token_string,
organization: organization
}
end
# Helper to encode a protobuf struct to base64 payload map
defp encode_payload(protobuf_struct) do
binary = protobuf_struct.__struct__.encode(protobuf_struct)
%{"binary" => Base.encode64(binary)}
end
defp build_heartbeat(attrs \\ %{}) do
Map.merge(
%AgentHeartbeat{
version: "1.0.0",
hostname: "test-agent",
uptime_seconds: 3600,
ip_address: "",
arch: "x86_64"
},
attrs
)
end
defp build_agent_error(device_id) do
%AgentError{
device_id: device_id,
job_id: "poll:#{device_id}",
message: "SNMP timeout",
timestamp: DateTime.to_unix(DateTime.utc_now())
}
end
defp build_monitoring_check(device_id, status) do
%MonitoringCheck{
device_id: device_id,
status: status,
response_time_ms: 10.0,
timestamp: DateTime.to_unix(DateTime.utc_now())
}
end
defp build_credential_test_result(test_id, success) do
%CredentialTestResult{
test_id: test_id,
success: success,
error_message: if(success, do: "", else: "Connection refused"),
system_description: if(success, do: "Test Device v1.0", else: ""),
timestamp: DateTime.to_unix(DateTime.utc_now())
}
end
defp build_mikrotik_result(device_id, opts \\ []) do
%MikrotikResult{
device_id: device_id,
job_id: Keyword.get(opts, :job_id, "mikrotik:#{device_id}"),
sentences: Keyword.get(opts, :sentences, []),
error: Keyword.get(opts, :error, ""),
timestamp: DateTime.to_unix(DateTime.utc_now())
}
end
# ── Stage 1: join/3 and terminate/2 ──────────────────────────────────
describe "join/3" do
test "join with invalid token returns error" do
{:ok, socket} = connect(AgentSocket, %{})
assert {:error, %{reason: "unauthorized"}} =
subscribe_and_join(socket, "agent:some-id", %{"token" => "invalid-token"})
end
test "join with missing token returns error" do
{:ok, socket} = connect(AgentSocket, %{})
assert {:error, %{reason: "missing token"}} =
subscribe_and_join(socket, "agent:some-id", %{})
end
test "join sets socket assigns", %{socket: socket, agent_token: agent_token, organization: organization} do
assert socket.assigns.agent_token_id == agent_token.id
assert socket.assigns.organization_id == organization.id
assert Map.has_key?(socket.assigns, :debug_enabled)
assert Map.has_key?(socket.assigns, :last_heartbeat_at)
end
test "join broadcasts agent_connected", %{organization: organization} do
# Subscribe to health topic before joining
Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
{:ok, new_agent_token, new_token_string} =
AgentsFixtures.agent_token_fixture(organization.id)
new_agent_token_id = new_agent_token.id
{:ok, new_socket} = connect(AgentSocket, %{})
{:ok, _, _socket} =
subscribe_and_join(new_socket, "agent:#{new_agent_token.id}", %{"token" => new_token_string})
assert_receive {:agent_connected, ^new_agent_token_id, _org_id}
end
test "terminate broadcasts agent_disconnected", %{socket: socket, agent_token: agent_token} do
Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
agent_token_id = agent_token.id
Process.flag(:trap_exit, true)
leave(socket)
assert_receive {:agent_disconnected, ^agent_token_id, _org_id}
end
end
# ── Stage 2: handle_in "heartbeat" ──────────────────────────────────
describe "handle_in heartbeat" do
test "valid heartbeat updates agent token in DB", %{socket: socket, agent_token: agent_token} do
heartbeat = build_heartbeat(%{version: "2.0.0"})
payload = encode_payload(heartbeat)
push(socket, "heartbeat", payload)
# Give async processing a moment
Process.sleep(25)
updated_token = Agents.get_agent_token!(agent_token.id)
assert updated_token.last_seen_at
assert updated_token.metadata["version"] == "2.0.0"
end
test "valid heartbeat broadcasts to agents:health", %{socket: socket, agent_token: agent_token} do
Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
agent_token_id = agent_token.id
heartbeat = build_heartbeat()
payload = encode_payload(heartbeat)
push(socket, "heartbeat", payload)
assert_receive {:agent_heartbeat, ^agent_token_id, _org_id}
end
test "invalid base64 heartbeat is handled gracefully", %{socket: socket} do
push(socket, "heartbeat", %{"binary" => "not-valid-base64!!!"})
# Channel should not crash - verify by sending another message
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
test "invalid protobuf heartbeat is handled gracefully", %{socket: socket} do
push(socket, "heartbeat", %{"binary" => Base.encode64("not-a-protobuf")})
# Channel should not crash
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
# ── Stage 3: handle_in "error" and "credential_test_result" ─────────
describe "handle_in error" do
test "valid error message is handled without crash", %{socket: socket, device: device} do
error = build_agent_error(device.id)
payload = encode_payload(error)
push(socket, "error", payload)
# Channel should not crash - verify with another message
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
test "invalid base64 error payload is handled gracefully", %{socket: socket} do
push(socket, "error", %{"binary" => "not-valid-base64!!!"})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
describe "handle_in credential_test_result" do
test "valid credential test result is broadcast", %{socket: socket} do
test_id = Ecto.UUID.generate()
# Subscribe to the credential test topic
Phoenix.PubSub.subscribe(Towerops.PubSub, "credential_test:#{test_id}")
result = build_credential_test_result(test_id, true)
payload = encode_payload(result)
push(socket, "credential_test_result", payload)
assert_receive {:credential_test_result, received_result}
assert received_result.test_id == test_id
assert received_result.success == true
end
test "failed credential test result is broadcast with error", %{socket: socket} do
test_id = Ecto.UUID.generate()
Phoenix.PubSub.subscribe(Towerops.PubSub, "credential_test:#{test_id}")
result = build_credential_test_result(test_id, false)
payload = encode_payload(result)
push(socket, "credential_test_result", payload)
assert_receive {:credential_test_result, received_result}
assert received_result.success == false
assert received_result.error_message == "Connection refused"
end
test "invalid base64 credential test result is handled gracefully", %{socket: socket} do
push(socket, "credential_test_result", %{"binary" => "not-valid-base64!!!"})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
# ── Stage 4: handle_in "monitoring_check" ───────────────────────────
describe "handle_in monitoring_check" do
test "valid monitoring check creates check record", %{socket: socket, device: device} do
import Ecto.Query
check = build_monitoring_check(device.id, "success")
payload = encode_payload(check)
push(socket, "monitoring_check", payload)
Process.sleep(25)
# Verify check was stored
checks = Towerops.Repo.all(from(m in Towerops.Monitoring.MonitoringCheck, where: m.device_id == ^device.id))
assert checks != []
assert hd(checks).status == "success"
end
test "monitoring check updates device status to up on success", %{socket: socket, device: device} do
check = build_monitoring_check(device.id, "success")
payload = encode_payload(check)
push(socket, "monitoring_check", payload)
Process.sleep(25)
updated_device = Towerops.Devices.get_device!(device.id)
assert updated_device.status == :up
end
test "monitoring check creates alert on status change to down", %{socket: socket, device: device} do
# First set device as up
Towerops.Devices.update_device_status(device, :up)
check = build_monitoring_check(device.id, "failure")
payload = encode_payload(check)
push(socket, "monitoring_check", payload)
Process.sleep(25)
updated_device = Towerops.Devices.get_device!(device.id)
assert updated_device.status == :down
# Verify alert was created
assert Towerops.Alerts.has_active_alert?(device.id, :device_down)
end
test "monitoring check creates device_up alert on recovery", %{socket: socket, device: device} do
# Set device as down first
Towerops.Devices.update_device_status(device, :down)
check = build_monitoring_check(device.id, "success")
payload = encode_payload(check)
push(socket, "monitoring_check", payload)
Process.sleep(25)
updated_device = Towerops.Devices.get_device!(device.id)
assert updated_device.status == :up
end
test "invalid base64 monitoring check is handled gracefully", %{socket: socket} do
push(socket, "monitoring_check", %{"binary" => "not-valid-base64!!!"})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
test "invalid protobuf monitoring check is handled gracefully", %{socket: socket} do
push(socket, "monitoring_check", %{"binary" => Base.encode64("not-a-protobuf")})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
# ── Stage 5: handle_in "mikrotik_result" and catch-all ─────────────
describe "handle_in mikrotik_result" do
test "valid mikrotik result is processed without crash", %{socket: socket, device: device} do
result = build_mikrotik_result(device.id)
payload = encode_payload(result)
push(socket, "mikrotik_result", payload)
# Channel should not crash
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
test "mikrotik result with sentences is processed", %{socket: socket, device: device} do
sentence = %MikrotikSentence{
attributes: %{"name" => "router1"}
}
result = build_mikrotik_result(device.id, sentences: [sentence])
payload = encode_payload(result)
push(socket, "mikrotik_result", payload)
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
test "invalid base64 mikrotik result is handled gracefully", %{socket: socket} do
push(socket, "mikrotik_result", %{"binary" => "not-valid-base64!!!"})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
test "invalid protobuf mikrotik result is handled gracefully", %{socket: socket} do
push(socket, "mikrotik_result", %{"binary" => Base.encode64("not-a-protobuf")})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
describe "handle_in catch-all" do
test "unknown event is handled gracefully", %{socket: socket} do
push(socket, "unknown_event", %{"data" => "test"})
# Channel should not crash
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
end
# ── Stage 6: handle_info lifecycle messages ─────────────────────────
describe "handle_info :send_jobs" do
test "pushes jobs to channel", %{socket: socket} do
send(socket.channel_pid, :send_jobs)
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
assert is_list(job_list.jobs)
end
end
describe "handle_info :check_heartbeat" do
test "reschedules when heartbeat is recent", %{socket: socket} do
# last_heartbeat_at was set during join (recent), so check should pass
send(socket.channel_pid, :check_heartbeat)
# Channel should still be alive
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
test "stops channel when heartbeat is stale", %{socket: socket} do
Process.flag(:trap_exit, true)
# Monitor the channel process before it stops
ref = Process.monitor(socket.channel_pid)
# Set last_heartbeat_at to >300 seconds ago to trigger timeout
stale_time = DateTime.add(DateTime.utc_now(), -400, :second)
# Phoenix Channel.Server GenServer state is the socket struct directly
:sys.replace_state(socket.channel_pid, fn socket_state ->
Phoenix.Socket.assign(socket_state, :last_heartbeat_at, stale_time)
end)
send(socket.channel_pid, :check_heartbeat)
assert_receive {:DOWN, ^ref, :process, _, _}, 5_000
end
end
describe "handle_info :token_disabled" do
test "stops channel", %{socket: socket} do
Process.flag(:trap_exit, true)
ref = Process.monitor(socket.channel_pid)
send(socket.channel_pid, :token_disabled)
assert_receive {:DOWN, ^ref, :process, _, _}, 5_000
end
end
describe "handle_info :restart_requested" do
test "pushes restart and stops channel", %{socket: socket} do
Process.flag(:trap_exit, true)
ref = Process.monitor(socket.channel_pid)
send(socket.channel_pid, :restart_requested)
assert_push "restart", %{}
assert_receive {:DOWN, ^ref, :process, _, _}, 5_000
end
end
describe "handle_info {:update_requested, url, checksum}" do
test "pushes update with url and checksum", %{socket: socket} do
send(socket.channel_pid, {:update_requested, "https://example.com/agent.tar.gz", "sha256:abc123"})
assert_push "update", %{url: "https://example.com/agent.tar.gz", checksum: "sha256:abc123"}
end
end
# ── Stage 7: handle_info PubSub messages ────────────────────────────
describe "handle_info {:assignments_changed, _}" do
test "triggers debounced job refresh", %{socket: socket} do
send(socket.channel_pid, {:assignments_changed, :assigned})
# Jobs push should come after 500ms debounce
assert_push "jobs", %{binary: _jobs_binary}, 1_000
end
test "debounces rapid assignment changes", %{socket: socket} do
# Send multiple rapid changes
send(socket.channel_pid, {:assignments_changed, :assigned})
send(socket.channel_pid, {:assignments_changed, :unassigned})
send(socket.channel_pid, {:assignments_changed, :assigned})
# Should only get one push (debounced) - reduced timeout for faster tests
assert_push "jobs", %{binary: _}, 1_000
refute_push "jobs", %{}, 200
end
end
describe "handle_info {:discovery_requested, device_id}" do
test "pushes discovery job for valid device", %{socket: socket, device: device} do
send(socket.channel_pid, {:discovery_requested, device.id})
assert_push "discovery_job", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
discover_job = Enum.find(job_list.jobs, &(&1.job_type == :DISCOVER))
assert discover_job
assert discover_job.device_id == device.id
end
test "handles nonexistent device without crash", %{socket: socket} do
send(socket.channel_pid, {:discovery_requested, Ecto.UUID.generate()})
# Channel should not crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
describe "handle_info {:credential_test_requested, test_id, config}" do
test "pushes credential test job", %{socket: socket} do
test_id = Ecto.UUID.generate()
snmp_config = [
ip: "192.168.1.100",
port: 161,
version: "2c",
community: "public",
v3_security_level: "",
v3_username: "",
v3_auth_protocol: "",
v3_auth_password: "",
v3_priv_protocol: "",
v3_priv_password: ""
]
send(socket.channel_pid, {:credential_test_requested, test_id, snmp_config})
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
test_job = Enum.find(job_list.jobs, &(&1.job_type == :TEST_CREDENTIALS))
assert test_job
assert test_job.job_id == test_id
assert test_job.snmp_device.ip == "192.168.1.100"
assert test_job.snmp_device.community == "public"
end
end
describe "handle_info {:live_poll_requested, ...}" do
test "pushes live poll job for valid device", %{socket: socket, device: device} do
sensor_oids = ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0"]
reply_topic = "live_poll:#{device.id}:sensor_data"
send(socket.channel_pid, {:live_poll_requested, device.id, sensor_oids, reply_topic})
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
poll_job = Enum.find(job_list.jobs, &(&1.job_type == :POLL))
assert poll_job
assert poll_job.device_id == device.id
assert poll_job.job_id == "live_poll:#{device.id}:#{reply_topic}"
# Verify requested OIDs are in the query
query_oids = Enum.flat_map(poll_job.queries, & &1.oids)
assert "1.3.6.1.2.1.1.1.0" in query_oids
assert "1.3.6.1.2.1.1.3.0" in query_oids
end
test "handles nonexistent device without crash", %{socket: socket} do
send(
socket.channel_pid,
{:live_poll_requested, Ecto.UUID.generate(), ["1.3.6.1.2.1.1.1.0"], "reply:topic"}
)
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
describe "handle_info {:live_poll_timeout, reply_topic}" do
test "broadcasts error via channel process", %{socket: socket} do
reply_topic = "live_poll:test:timeout_test"
Phoenix.PubSub.subscribe(Towerops.PubSub, reply_topic)
send(socket.channel_pid, {:live_poll_timeout, reply_topic})
assert_receive {:live_poll_error, :timeout}, 1_000
end
end
describe "handle_info {:backup_requested, job}" do
test "pushes backup job to agent", %{socket: socket, device: device} do
job = %AgentJob{
job_id: "backup:#{device.id}",
job_type: :MIKROTIK,
device_id: device.id,
mikrotik_device: nil,
mikrotik_commands: []
}
send(socket.channel_pid, {:backup_requested, job})
assert_push "backup_job", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
backup_job = hd(job_list.jobs)
assert backup_job.job_id == "backup:#{device.id}"
assert backup_job.device_id == device.id
end
end
describe "handle_info {:poll_after_discovery, device_id}" do
test "pushes poll jobs for discovered device", %{socket: socket, device: device} do
# First run discovery so device has an snmp_device record
# (build_polling_queries requires device.snmp_device.sensors and .interfaces)
discovery_result = %SnmpResult{
device_id: device.id,
job_type: :DISCOVER,
job_id: "discover:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
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"
}
}
push(socket, "result", encode_payload(discovery_result))
# Wait for discovery to complete and the automatic poll_after_discovery
assert_push "jobs", %{binary: jobs_binary}, 5_000
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
# Should have at least a poll job
assert job_list.jobs != []
end
test "handles nonexistent device without crash", %{socket: socket} do
Process.flag(:trap_exit, true)
send(socket.channel_pid, {:poll_after_discovery, Ecto.UUID.generate()})
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
# ── Stage 8: SNMP result edge cases ─────────────────────────────────
describe "handle_in result edge cases" do
test "invalid base64 result is handled gracefully", %{socket: socket} do
push(socket, "result", %{"binary" => "not-valid-base64!!!"})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
test "invalid protobuf result is handled gracefully", %{socket: socket} do
push(socket, "result", %{"binary" => Base.encode64("not-a-protobuf")})
ref = push(socket, "heartbeat", encode_payload(build_heartbeat()))
refute_reply ref, :error
end
test "result for nonexistent device is handled", %{socket: socket} do
result = %SnmpResult{
device_id: Ecto.UUID.generate(),
job_type: :POLL,
job_id: "poll:#{Ecto.UUID.generate()}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{"1.3.6.1.2.1.1.1.0" => "Test"}
}
payload = encode_payload(result)
push(socket, "result", payload)
# Channel should not crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
test "result for device in wrong organization is rejected", %{socket: socket} do
# Create a device in a different organization
other_user = AccountsFixtures.user_fixture()
other_org = OrganizationsFixtures.organization_fixture(other_user.id)
other_device =
DevicesFixtures.device_fixture(%{
organization_id: other_org.id,
snmp_version: "2c",
snmp_community: "public"
})
result = %SnmpResult{
device_id: other_device.id,
job_type: :POLL,
job_id: "poll:#{other_device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{"1.3.6.1.2.1.1.1.0" => "Test"}
}
payload = encode_payload(result)
push(socket, "result", payload)
# Channel should not crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
test "live poll result broadcasts to reply topic", %{socket: socket, device: device} do
reply_topic = "live_poll:#{device.id}:sensor_data"
Phoenix.PubSub.subscribe(Towerops.PubSub, reply_topic)
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "live_poll:#{device.id}:#{reply_topic}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
"1.3.6.1.2.1.1.1.0" => "Test Device",
"1.3.6.1.2.1.1.3.0" => "123456"
}
}
payload = encode_payload(result)
push(socket, "result", payload)
assert_receive {:live_poll_result, oid_values}, 2_000
assert Map.has_key?(oid_values, "1.3.6.1.2.1.1.1.0")
end
end
# ── Stage 9: Polling result processing with sensors/interfaces ─────
describe "poll result with sensor and interface data" do
setup %{device: device} do
# First run discovery to create snmp_device record
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 data
"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, _discovered} = AgentDiscovery.process_agent_discovery(device, oid_values)
# Create a sensor on the snmp_device
snmp_device = Towerops.Snmp.get_device_with_associations(device.id)
{:ok, sensor} =
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 Temperature",
sensor_unit: "celsius",
sensor_divisor: 1
})
# Reload device with all associations
device_with_details = Towerops.Devices.get_device_with_details(device.id)
%{
discovered_device: device_with_details,
snmp_device: snmp_device,
sensor: sensor,
interface: hd(snmp_device.interfaces)
}
end
test "poll result stores sensor readings", %{
socket: socket,
device: device,
sensor: 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: %{
sensor.sensor_oid => "45"
}
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
# Verify sensor reading was stored
updated_sensor = Towerops.Repo.get!(Sensor, sensor.id)
assert updated_sensor.last_value == 45.0
end
test "poll result stores interface stats", %{
socket: socket,
device: device,
interface: interface
} do
idx = interface.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}" => "1000000",
"1.3.6.1.2.1.31.1.1.1.10.#{idx}" => "2000000",
"1.3.6.1.2.1.2.2.1.14.#{idx}" => "5",
"1.3.6.1.2.1.2.2.1.20.#{idx}" => "3",
"1.3.6.1.2.1.2.2.1.13.#{idx}" => "1",
"1.3.6.1.2.1.2.2.1.19.#{idx}" => "0"
}
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
# Verify interface stat was stored
stats = Towerops.Snmp.get_interface_stats(interface.id)
assert stats != []
stat = hd(stats)
assert stat.if_in_octets == 1_000_000
assert stat.if_out_octets == 2_000_000
end
test "poll result with leading dot OIDs is normalized", %{
socket: socket,
device: device,
sensor: sensor
} do
# Agent sends OIDs with leading dots, channel should normalize them
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
".#{sensor.sensor_oid}" => "72"
}
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
updated_sensor = Towerops.Repo.get!(Sensor, sensor.id)
assert updated_sensor.last_value == 72.0
end
test "poll result with sensor divisor scales value", %{
socket: socket,
device: device,
snmp_device: snmp_device
} do
# Create a sensor with divisor of 10
{:ok, sensor_with_divisor} =
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 Rail",
sensor_unit: "volts",
sensor_divisor: 10
})
result = %SnmpResult{
device_id: device.id,
job_type: :POLL,
job_id: "poll:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
sensor_with_divisor.sensor_oid => "120"
}
}
push(socket, "result", encode_payload(result))
Process.sleep(50)
updated_sensor = Towerops.Repo.get!(Sensor, sensor_with_divisor.id)
assert updated_sensor.last_value == 12.0
end
end
# ── Stage 10: SNMPv3 credential path ────────────────────────────────
describe "SNMPv3 device job building" do
test "sends discovery job with v3 credentials", %{organization: organization} do
# Create a v3 device with required SNMPv3 credentials
v3_device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "3",
snmp_community: "",
snmpv3_username: "testuser",
snmpv3_auth_protocol: "SHA-256",
snmpv3_auth_password: "authpass123",
snmpv3_priv_protocol: "AES",
snmpv3_priv_password: "privpass123",
snmpv3_security_level: "authPriv",
snmpv3_credential_source: "device"
})
{:ok, agent_token2, token_string2} =
AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token2.id, v3_device.id)
{:ok, socket2} = connect(AgentSocket, %{})
{:ok, _, socket2} =
subscribe_and_join(socket2, "agent:#{agent_token2.id}", %{"token" => token_string2})
# Drain initial jobs
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
discover_job = Enum.find(job_list.jobs, &(&1.job_type == :DISCOVER))
assert discover_job
assert discover_job.snmp_device.version == "3"
Process.flag(:trap_exit, true)
leave(socket2)
end
end
# ── Stage 11: Poll after discovery with monitoring enabled ──────────
describe "poll after discovery with monitoring" do
test "includes ping job when monitoring is enabled", %{organization: organization} do
# Create device with monitoring enabled
monitored_device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: "public",
monitoring_enabled: true
})
{:ok, agent_token3, token_string3} =
AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(agent_token3.id, monitored_device.id)
{:ok, socket3} = connect(AgentSocket, %{})
{:ok, _, socket3} =
subscribe_and_join(socket3, "agent:#{agent_token3.id}", %{"token" => token_string3})
# Drain initial jobs push
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
# Should have both a discover/poll job and a ping job
ping_job = Enum.find(job_list.jobs, &(&1.job_type == :PING))
assert ping_job
assert ping_job.device_id == monitored_device.id
Process.flag(:trap_exit, true)
leave(socket3)
end
end
# ── Stage 12: Device reassignment rejection ─────────────────────────
describe "device reassignment" do
test "rejects result for device assigned to a different agent", %{
socket: socket,
organization: organization
} do
# Create a device assigned to a different agent
other_device =
DevicesFixtures.device_fixture(%{
organization_id: organization.id,
snmp_version: "2c",
snmp_community: "public"
})
{:ok, other_agent, _} = AgentsFixtures.agent_token_fixture(organization.id)
{:ok, _} = AgentsFixtures.agent_assignment_fixture(other_agent.id, other_device.id)
result = %SnmpResult{
device_id: other_device.id,
job_type: :POLL,
job_id: "poll:#{other_device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{"1.3.6.1.2.1.1.1.0" => "Test"}
}
push(socket, "result", encode_payload(result))
# Channel should handle gracefully without crash
Process.sleep(50)
assert Process.alive?(socket.channel_pid)
end
end
# ── Existing tests (preserved) ──────────────────────────────────────
describe "poll after discovery" do
test "pushes poll jobs after successful discovery result", %{
socket: socket,
device: device
} do
# Build a minimal discovery result with system info OIDs
# that AgentDiscovery.process_agent_discovery can handle
discovery_result = %SnmpResult{
device_id: device.id,
job_type: :DISCOVER,
job_id: "discover:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
# sysDescr
"1.3.6.1.2.1.1.1.0" => "Test Device Description",
# sysObjectID
"1.3.6.1.2.1.1.2.0" => "1.3.6.1.4.1.9.1.1",
# sysUpTime
"1.3.6.1.2.1.1.3.0" => "123456",
# sysContact
"1.3.6.1.2.1.1.4.0" => "admin@test.com",
# sysName
"1.3.6.1.2.1.1.5.0" => "test-device",
# sysLocation
"1.3.6.1.2.1.1.6.0" => "Test Location"
}
}
binary = SnmpResult.encode(discovery_result)
payload = %{"binary" => Base.encode64(binary)}
# Send the discovery result to the channel
push(socket, "result", payload)
# Should receive a "jobs" push with poll job(s) for the discovered device
assert_push "jobs", %{binary: jobs_binary}, 5_000
# Decode the job list and verify it contains a POLL job for our device
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
poll_job =
Enum.find(job_list.jobs, fn job ->
job.job_type == :POLL and job.device_id == device.id
end)
assert poll_job != nil, "Expected a POLL job for device #{device.id}"
assert poll_job.job_id == "poll:#{device.id}"
end
test "poll job uses 64-bit HC counters for interface octets", %{
socket: socket,
device: device
} do
# Build discovery result with interface data so the poll job
# will include interface OIDs
discovery_result = %SnmpResult{
device_id: device.id,
job_type: :DISCOVER,
job_id: "discover:#{device.id}",
timestamp: DateTime.to_unix(DateTime.utc_now()),
oid_values: %{
# System info
"1.3.6.1.2.1.1.1.0" => "Test Device Description",
"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",
# ifTable - interface index
"1.3.6.1.2.1.2.2.1.1.1" => "1",
# ifDescr
"1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1",
# ifType (ethernetCsmacd = 6)
"1.3.6.1.2.1.2.2.1.3.1" => "6",
# ifSpeed
"1.3.6.1.2.1.2.2.1.5.1" => "1000000000",
# ifPhysAddress
"1.3.6.1.2.1.2.2.1.6.1" => "aa:bb:cc:dd:ee:ff",
# ifAdminStatus (up = 1)
"1.3.6.1.2.1.2.2.1.7.1" => "1",
# ifOperStatus (up = 1)
"1.3.6.1.2.1.2.2.1.8.1" => "1"
}
}
binary = SnmpResult.encode(discovery_result)
payload = %{"binary" => Base.encode64(binary)}
push(socket, "result", payload)
# Receive the poll job pushed after discovery
assert_push "jobs", %{binary: jobs_binary}, 5_000
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
poll_job =
Enum.find(job_list.jobs, fn job ->
job.job_type == :POLL and job.device_id == device.id
end)
assert poll_job != nil, "Expected a POLL job for device #{device.id}"
# Collect all OIDs from all queries in the poll job
all_oids = Enum.flat_map(poll_job.queries, & &1.oids)
# Must use 64-bit HC counters (ifHCInOctets / ifHCOutOctets)
assert Enum.any?(all_oids, &String.starts_with?(&1, "1.3.6.1.2.1.31.1.1.1.6.")),
"Expected ifHCInOctets OIDs (1.3.6.1.2.1.31.1.1.1.6.x) but found: #{inspect(all_oids)}"
assert Enum.any?(all_oids, &String.starts_with?(&1, "1.3.6.1.2.1.31.1.1.1.10.")),
"Expected ifHCOutOctets OIDs (1.3.6.1.2.1.31.1.1.1.10.x) but found: #{inspect(all_oids)}"
# Must NOT use old 32-bit counters (ifInOctets / ifOutOctets)
refute Enum.any?(all_oids, &String.starts_with?(&1, "1.3.6.1.2.1.2.2.1.10.")),
"Found old 32-bit ifInOctets OIDs (1.3.6.1.2.1.2.2.1.10.x) - should use HC counters"
refute Enum.any?(all_oids, &String.starts_with?(&1, "1.3.6.1.2.1.2.2.1.16.")),
"Found old 32-bit ifOutOctets OIDs (1.3.6.1.2.1.2.2.1.16.x) - should use HC counters"
end
end
describe "discovery job OIDs" do
test "initial discovery job contains expected OIDs", %{socket: socket} do
# Trigger a fresh job push by sending :send_jobs
send(socket.channel_pid, :send_jobs)
assert_push "jobs", %{binary: jobs_binary}, 5_000
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
discover_job =
Enum.find(job_list.jobs, fn job ->
job.job_type == :DISCOVER
end)
assert discover_job != nil, "Expected a DISCOVER job in initial jobs"
# Collect all OIDs from all queries in the discovery job
all_oids = Enum.flat_map(discover_job.queries, & &1.oids)
# ENTITY-STATE-MIB
assert "1.3.6.1.2.1.131.1.1.1.1" in all_oids,
"Missing ENTITY-STATE-MIB OID (1.3.6.1.2.1.131.1.1.1.1)"
# HOST-RESOURCES-MIB: hrProcessorTable
assert "1.3.6.1.2.1.25.3.3" in all_oids,
"Missing hrProcessorTable OID (1.3.6.1.2.1.25.3.3)"
# HOST-RESOURCES-MIB: hrStorageTable
assert "1.3.6.1.2.1.25.2.3" in all_oids,
"Missing hrStorageTable OID (1.3.6.1.2.1.25.2.3)"
# HOST-RESOURCES-MIB: hrDeviceTable
assert "1.3.6.1.2.1.25.3.2" in all_oids,
"Missing hrDeviceTable OID (1.3.6.1.2.1.25.3.2)"
# UCD-SNMP-MIB: ssCpuUser
assert "1.3.6.1.4.1.2021.11.9.0" in all_oids,
"Missing ssCpuUser OID (1.3.6.1.4.1.2021.11.9.0)"
end
end
# ── Stage: Device deletion and job refresh ─────────────────────────────────
describe "device deletion and job refresh" do
test "agent receives updated job list after device deletion", %{
device: device,
agent_token: agent_token
} do
# Create a second device to verify the list shrinks correctly
device2 =
DevicesFixtures.device_fixture(%{
organization_id: device.organization_id,
snmp_version: "2c",
snmp_community: "public",
ip_address: "192.168.1.2"
})
{:ok, _assignment} =
AgentsFixtures.agent_assignment_fixture(agent_token.id, device2.id)
# Trigger initial job refresh to get baseline
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token.id}:assignments",
{:assignments_changed, :test_setup}
)
# Should receive jobs message with 2 devices
assert_push "jobs", %{binary: initial_jobs_binary}, 1_000
{:ok, decoded_initial} = Base.decode64(initial_jobs_binary)
initial_job_list = AgentJobList.decode(decoded_initial)
initial_device_ids = initial_job_list.jobs |> Enum.map(& &1.device_id) |> Enum.uniq()
assert length(initial_device_ids) == 2
assert device.id in initial_device_ids
assert device2.id in initial_device_ids
# Delete the first device
{:ok, _deleted} = Towerops.Devices.delete_device(device)
# Should receive updated jobs message with only 1 device
assert_push "jobs", %{binary: updated_jobs_binary}, 1_000
{:ok, decoded_updated} = Base.decode64(updated_jobs_binary)
updated_job_list = AgentJobList.decode(decoded_updated)
updated_device_ids = updated_job_list.jobs |> Enum.map(& &1.device_id) |> Enum.uniq()
# Verify the deleted device is NOT in the new job list
assert length(updated_device_ids) == 1
refute device.id in updated_device_ids
assert device2.id in updated_device_ids
end
test "agent result for deleted device is rejected", %{socket: socket, device: device} do
# Delete the device
{:ok, _deleted} = Towerops.Devices.delete_device(device)
# Wait for jobs refresh
assert_push "jobs", _, 1_000
# Try to send a result for the deleted device
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.1.1.0" => "Test"}
}
payload = encode_payload(result)
push(socket, "result", payload)
# Channel should remain alive (graceful error handling)
Process.sleep(25)
assert Process.alive?(socket.channel_pid)
# The result should be logged as "Device not found" but not crash
# (This is verified by the channel staying alive)
end
test "multiple rapid assignment changes are debounced", %{agent_token: agent_token} do
# Trigger multiple rapid assignment changes
for i <- 1..5 do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token.id}:assignments",
{:assignments_changed, "rapid_change_#{i}"}
)
Process.sleep(10)
end
# Should only receive ONE jobs message (debounced)
assert_push "jobs", %{binary: _jobs_binary}, 1_000
# Should NOT receive additional jobs messages
refute_push "jobs", _, 200
end
end
end