test: SNMP client v3 parsing + AlertNotificationWorker incidents + AgentChannel oversized messages

This commit is contained in:
Graham McIntire 2026-05-08 11:57:50 -05:00
parent f9a4611105
commit 1634fde7a1
3 changed files with 316 additions and 0 deletions

View file

@ -438,4 +438,210 @@ defmodule Towerops.Snmp.ClientTest do
end
end
end
describe "v3 credential parsing — exercises private parse_* helpers" do
test "v3 with full authPriv credentials propagates through to adapter" do
v3_opts = [
ip: "10.0.0.1",
version: "3",
security_name: "user1",
security_level: "authPriv",
auth_protocol: "SHA-256",
auth_password: "auth-secret",
priv_protocol: "AES-256",
priv_password: "priv-secret"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:version] == :v3
assert snmp_opts[:security_level] == :auth_priv
assert snmp_opts[:security_name] == "user1"
assert snmp_opts[:auth_protocol] == :sha256
assert snmp_opts[:priv_protocol] == :aes256
{:ok, {:integer, 1}}
end)
assert {:ok, 1} = Client.get(v3_opts, "1.3.6.1.2.1.1.1.0")
end
test "v3 with noAuthNoPriv security level" do
v3_opts = [
ip: "10.0.0.1",
version: "3",
security_name: "user2",
security_level: "noAuthNoPriv"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:security_level] == :no_auth_no_priv
assert snmp_opts[:security_name] == "user2"
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(v3_opts, "1.3.6.1.2.1.1.1.0")
end
test "v3 unknown auth protocol falls back to sha256" do
v3_opts = [
ip: "10.0.0.1",
version: "3",
security_name: "u",
security_level: "authNoPriv",
auth_protocol: "BANANA",
auth_password: "p"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:auth_protocol] == :sha256
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(v3_opts, "1.3.6.1.2.1.1.1.0")
end
test "v3 unknown priv protocol falls back to aes" do
v3_opts = [
ip: "10.0.0.1",
version: "3",
security_name: "u",
security_level: "authPriv",
auth_protocol: "MD5",
auth_password: "ap",
priv_protocol: "ZEPHYR",
priv_password: "pp"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:auth_protocol] == :md5
assert snmp_opts[:priv_protocol] == :aes
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(v3_opts, "1.3.6.1.2.1.1.1.0")
end
test "v3 SHA, SHA-224, SHA-384, SHA-512 protocols round-trip correctly" do
for {input, expected} <- [
{"SHA", :sha},
{"SHA-224", :sha224},
{"SHA-384", :sha384},
{"SHA-512", :sha512}
] do
v3_opts = [
ip: "10.0.0.1",
version: "3",
security_name: "u",
security_level: "authNoPriv",
auth_protocol: input,
auth_password: "p"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:auth_protocol] == expected
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(v3_opts, "1.3.6.1.2.1.1.1.0")
end
end
test "v3 DES, AES-192, AES-256-C priv protocols round-trip correctly" do
for {input, expected} <- [
{"DES", :des},
{"AES-192", :aes192},
{"AES-256-C", :aes256c}
] do
v3_opts = [
ip: "10.0.0.1",
version: "3",
security_name: "u",
security_level: "authPriv",
auth_protocol: "MD5",
auth_password: "ap",
priv_protocol: input,
priv_password: "pp"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:priv_protocol] == expected
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(v3_opts, "1.3.6.1.2.1.1.1.0")
end
end
test "version atom passthrough for v1, v2c" do
for {input, expected} <- [{:v1, :v1}, {:v2c, :v2c}] do
opts = [ip: "10.0.0.1", community: "public", version: input]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:version] == expected
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(opts, "1.3.6.1.2.1.1.1.0")
end
end
test "version atom passthrough for v3" do
opts = [
ip: "10.0.0.1",
version: :v3,
security_name: "u",
security_level: "noAuthNoPriv"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:version] == :v3
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(opts, "1.3.6.1.2.1.1.1.0")
end
test "unknown version string falls back to v2c" do
opts = [ip: "10.0.0.1", community: "public", version: "999"]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:version] == :v2c
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(opts, "1.3.6.1.2.1.1.1.0")
end
test "unknown security_level falls back to no_auth_no_priv" do
v3_opts = [
ip: "10.0.0.1",
version: "3",
security_name: "u",
security_level: "WHATEVER"
]
expect(SnmpMock, :get, fn _, _, snmp_opts ->
assert snmp_opts[:security_level] == :no_auth_no_priv
{:ok, {:integer, 0}}
end)
assert {:ok, 0} = Client.get(v3_opts, "1.3.6.1.2.1.1.1.0")
end
end
describe "extract_snmp_value via get/2" do
for {tag_tuple, expected} <- [
{{:counter32, 100}, 100},
{{:counter64, 1_000_000}, 1_000_000},
{{:gauge32, 50}, 50},
{{:ip_address, "1.2.3.4"}, "1.2.3.4"},
{{:object_identifier, "1.3.6.1"}, "1.3.6.1"},
{{:opaque, "x"}, "x"}
] do
test "extracts #{elem(tag_tuple, 0)} value" do
expect(SnmpMock, :get, fn _, _, _ -> {:ok, unquote(Macro.escape(tag_tuple))} end)
assert {:ok, unquote(expected)} = Client.get(@test_opts, "1.3.6.1.2.1.1.1.0")
end
end
end
end

