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
1301 lines
44 KiB
Elixir
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
|