diff --git a/test/towerops/snmp/client_test.exs b/test/towerops/snmp/client_test.exs index db994ab2..538c013c 100644 --- a/test/towerops/snmp/client_test.exs +++ b/test/towerops/snmp/client_test.exs @@ -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 diff --git a/test/towerops/workers/alert_notification_worker_test.exs b/test/towerops/workers/alert_notification_worker_test.exs index 6985a42e..914d41c1 100644 --- a/test/towerops/workers/alert_notification_worker_test.exs +++ b/test/towerops/workers/alert_notification_worker_test.exs @@ -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 diff --git a/test/towerops_web/channels/agent_channel_test.exs b/test/towerops_web/channels/agent_channel_test.exs index 885a417b..26018b18 100644 --- a/test/towerops_web/channels/agent_channel_test.exs +++ b/test/towerops_web/channels/agent_channel_test.exs @@ -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