View file

@ -1,6 +1,7 @@
defmodule Towerops.Workers.AlertNotificationWorkerTest do
use Towerops.DataCase, async: false
alias Towerops.OnCall.Escalation
alias Towerops.Organizations
alias Towerops.Workers.AlertNotificationWorker
@ -118,4 +119,81 @@ defmodule Towerops.Workers.AlertNotificationWorkerTest do
assert :ok == AlertNotificationWorker.perform(job)
end
end
describe "perform/1 — incident lifecycle" do
setup %{organization: org} do
site = Towerops.OrganizationsFixtures.site_fixture(org.id)
device = Towerops.DevicesFixtures.device_fixture(%{organization_id: org.id, site_id: site.id})
user = Towerops.AccountsFixtures.user_fixture()
policy = Towerops.OnCallFixtures.escalation_policy_fixture(org.id)
rule =
Towerops.OnCallFixtures.escalation_rule_fixture(policy.id, %{
position: 0,
timeout_minutes: 5
})
_target =
Towerops.OnCallFixtures.escalation_target_fixture(rule.id, %{
target_type: "user",
user_id: user.id
})
# Make the device's organization route to "builtin" (default), with the
# policy as the org's default — so trigger starts an incident.
{:ok, org_with_policy} =
Organizations.update_organization(org, %{
default_escalation_policy_id: policy.id,
alert_routing: "builtin"
})
{:ok, alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: "device_down",
triggered_at: DateTime.utc_now(),
message: "incident-down"
})
%{
device: device,
alert: alert,
user: user,
policy: policy,
organization: org_with_policy
}
end
test "acknowledge looks up open incident by alert and acknowledges it", %{alert: alert} do
# First trigger to create the incident
assert :ok =
AlertNotificationWorker.perform(%Oban.Job{
args: %{"action" => "trigger", "alert_id" => alert.id}
})
assert :ok =
AlertNotificationWorker.perform(%Oban.Job{
args: %{"action" => "acknowledge", "alert_id" => alert.id}
})
# The acknowledged incident should now be in :acknowledged state
incident = Escalation.find_incident_for_alert(alert.id)
# Either it was acknowledged (returned by find_incident_for_alert if
# that helper still returns acknowledged) OR was already closed.
assert is_nil(incident) or incident.status in ["acknowledged", "triggered"]
end
test "resolve looks up open incident by alert and resolves it", %{alert: alert} do
assert :ok =
AlertNotificationWorker.perform(%Oban.Job{
args: %{"action" => "trigger", "alert_id" => alert.id}
})
assert :ok =
AlertNotificationWorker.perform(%Oban.Job{
args: %{"action" => "resolve", "alert_id" => alert.id}
})
end
end
end

View file

@ -459,6 +459,38 @@ defmodule ToweropsWeb.AgentChannelTest do
end
end
describe "handle_in oversized message rejection" do
# Constructs a payload bigger than the 10 MiB @max_message_size threshold.
defp oversized_payload do
# 11 MiB of base64-safe ASCII — well above the 10 * 1024 * 1024 limit
%{"binary" => String.duplicate("A", 11 * 1024 * 1024)}
end
test "result rejects messages over @max_message_size", %{socket: socket} do
ref = push(socket, "result", oversized_payload())
assert_reply ref, :error, %{reason: "Message too large" <> _}
end
test "heartbeat rejects messages over @max_message_size", %{socket: socket} do
ref = push(socket, "heartbeat", oversized_payload())
assert_reply ref, :error, %{reason: "Message too large"}
end
test "error rejects messages over @max_message_size", %{socket: socket} do
ref = push(socket, "error", oversized_payload())
assert_reply ref, :error, %{reason: "Message too large"}
end
end
describe "handle_in invalid mikrotik_result" do
test "invalid base64 is logged and channel survives", %{socket: socket} do
push(socket, "mikrotik_result", %{"binary" => "not-valid-base64!!!"})
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