test: SNMP client v3 parsing + AlertNotificationWorker incidents + AgentChannel oversized messages
This commit is contained in:
parent
f9a4611105
commit
1634fde7a1
3 changed files with 316 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue