diff --git a/test/towerops/accounts/hibp_test.exs b/test/towerops/accounts/hibp_test.exs index 42524896..09460ff5 100644 --- a/test/towerops/accounts/hibp_test.exs +++ b/test/towerops/accounts/hibp_test.exs @@ -75,4 +75,53 @@ defmodule Towerops.Accounts.HIBPTest do assert {:ok, 5} == HIBP.check_suffix(body, "GOODLINE") end end + + describe "check_password/1 with HTTP stubs" do + test "returns {:ok, 0} when suffix not found in response" do + Req.Test.stub(HIBP, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/plain") + |> Plug.Conn.send_resp(200, "OTHER:42\r\nNOMATCH:7\r\n") + end) + + assert {:ok, 0} = HIBP.check_password("uniquepassword123") + end + + test "returns {:ok, count} when suffix found in response" do + hash = HIBP.hash_password("password") + suffix = String.slice(hash, 5..-1//1) + + Req.Test.stub(HIBP, fn conn -> + conn + |> Plug.Conn.put_resp_content_type("text/plain") + |> Plug.Conn.send_resp(200, "#{suffix}:54321\r\nOTHER:7\r\n") + end) + + assert {:ok, 54_321} = HIBP.check_password("password") + end + + test "returns {:ok, :unknown} on rate limit" do + Req.Test.stub(HIBP, fn conn -> + Plug.Conn.send_resp(conn, 429, "") + end) + + assert {:ok, :unknown} = HIBP.check_password("password") + end + + test "returns {:ok, :unknown} on server error" do + Req.Test.stub(HIBP, fn conn -> + Plug.Conn.send_resp(conn, 500, "") + end) + + assert {:ok, :unknown} = HIBP.check_password("password") + end + + test "returns {:ok, :unknown} on transport error" do + Req.Test.stub(HIBP, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:ok, :unknown} = HIBP.check_password("password") + end + end end diff --git a/test/towerops/logger_filters_test.exs b/test/towerops/logger_filters_test.exs index 5ae417ff..e4f7f866 100644 --- a/test/towerops/logger_filters_test.exs +++ b/test/towerops/logger_filters_test.exs @@ -4,191 +4,88 @@ defmodule Towerops.LoggerFiltersTest do alias Towerops.LoggerFilters describe "drop_oban_shutdown/2" do - test "stops Oban source shutdown messages" do - log_event = %{ - meta: %{source: :oban}, - msg: {:string, "Received unexpected message: {:EXIT, #PID<0.123.0>, :shutdown}"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :stop + test "stops on Oban source shutdown messages" do + log = %{meta: %{source: :oban}, msg: {:string, "process EXIT :shutdown"}} + assert :stop = LoggerFilters.drop_oban_shutdown(log, []) end - test "stops Oban queue shutdown messages with error_logger tag" do - log_event = %{ + test "stops on Oban queue shutdown messages" do + log = %{ meta: %{error_logger: %{tag: :error_msg}}, - msg: - {:string, - "Oban.Queue.Watchman #PID<0.456.0> received unexpected message in handle_info/2: {:EXIT, #PID<0.789.0>, :shutdown}"} + msg: {:string, "Oban.Queue received unexpected message: {:EXIT, :shutdown}"} } - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :stop + assert :stop = LoggerFilters.drop_oban_shutdown(log, []) end - test "stops Oban plugins shutdown messages" do - log_event = %{ + test "stops on Oban.Plugins shutdown messages" do + log = %{ meta: %{error_logger: %{tag: :error_msg}}, - msg: {:string, "Oban.Plugins.Pruner received unexpected message: {:EXIT, #PID<0.123.0>, :shutdown}"} + msg: {:string, "Oban.Plugins received unexpected message: {:EXIT, :shutdown}"} } - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :stop + assert :stop = LoggerFilters.drop_oban_shutdown(log, []) end test "ignores non-Oban messages" do - log_event = %{ - meta: %{}, - msg: {:string, "Some regular log message"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore + log = %{meta: %{source: :phoenix}, msg: {:string, "request completed"}} + assert :ignore = LoggerFilters.drop_oban_shutdown(log, []) end - test "ignores Oban messages without shutdown" do - log_event = %{ - meta: %{source: :oban}, - msg: {:string, "Some normal Oban message"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore + test "ignores Oban source messages without EXIT:shutdown" do + log = %{meta: %{source: :oban}, msg: {:string, "job completed successfully"}} + assert :ignore = LoggerFilters.drop_oban_shutdown(log, []) end - test "ignores Oban messages with EXIT but no shutdown" do - log_event = %{ - meta: %{source: :oban}, - msg: {:string, "Received unexpected message: {:EXIT, #PID<0.123.0>, :normal}"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore + test "ignores messages without matching meta shape" do + assert :ignore = LoggerFilters.drop_oban_shutdown(%{}, []) end - test "ignores messages with only EXIT keyword" do - log_event = %{ - meta: %{source: :oban}, - msg: {:string, "EXIT without shutdown"} - } + test "ignores messages with non-string msg" do + log = %{meta: %{source: :oban}, msg: :not_a_string} + assert :ignore = LoggerFilters.drop_oban_shutdown(log, []) + end + end - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore + describe "drop_snmp_mib_errors/2" do + test "stops on SNMP MIB error messages" do + log = %{msg: {:string, "Cannot find module (FOO): At line 1 in (none)"}} + assert :stop = LoggerFilters.drop_snmp_mib_errors(log, []) end - test "ignores messages with only shutdown keyword" do - log_event = %{ - meta: %{source: :oban}, - msg: {:string, "Graceful shutdown"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore + test "ignores non-MIB error messages" do + log = %{msg: {:string, "some other error"}} + assert :ignore = LoggerFilters.drop_snmp_mib_errors(log, []) end - test "ignores Oban queue messages without EXIT" do - log_event = %{ - meta: %{error_logger: %{tag: :error_msg}}, - msg: {:string, "Oban.Queue.Watchman received unexpected message: :some_message"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore - end - - test "ignores Oban queue messages without shutdown" do - log_event = %{ - meta: %{error_logger: %{tag: :error_msg}}, - msg: {:string, "Oban.Queue.Watchman received unexpected message: {:EXIT, #PID<0.123.0>, :normal}"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore - end - - test "ignores log events with missing meta fields" do - log_event = %{ - msg: {:string, "Some message"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore - end - - test "ignores log events with non-string messages" do - log_event = %{ - meta: %{source: :oban}, - msg: {:report, %{some: :data}} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore - end - - test "ignores log events with wrong error_logger tag" do - log_event = %{ - meta: %{error_logger: %{tag: :info}}, - msg: {:string, "Oban.Queue.Watchman received unexpected message: {:EXIT, #PID<0.123.0>, :shutdown}"} - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore - end - - test "handles nil message gracefully" do - log_event = %{ - meta: %{source: :oban}, - msg: nil - } - - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore - end - - test "passes through opts parameter unchanged" do - log_event = %{ - meta: %{}, - msg: {:string, "Some message"} - } - - # Verify opts parameter doesn't affect the result - assert LoggerFilters.drop_oban_shutdown(log_event, []) == :ignore - assert LoggerFilters.drop_oban_shutdown(log_event, some: :option) == :ignore - assert LoggerFilters.drop_oban_shutdown(log_event, %{key: "value"}) == :ignore + test "ignores messages without matching meta shape" do + assert :ignore = LoggerFilters.drop_snmp_mib_errors(%{}, []) end end describe "drop_shutdown_errors/2" do - test "stops port_died log messages" do - log_event = %{ - msg: {:string, "Erlang error: {:port_died, :normal}"} - } - - assert LoggerFilters.drop_shutdown_errors(log_event, []) == :stop + test "stops on port_died messages" do + log = %{msg: {:string, "port_died: normal"}} + assert :stop = LoggerFilters.drop_shutdown_errors(log, []) end - test "stops write_failed with epipe log messages" do - log_event = %{ - msg: {:string, "{:write_failed, :standard_io, :enoent, {:no_translation, :unicode, :latin1}} {:device, :epipe}"} - } + test "stops on write_failed epipe messages" do + log = %{msg: {:string, "write_failed: device epipe"}} + assert :stop = LoggerFilters.drop_shutdown_errors(log, []) + end - assert LoggerFilters.drop_shutdown_errors(log_event, []) == :stop + test "ignores non-shutdown messages" do + log = %{msg: {:string, "normal operation message"}} + assert :ignore = LoggerFilters.drop_shutdown_errors(log, []) end test "ignores write_failed without epipe" do - log_event = %{ - msg: {:string, "{:write_failed, :standard_io, :enoent}"} - } - - assert LoggerFilters.drop_shutdown_errors(log_event, []) == :ignore + log = %{msg: {:string, "write_failed: something else"}} + assert :ignore = LoggerFilters.drop_shutdown_errors(log, []) end - test "ignores normal log messages" do - log_event = %{ - msg: {:string, "Some regular log message"} - } - - assert LoggerFilters.drop_shutdown_errors(log_event, []) == :ignore - end - - test "ignores non-string log messages" do - log_event = %{ - msg: {:report, %{some: :data}} - } - - assert LoggerFilters.drop_shutdown_errors(log_event, []) == :ignore - end - - test "ignores log events with nil message" do - log_event = %{msg: nil} - - assert LoggerFilters.drop_shutdown_errors(log_event, []) == :ignore + test "ignores messages without matching meta shape" do + assert :ignore = LoggerFilters.drop_shutdown_errors(%{}, []) end end end diff --git a/test/towerops/numeric_test.exs b/test/towerops/numeric_test.exs index 93689e73..1da1c3f9 100644 --- a/test/towerops/numeric_test.exs +++ b/test/towerops/numeric_test.exs @@ -4,86 +4,78 @@ defmodule Towerops.NumericTest do alias Towerops.Numeric describe "parse_integer/1" do - test "parses integer values" do - assert Numeric.parse_integer(42) == {:ok, 42} + test "returns {:ok, int} for integer" do + assert {:ok, 42} = Numeric.parse_integer(42) + assert {:ok, 0} = Numeric.parse_integer(0) + assert {:ok, -5} = Numeric.parse_integer(-5) end - test "parses integer strings" do - assert Numeric.parse_integer("42") == {:ok, 42} - assert Numeric.parse_integer("0") == {:ok, 0} - assert Numeric.parse_integer("-5") == {:ok, -5} + test "returns {:ok, int} for integer string" do + assert {:ok, 42} = Numeric.parse_integer("42") + assert {:ok, -5} = Numeric.parse_integer("-5") end - test "parses integer from string with trailing chars" do - assert Numeric.parse_integer("42abc") == {:ok, 42} + test "parses integer from string with trailing non-numeric" do + assert {:ok, 42} = Numeric.parse_integer("42abc") end - test "returns error for nil" do - assert Numeric.parse_integer(nil) == :error + test "returns :error for unparseable string" do + assert :error = Numeric.parse_integer("abc") end - test "returns error for empty string" do - assert Numeric.parse_integer("") == :error + test "returns :error for empty string" do + assert :error = Numeric.parse_integer("") end - test "returns error for null string" do - assert Numeric.parse_integer("null") == :error + test "returns :error for null string" do + assert :error = Numeric.parse_integer("null") end - test "returns error for non-numeric string" do - assert Numeric.parse_integer("abc") == :error - end - - test "returns error for floats" do - assert Numeric.parse_integer(4.2) == :error - end - - test "returns error for other types" do - assert Numeric.parse_integer([]) == :error - assert Numeric.parse_integer(%{}) == :error - assert Numeric.parse_integer(:atom) == :error + test "returns :error for non-integer types" do + assert :error = Numeric.parse_integer(nil) + assert :error = Numeric.parse_integer(:atom) + assert :error = Numeric.parse_integer([1, 2, 3]) + assert :error = Numeric.parse_integer(%{}) + assert :error = Numeric.parse_integer(3.14) end end describe "parse_float/1" do - test "parses float values" do - assert Numeric.parse_float(4.2) == {:ok, 4.2} + test "returns {:ok, float} for float" do + assert {:ok, 4.2} = Numeric.parse_float(4.2) + assert {:ok, 0.0} = Numeric.parse_float(0.0) end - test "parses integer values as float" do - assert Numeric.parse_float(42) == {:ok, 42.0} + test "returns {:ok, float} for integer (converted)" do + assert {:ok, 42.0} = Numeric.parse_float(42) + assert {:ok, 0.0} = Numeric.parse_float(0) end - test "parses float strings" do - assert Numeric.parse_float("4.2") == {:ok, 4.2} - assert Numeric.parse_float("0.0") == {:ok, 0.0} - assert Numeric.parse_float("-5.5") == {:ok, -5.5} + test "returns {:ok, float} for float string" do + assert {:ok, 4.2} = Numeric.parse_float("4.2") + assert {:ok, -3.14} = Numeric.parse_float("-3.14") end - test "parses integer strings as float" do - assert Numeric.parse_float("42") == {:ok, 42.0} + test "returns {:ok, float} for integer string" do + assert {:ok, 42.0} = Numeric.parse_float("42") end - test "returns error for nil" do - assert Numeric.parse_float(nil) == :error + test "returns :error for unparseable string" do + assert :error = Numeric.parse_float("abc") end - test "returns error for empty string" do - assert Numeric.parse_float("") == :error + test "returns :error for empty string" do + assert :error = Numeric.parse_float("") end - test "returns error for null string" do - assert Numeric.parse_float("null") == :error + test "returns :error for null string" do + assert :error = Numeric.parse_float("null") end - test "returns error for non-numeric string" do - assert Numeric.parse_float("abc") == :error - end - - test "returns error for other types" do - assert Numeric.parse_float([]) == :error - assert Numeric.parse_float(%{}) == :error - assert Numeric.parse_float(:atom) == :error + test "returns :error for non-numeric types" do + assert :error = Numeric.parse_float(nil) + assert :error = Numeric.parse_float(:atom) + assert :error = Numeric.parse_float([1, 2, 3]) end end end diff --git a/test/towerops/pagerduty/client_test.exs b/test/towerops/pagerduty/client_test.exs index c6bce8e4..7be5443d 100644 --- a/test/towerops/pagerduty/client_test.exs +++ b/test/towerops/pagerduty/client_test.exs @@ -4,42 +4,113 @@ defmodule Towerops.PagerDuty.ClientTest do alias Towerops.PagerDuty.Client describe "trigger/5" do - test "builds correct event body" do - _alert = %{ - id: "abc-123", - alert_type: "device_down", - triggered_at: ~U[2024-01-15 10:30:00Z], - message: "Device is not responding to ping" - } + test "returns {:ok, body} on 202" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(202) |> Req.Test.json(%{status: "success"}) + end) - _device = %{ - id: "dev-456", - hostname: "router-01", - ip_address: "10.0.0.1", - organization_id: "org-789" - } + alert = %{id: "a1", alert_type: :device_down, triggered_at: ~U[2024-01-15 10:30:00Z], message: "down"} + device = %{id: "d1", hostname: "rtr-01", ip_address: "10.0.0.1"} + assert {:ok, _} = Client.trigger("rk123", alert, device, "HQ", "https://ops.example.com") + end - # We can't easily test the HTTP call without mocking, - # but we verify the function exists and accepts the right arity - assert is_function(&Client.trigger/5) + test "returns {:ok, body} on 200" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(200) |> Req.Test.json(%{status: "success"}) + end) + + alert = %{id: "a2", alert_type: :device_down, triggered_at: ~U[2024-01-15 10:30:00Z], message: "down"} + device = %{id: "d2", hostname: "rtr-02", ip_address: "10.0.0.2"} + assert {:ok, _} = Client.trigger("rk", alert, device, "HQ", "https://ops.example.com") + end + + test "returns error on 400" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(400) |> Req.Test.json(%{message: "invalid event"}) + end) + + alert = %{id: "a3", alert_type: :device_down, triggered_at: ~U[2024-01-15 10:30:00Z], message: "down"} + device = %{id: "d3", hostname: "rtr-03", ip_address: "10.0.0.3"} + + assert {:error, {:bad_request, "invalid event"}} = + Client.trigger("rk", alert, device, "HQ", "https://ops.example.com") + end + + test "returns error on 429" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_resp_header("retry-after", "1") + |> Plug.Conn.put_status(429) + |> Req.Test.json(%{}) + end) + + alert = %{id: "a4", alert_type: :device_down, triggered_at: ~U[2024-01-15 10:30:00Z], message: "down"} + device = %{id: "d4", hostname: "rtr-04", ip_address: "10.0.0.4"} + assert {:error, :rate_limited} = Client.trigger("rk", alert, device, "HQ", "https://ops.example.com") + end + + test "returns error on unexpected status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{error: "internal"}) + end) + + alert = %{id: "a5", alert_type: :device_down, triggered_at: ~U[2024-01-15 10:30:00Z], message: "down"} + device = %{id: "d5", hostname: "rtr-05", ip_address: "10.0.0.5"} + assert {:error, {:unexpected_status, 500}} = Client.trigger("rk", alert, device, "HQ", "https://ops.example.com") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + alert = %{id: "a6", alert_type: :device_down, triggered_at: ~U[2024-01-15 10:30:00Z], message: "down"} + device = %{id: "d6", hostname: "rtr-06", ip_address: "10.0.0.6"} + assert {:error, _} = Client.trigger("rk", alert, device, "HQ", "https://ops.example.com") end end describe "acknowledge/2" do - test "accepts routing key and alert" do - assert is_function(&Client.acknowledge/2) + test "returns {:ok, body} on 202" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(202) |> Req.Test.json(%{status: "success"}) + end) + + alert = %{id: "a7", alert_type: :device_down} + assert {:ok, _} = Client.acknowledge("rk", alert) end end describe "resolve/2" do - test "accepts routing key and alert" do - assert is_function(&Client.resolve/2) + test "returns {:ok, body} on 202" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(202) |> Req.Test.json(%{status: "success"}) + end) + + alert = %{id: "a8", alert_type: :device_down} + assert {:ok, _} = Client.resolve("rk", alert) end end describe "test_connection/1" do - test "accepts routing key" do - assert is_function(&Client.test_connection/1) + test "returns ok on successful trigger+resolve" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(202) |> Req.Test.json(%{status: "success"}) + end) + + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(202) |> Req.Test.json(%{status: "success"}) + end) + + assert {:ok, _} = Client.test_connection("rk") + end + + test "returns error when trigger fails" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(400) |> Req.Test.json(%{message: "bad key"}) + end) + + assert {:error, _} = Client.test_connection("bad-rk") end end end diff --git a/test/towerops/preseem/client_test.exs b/test/towerops/preseem/client_test.exs index 8062e999..bfb24caf 100644 --- a/test/towerops/preseem/client_test.exs +++ b/test/towerops/preseem/client_test.exs @@ -4,138 +4,131 @@ defmodule Towerops.Preseem.ClientTest do alias Towerops.Preseem.Client describe "test_connection/1" do - test "returns ok when API responds with 200" do + test "returns {:ok, body} on 200" do Req.Test.stub(Client, fn conn -> - assert conn.request_path == "/model/v1/access_points" - assert conn.query_string =~ "limit=100" - - Req.Test.json(conn, %{ - "data" => [%{"id" => 1, "name" => "Test AP"}], - "paginator" => %{"page" => 1, "page_count" => 1, "limit" => 100, "total_count" => 1} - }) + Req.Test.json(conn, %{data: [%{id: 1, name: "AP-1"}]}) end) - assert {:ok, _body} = Client.test_connection("valid-api-key") + assert {:ok, _} = Client.test_connection("api-key") end - test "returns error for 401 unauthorized" do + test "returns error on 401" do Req.Test.stub(Client, fn conn -> - conn - |> Plug.Conn.put_status(401) - |> Req.Test.json(%{"error" => "unauthorized"}) + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) end) assert {:error, :unauthorized} = Client.test_connection("bad-key") end - test "returns error for 403 forbidden" do + test "returns error on 403" do Req.Test.stub(Client, fn conn -> - conn - |> Plug.Conn.put_status(403) - |> Req.Test.json(%{"error" => "forbidden"}) + conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{}) end) - assert {:error, :forbidden} = Client.test_connection("restricted-key") + assert {:error, :forbidden} = Client.test_connection("bad-key") + end + + test "returns error on unexpected status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{error: "internal"}) + end) + + assert {:error, {:unexpected_status, 500}} = Client.test_connection("bad-key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.test_connection("bad-key") end end describe "list_access_points/1" do - test "returns list of access point scores on success" do + test "returns {:ok, list} with array response" do Req.Test.stub(Client, fn conn -> - assert conn.request_path == "/model/v1/access_point_scores" - - Req.Test.json(conn, [ - %{ - "element_uuid" => "uuid-1", - "name" => "Tower1-AP1", - "system_mac_address" => "AA:BB:CC:DD:EE:FF", - "management_ip" => "10.0.0.1", - "model_name" => "AF60", - "firmware" => "4.3.2", - "rf_score" => 78.0, - "ap_health_score" => 92.5, - "subscriber_capacity" => 85, - "connections" => 30, - "airtime_remaining_tx" => 55 - }, - %{ - "element_uuid" => "uuid-2", - "name" => "Tower1-AP2", - "management_ip" => "10.0.0.2" - } - ]) + Req.Test.json(conn, [%{id: 1, name: "AP-1"}, %{id: 2, name: "AP-2"}]) end) - assert {:ok, [ap1, ap2]} = Client.list_access_points("valid-key") - assert ap1["element_uuid"] == "uuid-1" - assert ap2["element_uuid"] == "uuid-2" + assert {:ok, [%{"id" => 1}, %{"id" => 2}]} = Client.list_access_points("key") end - test "handles wrapped response with data key" do + test "returns {:ok, list} with data wrapper" do Req.Test.stub(Client, fn conn -> - Req.Test.json(conn, %{ - "data" => [ - %{"element_uuid" => "uuid-1", "name" => "AP1"} - ] - }) + Req.Test.json(conn, %{data: [%{id: 1, name: "AP-1"}]}) end) - assert {:ok, [ap]} = Client.list_access_points("valid-key") - assert ap["element_uuid"] == "uuid-1" + assert {:ok, [%{"id" => 1}]} = Client.list_access_points("key") end - test "handles single object response by wrapping in list" do + test "returns {:ok, list} with single object wrapped in list" do Req.Test.stub(Client, fn conn -> - Req.Test.json(conn, %{ - "element_uuid" => "uuid-1", - "name" => "Single AP" - }) + Req.Test.json(conn, %{id: 1, name: "AP-1"}) end) - assert {:ok, [ap]} = Client.list_access_points("valid-key") - assert ap["element_uuid"] == "uuid-1" + assert {:ok, [%{"id" => 1}]} = Client.list_access_points("key") end - test "returns error for 401" do + test "returns {:ok, []} for string body" do Req.Test.stub(Client, fn conn -> - conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"}) + Plug.Conn.send_resp(conn, 200, "") end) - assert {:error, :unauthorized} = Client.list_access_points("bad-key") + assert {:ok, []} = Client.list_access_points("key") + end + + test "returns error on 401" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Client.list_access_points("key") + end + + test "returns error on unexpected status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{error: "internal"}) + end) + + assert {:error, {:unexpected_status, 500, %{"error" => "internal"}}} = Client.list_access_points("key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.list_access_points("key") end end describe "list_access_points_basic/1" do - test "fetches all pages of access points" do + test "returns {:ok, list} from single page" do Req.Test.stub(Client, fn conn -> - assert conn.request_path == "/model/v1/access_points" - params = Plug.Conn.fetch_query_params(conn).query_params - - case params["page"] do - nil -> - Req.Test.json(conn, %{ - "data" => [%{"id" => 1, "name" => "AP1"}], - "paginator" => %{"page" => 1, "page_count" => 2, "limit" => 500, "total_count" => 2} - }) - - "2" -> - Req.Test.json(conn, %{ - "data" => [%{"id" => 2, "name" => "AP2"}], - "paginator" => %{"page" => 2, "page_count" => 2, "limit" => 500, "total_count" => 2} - }) - end + Req.Test.json(conn, %{ + data: [%{id: 1, name: "AP-1"}], + paginator: %{page: 1, page_count: 1} + }) end) - assert {:ok, aps} = Client.list_access_points_basic("valid-key") - assert length(aps) == 2 + assert {:ok, [%{"id" => 1}]} = Client.list_access_points_basic("key") end - test "returns error for 401" do + test "returns error on unauthorized" do Req.Test.stub(Client, fn conn -> - conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"}) + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) end) - assert {:error, :unauthorized} = Client.list_access_points_basic("bad-key") + assert {:error, :unauthorized} = Client.list_access_points_basic("key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.list_access_points_basic("key") end end end diff --git a/test/towerops/profiles/profile_watcher_test.exs b/test/towerops/profiles/profile_watcher_test.exs index c5bcd3e3..7f7571d5 100644 --- a/test/towerops/profiles/profile_watcher_test.exs +++ b/test/towerops/profiles/profile_watcher_test.exs @@ -4,50 +4,61 @@ defmodule Towerops.Profiles.ProfileWatcherTest do alias Towerops.Profiles.ProfileWatcher describe "yaml_file?/1" do - test "matches .yaml extension" do - assert ProfileWatcher.yaml_file?("/tmp/x/foo.yaml") + test "returns true for .yaml files" do + assert ProfileWatcher.yaml_file?("path/to/profile.yaml") end - test "matches .yml extension" do - assert ProfileWatcher.yaml_file?("/tmp/x/foo.yml") + test "returns true for .yml files" do + assert ProfileWatcher.yaml_file?("path/to/profile.yml") end - test "rejects unrelated extensions" do - refute ProfileWatcher.yaml_file?("/tmp/x/foo.json") - refute ProfileWatcher.yaml_file?("/tmp/x/foo") - refute ProfileWatcher.yaml_file?("/tmp/x/foo.exs") + test "returns false for other extensions" do + refute ProfileWatcher.yaml_file?("profile.json") + refute ProfileWatcher.yaml_file?("profile.txt") + refute ProfileWatcher.yaml_file?("profile") + end + + test "raises on nil" do + assert_raise FunctionClauseError, fn -> + ProfileWatcher.yaml_file?(nil) + end end end describe "should_reload?/1" do - test "reloads on :created" do + test "returns true for :created event" do assert ProfileWatcher.should_reload?([:created]) end - test "reloads on :modified" do + test "returns true for :modified event" do assert ProfileWatcher.should_reload?([:modified]) end - test "reloads on :removed" do + test "returns true for :removed event" do assert ProfileWatcher.should_reload?([:removed]) end - test "reloads on :renamed" do + test "returns true for :renamed event" do assert ProfileWatcher.should_reload?([:renamed]) end - test "reloads if any of several events matches" do - assert ProfileWatcher.should_reload?([:undefined, :modified]) + test "returns true when any event triggers reload" do + assert ProfileWatcher.should_reload?([:accessed, :modified]) end - test "does not reload on unrelated events" do - refute ProfileWatcher.should_reload?([:undefined]) + test "returns false for non-reload events" do + refute ProfileWatcher.should_reload?([:accessed]) + refute ProfileWatcher.should_reload?([:attrs_changed]) + end + + test "returns false for empty list" do refute ProfileWatcher.should_reload?([]) end - test "non-list input returns false" do + test "returns false for non-list input" do refute ProfileWatcher.should_reload?(nil) refute ProfileWatcher.should_reload?(:created) + refute ProfileWatcher.should_reload?(%{}) end end end diff --git a/test/towerops/proto/agent_test.exs b/test/towerops/proto/agent_test.exs index 28aa40b2..1db4127d 100644 --- a/test/towerops/proto/agent_test.exs +++ b/test/towerops/proto/agent_test.exs @@ -28,7 +28,13 @@ defmodule Towerops.Agent.ProtoTest do alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpQuery alias Towerops.Agent.SnmpResult + alias Towerops.Proto.Decode alias Towerops.Proto.Encode + alias Towerops.Proto.Types.AgentConfig + alias Towerops.Proto.Types.Check + alias Towerops.Proto.Types.CheckList + alias Towerops.Proto.Types.Device + alias Towerops.Proto.Types.HttpCheckConfig alias Towerops.Proto.Types.LldpNeighbor @device_uuid "a1b2c3d4-e5f6-7890-abcd-ef1234567890" @@ -1045,4 +1051,634 @@ defmodule Towerops.Agent.ProtoTest do assert decoded.uptime_seconds == 0 end end + + describe "Decode.decode_agent_config" do + test "encodes and decodes a full agent config with devices and checks" do + config = %AgentConfig{ + version: "1.0.0", + poll_interval_seconds: 300, + devices: [ + %Device{ + id: "device-1", + name: "core-switch", + ip_address: "192.168.1.1", + snmp: %Towerops.Proto.Types.SnmpConfig{ + enabled: true, + version: "2c", + community: "public", + port: 161, + transport: "udp" + }, + poll_interval_seconds: 300, + sensors: [ + %Towerops.Proto.Types.Sensor{ + id: "sensor-1", + sensor_type: "temperature", + oid: "1.3.6.1.4.1.14988.1.1.3.10.0", + divisor: 10.0, + unit: "celsius", + metadata: %{"location" => "CPU Core 1"} + } + ], + interfaces: [ + %Towerops.Proto.Types.Interface{ + id: "if-1", + if_index: 1, + if_name: "GigabitEthernet0/1" + } + ], + monitoring_enabled: true, + check_interval_seconds: 60 + } + ], + checks: [ + %Check{ + id: "check-1", + check_type: "http", + interval_seconds: 60, + timeout_ms: 5000, + config: + {:http, + %HttpCheckConfig{ + url: "https://example.com/health", + method: "GET", + expected_status: 200, + verify_ssl: true, + headers: %{}, + body: "", + regex: "", + follow_redirects: false + }} + } + ] + } + + encoded = Encode.encode_agent_config(config) + assert {:ok, decoded} = Decode.decode_agent_config(encoded) + + assert decoded.version == config.version + assert decoded.poll_interval_seconds == config.poll_interval_seconds + assert length(decoded.devices) == 1 + assert length(decoded.checks) == 1 + + dev = hd(decoded.devices) + assert dev.id == "device-1" + assert dev.name == "core-switch" + assert dev.ip_address == "192.168.1.1" + assert dev.snmp.enabled == true + assert dev.snmp.version == "2c" + assert dev.snmp.community == "public" + assert dev.snmp.port == 161 + assert dev.poll_interval_seconds == 300 + assert length(dev.sensors) == 1 + assert hd(dev.sensors).id == "sensor-1" + assert hd(dev.sensors).sensor_type == "temperature" + assert hd(dev.sensors).divisor == 10.0 + assert length(dev.interfaces) == 1 + assert hd(dev.interfaces).if_index == 1 + assert hd(dev.interfaces).if_name == "GigabitEthernet0/1" + assert dev.monitoring_enabled == true + assert dev.check_interval_seconds == 60 + + chk = hd(decoded.checks) + assert chk.id == "check-1" + assert chk.check_type == "http" + assert chk.interval_seconds == 60 + assert chk.timeout_ms == 5000 + end + + test "encodes and decodes config with empty devices and checks" do + config = %AgentConfig{ + version: "2.0.0", + poll_interval_seconds: 120, + devices: [], + checks: [] + } + + encoded = Encode.encode_agent_config(config) + assert {:ok, decoded} = Decode.decode_agent_config(encoded) + + assert decoded.version == "2.0.0" + assert decoded.poll_interval_seconds == 120 + assert decoded.devices == [] + assert decoded.checks == [] + end + + test "encodes and decodes config with device without snmp" do + config = %AgentConfig{ + version: "1.0.0", + poll_interval_seconds: 300, + devices: [ + %Device{ + id: "device-nosnmp", + name: "non-snmp-device", + ip_address: "10.0.0.1", + snmp: nil, + poll_interval_seconds: 300, + sensors: [], + interfaces: [], + monitoring_enabled: false, + check_interval_seconds: 0 + } + ], + checks: [] + } + + encoded = Encode.encode_agent_config(config) + assert {:ok, decoded} = Decode.decode_agent_config(encoded) + + assert length(decoded.devices) == 1 + assert hd(decoded.devices).snmp == nil + assert hd(decoded.devices).monitoring_enabled == false + end + + test "rejects wrong wire type for string field" do + # field 1 (version) expects wire type 2 (LEN), send wire type 0 (VARINT) + bin = <<0x08, 0x01>> + assert {:error, _reason} = Decode.decode_agent_config(bin) + end + end + + describe "Decode.decode_agent_job_list" do + test "encodes and decodes a list of jobs" do + job_list = %Towerops.Proto.Types.AgentJobList{ + jobs: [ + %Towerops.Proto.Types.AgentJob{ + job_id: "poll:device-1", + job_type: :poll, + device_id: "device-1", + snmp_device: %Towerops.Proto.Types.SnmpDevice{ + ip: "192.168.1.1", + community: "public", + version: "2c", + port: 161, + v3_security_level: "", + v3_username: "", + v3_auth_protocol: "", + v3_auth_password: "", + v3_priv_protocol: "", + v3_priv_password: "", + transport: "udp" + }, + queries: [ + %Towerops.Proto.Types.SnmpQuery{ + query_type: :get, + oids: ["1.3.6.1.2.1.1.1.0"] + } + ], + mikrotik_device: nil, + mikrotik_commands: [] + }, + %Towerops.Proto.Types.AgentJob{ + job_id: "discover:device-2", + job_type: :discover, + device_id: "device-2", + snmp_device: %Towerops.Proto.Types.SnmpDevice{ + ip: "10.0.0.2", + community: "private", + version: "2c", + port: 161, + v3_security_level: "", + v3_username: "", + v3_auth_protocol: "", + v3_auth_password: "", + v3_priv_protocol: "", + v3_priv_password: "", + transport: "" + }, + queries: [ + %Towerops.Proto.Types.SnmpQuery{ + query_type: :walk, + oids: ["1.3.6.1.2.1.2.2.1"] + } + ], + mikrotik_device: nil, + mikrotik_commands: [] + } + ] + } + + encoded = Encode.encode_agent_job_list(job_list) + assert {:ok, decoded} = Decode.decode_agent_job_list(encoded) + + assert length(decoded.jobs) == 2 + + first = Enum.at(decoded.jobs, 0) + assert first.job_id == "poll:device-1" + assert first.job_type == :poll + assert first.device_id == "device-1" + assert first.snmp_device.ip == "192.168.1.1" + assert hd(first.queries).query_type == :get + + second = Enum.at(decoded.jobs, 1) + assert second.job_id == "discover:device-2" + assert second.job_type == :discover + assert second.device_id == "device-2" + assert hd(second.queries).query_type == :walk + end + + test "encodes and decodes empty job list" do + job_list = %Towerops.Proto.Types.AgentJobList{jobs: []} + encoded = Encode.encode_agent_job_list(job_list) + assert {:ok, decoded} = Decode.decode_agent_job_list(encoded) + + assert decoded.jobs == [] + end + + test "encodes and decodes single job" do + job_list = %Towerops.Proto.Types.AgentJobList{ + jobs: [ + %Towerops.Proto.Types.AgentJob{ + job_id: "single:device-1", + job_type: :poll, + device_id: "device-1", + snmp_device: nil, + queries: [], + mikrotik_device: nil, + mikrotik_commands: [] + } + ] + } + + encoded = Encode.encode_agent_job_list(job_list) + assert {:ok, decoded} = Decode.decode_agent_job_list(encoded) + + assert length(decoded.jobs) == 1 + assert hd(decoded.jobs).job_id == "single:device-1" + end + + test "rejects wrong wire type" do + bin = <<0x08, 0x01>> + assert {:error, _reason} = Decode.decode_agent_job_list(bin) + end + end + + describe "Decode.decode_check_list" do + test "encodes and decodes a list of checks with http config" do + check_list = %CheckList{ + checks: [ + %Check{ + id: "http-check-1", + check_type: "http", + interval_seconds: 60, + timeout_ms: 5000, + config: + {:http, + %HttpCheckConfig{ + url: "https://example.com/ping", + method: "GET", + expected_status: 200, + verify_ssl: true, + headers: %{}, + body: "", + regex: "OK", + follow_redirects: false + }} + } + ] + } + + encoded = Encode.encode_check_list(check_list) + assert {:ok, decoded} = Decode.decode_check_list(encoded) + + assert length(decoded.checks) == 1 + check = hd(decoded.checks) + assert check.id == "http-check-1" + assert check.check_type == "http" + assert check.interval_seconds == 60 + assert check.timeout_ms == 5000 + + assert {:http, http_config} = check.config + assert http_config.url == "https://example.com/ping" + assert http_config.method == "GET" + assert http_config.expected_status == 200 + assert http_config.verify_ssl == true + assert http_config.regex == "OK" + end + + test "encodes and decodes checks with different config types" do + check_list = %CheckList{ + checks: [ + %Check{ + id: "tcp-check-1", + check_type: "tcp", + interval_seconds: 120, + timeout_ms: 3000, + config: + {:tcp, + %Towerops.Proto.Types.TcpCheckConfig{ + host: "smtp.example.com", + port: 25, + send: "", + expect: "220" + }} + }, + %Check{ + id: "dns-check-1", + check_type: "dns", + interval_seconds: 300, + timeout_ms: 5000, + config: + {:dns, + %Towerops.Proto.Types.DnsCheckConfig{ + hostname: "example.com", + server: "8.8.8.8", + record_type: "A", + expected: "93.184.216.34" + }} + }, + %Check{ + id: "ssl-check-1", + check_type: "ssl", + interval_seconds: 86_400, + timeout_ms: 10_000, + config: + {:ssl, + %Towerops.Proto.Types.SslCheckConfig{ + host: "example.com", + port: 443, + warning_days: 30 + }} + } + ] + } + + encoded = Encode.encode_check_list(check_list) + assert {:ok, decoded} = Decode.decode_check_list(encoded) + + assert length(decoded.checks) == 3 + + tcp = Enum.at(decoded.checks, 0) + assert tcp.id == "tcp-check-1" + assert {:tcp, tcp_conf} = tcp.config + assert tcp_conf.host == "smtp.example.com" + assert tcp_conf.port == 25 + + dns = Enum.at(decoded.checks, 1) + assert dns.id == "dns-check-1" + assert {:dns, dns_conf} = dns.config + assert dns_conf.hostname == "example.com" + assert dns_conf.server == "8.8.8.8" + + ssl = Enum.at(decoded.checks, 2) + assert ssl.id == "ssl-check-1" + assert {:ssl, ssl_conf} = ssl.config + assert ssl_conf.host == "example.com" + assert ssl_conf.port == 443 + assert ssl_conf.warning_days == 30 + end + + test "encodes and decodes empty check list" do + check_list = %CheckList{checks: []} + encoded = Encode.encode_check_list(check_list) + assert {:ok, decoded} = Decode.decode_check_list(encoded) + + assert decoded.checks == [] + end + + test "encodes and decodes check with no_config" do + check_list = %CheckList{ + checks: [ + %Check{ + id: "no-config-check", + check_type: "custom", + interval_seconds: 60, + timeout_ms: 1000, + config: :no_config + } + ] + } + + encoded = Encode.encode_check_list(check_list) + assert {:ok, decoded} = Decode.decode_check_list(encoded) + + assert length(decoded.checks) == 1 + assert hd(decoded.checks).config == :no_config + end + + test "rejects wrong wire type" do + bin = <<0x08, 0x01>> + assert {:error, _reason} = Decode.decode_check_list(bin) + end + end + + describe "Decode.decode_sensor" do + test "encodes and decodes sensor with metadata" do + sensor = %Towerops.Proto.Types.Sensor{ + id: "sensor-cpu-temp", + sensor_type: "temperature", + oid: "1.3.6.1.4.1.14988.1.1.3.10.0", + divisor: 10.0, + unit: "celsius", + metadata: %{"location" => "CPU Core 1", "threshold" => "80"} + } + + encoded = Encode.encode_sensor(sensor) + assert {:ok, decoded} = Decode.decode_sensor(encoded) + + assert decoded.id == sensor.id + assert decoded.sensor_type == sensor.sensor_type + assert decoded.oid == sensor.oid + assert decoded.divisor == sensor.divisor + assert decoded.unit == sensor.unit + assert decoded.metadata == sensor.metadata + assert decoded.metadata["location"] == "CPU Core 1" + end + + test "encodes and decodes sensor without metadata" do + sensor = %Towerops.Proto.Types.Sensor{ + id: "sensor-voltage", + sensor_type: "voltage", + oid: "1.3.6.1.4.1.14988.1.1.3.8.0", + divisor: 1.0, + unit: "volts", + metadata: %{} + } + + encoded = Encode.encode_sensor(sensor) + assert {:ok, decoded} = Decode.decode_sensor(encoded) + + assert decoded.id == sensor.id + assert decoded.sensor_type == sensor.sensor_type + assert decoded.oid == sensor.oid + assert decoded.divisor == sensor.divisor + assert decoded.unit == sensor.unit + assert decoded.metadata == %{} + end + + test "encodes and decodes empty sensor" do + sensor = %Towerops.Proto.Types.Sensor{ + id: "", + sensor_type: "", + oid: "", + divisor: 0.0, + unit: "", + metadata: %{} + } + + encoded = Encode.encode_sensor(sensor) + assert {:ok, decoded} = Decode.decode_sensor(encoded) + + assert decoded.id == "" + assert decoded.sensor_type == "" + assert decoded.oid == "" + assert decoded.divisor == 0.0 + assert decoded.unit == "" + assert decoded.metadata == %{} + end + + test "encodes and decodes sensor with zero divisor" do + sensor = %Towerops.Proto.Types.Sensor{ + id: "sensor-unscaled", + sensor_type: "counter", + oid: "1.3.6.1.2.1.2.2.1.10", + divisor: 0.0, + unit: "bytes", + metadata: %{} + } + + encoded = Encode.encode_sensor(sensor) + assert {:ok, decoded} = Decode.decode_sensor(encoded) + + assert decoded.divisor == 0.0 + end + + test "rejects wrong wire type for string field" do + # field 1 (id) expects wire type 2 (LEN), send wire type 0 (VARINT) + bin = <<0x08, 0x01>> + assert {:error, _reason} = Decode.decode_sensor(bin) + end + end + + describe "Decode.decode_monitoring_check" do + test "encodes and decodes success check" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: @device_uuid, + status: "success", + response_time_ms: 45.2, + timestamp: 1_234_567_890 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:ok, decoded} = Decode.decode_monitoring_check(encoded) + + assert decoded.device_id == check.device_id + assert decoded.status == check.status + assert decoded.response_time_ms == check.response_time_ms + assert decoded.timestamp == check.timestamp + end + + test "encodes and decodes failure check" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: @device_uuid, + status: "failure", + response_time_ms: 0.0, + timestamp: 1_234_567_890 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:ok, decoded} = Decode.decode_monitoring_check(encoded) + + assert decoded.device_id == check.device_id + assert decoded.status == check.status + assert decoded.response_time_ms == check.response_time_ms + assert decoded.timestamp == check.timestamp + end + + test "decodes empty device_id as error" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: "", + status: "success", + response_time_ms: 10.0, + timestamp: 1_234_567_890 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:error, _reason} = Decode.decode_monitoring_check(encoded) + end + + test "decodes invalid device_id format as error" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: "not-a-uuid", + status: "success", + response_time_ms: 10.0, + timestamp: 1_234_567_890 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:error, _reason} = Decode.decode_monitoring_check(encoded) + end + + test "rejects status string that is too long" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: @device_uuid, + status: String.duplicate("a", 256), + response_time_ms: 10.0, + timestamp: 1_234_567_890 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:error, _reason} = Decode.decode_monitoring_check(encoded) + end + + test "rejects negative response time" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: @device_uuid, + status: "success", + response_time_ms: -1.0, + timestamp: 1_234_567_890 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:error, _reason} = Decode.decode_monitoring_check(encoded) + end + + test "rejects response time that is too large" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: @device_uuid, + status: "success", + response_time_ms: 60_001, + timestamp: 1_234_567_890 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:error, _reason} = Decode.decode_monitoring_check(encoded) + end + + test "rejects negative timestamp" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: @device_uuid, + status: "success", + response_time_ms: 10.0, + timestamp: -1 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:error, _reason} = Decode.decode_monitoring_check(encoded) + end + + test "rejects timestamp that is too large" do + check = %Towerops.Proto.Types.MonitoringCheck{ + device_id: @device_uuid, + status: "success", + response_time_ms: 10.0, + timestamp: 2_147_483_648 + } + + encoded = Encode.encode_monitoring_check(check) + assert {:error, _reason} = Decode.decode_monitoring_check(encoded) + end + + test "rejects wrong wire type for string field" do + # field 1 (device_id) expects wire type 2 (LEN), send wire type 0 (VARINT) + bin = <<0x08, 0x01>> + assert {:error, _reason} = Decode.decode_monitoring_check(bin) + end + + test "rejects wrong wire type for double field" do + # field 3 (response_time_ms) expects wire type 1 (64-bit), send wire type 0 (VARINT) + bin = <<0x18, 0x00>> + assert {:error, _reason} = Decode.decode_monitoring_check(bin) + end + end end diff --git a/test/towerops/proto/types_test.exs b/test/towerops/proto/types_test.exs new file mode 100644 index 00000000..6a179e3f --- /dev/null +++ b/test/towerops/proto/types_test.exs @@ -0,0 +1,86 @@ +defmodule Towerops.Proto.TypesTest do + use ExUnit.Case, async: true + + alias Towerops.Proto.Types + + describe "job_type_to_int/1" do + test "returns 0 for :discover" do + assert Types.job_type_to_int(:discover) == 0 + end + + test "returns 1 for :poll" do + assert Types.job_type_to_int(:poll) == 1 + end + + test "returns 2 for :mikrotik" do + assert Types.job_type_to_int(:mikrotik) == 2 + end + + test "returns 3 for :test_credentials" do + assert Types.job_type_to_int(:test_credentials) == 3 + end + + test "returns 4 for :ping" do + assert Types.job_type_to_int(:ping) == 4 + end + + test "returns 5 for :lldp_topology" do + assert Types.job_type_to_int(:lldp_topology) == 5 + end + end + + describe "job_type_from_int/1" do + test "returns {:ok, :discover} for 0" do + assert Types.job_type_from_int(0) == {:ok, :discover} + end + + test "returns {:ok, :poll} for 1" do + assert Types.job_type_from_int(1) == {:ok, :poll} + end + + test "returns {:ok, :mikrotik} for 2" do + assert Types.job_type_from_int(2) == {:ok, :mikrotik} + end + + test "returns {:ok, :test_credentials} for 3" do + assert Types.job_type_from_int(3) == {:ok, :test_credentials} + end + + test "returns {:ok, :ping} for 4" do + assert Types.job_type_from_int(4) == {:ok, :ping} + end + + test "returns {:ok, :lldp_topology} for 5" do + assert Types.job_type_from_int(5) == {:ok, :lldp_topology} + end + + test "returns :error for unknown value" do + assert Types.job_type_from_int(99) == :error + assert Types.job_type_from_int(-1) == :error + end + end + + describe "query_type_to_int/1" do + test "returns 0 for :get" do + assert Types.query_type_to_int(:get) == 0 + end + + test "returns 1 for :walk" do + assert Types.query_type_to_int(:walk) == 1 + end + end + + describe "query_type_from_int/1" do + test "returns {:ok, :get} for 0" do + assert Types.query_type_from_int(0) == {:ok, :get} + end + + test "returns {:ok, :walk} for 1" do + assert Types.query_type_from_int(1) == {:ok, :walk} + end + + test "returns :error for unknown value" do + assert Types.query_type_from_int(99) == :error + end + end +end diff --git a/test/towerops/snmp/profiles/vendors/airos_test.exs b/test/towerops/snmp/profiles/vendors/airos_test.exs index 4da7cbb4..56ed09bd 100644 --- a/test/towerops/snmp/profiles/vendors/airos_test.exs +++ b/test/towerops/snmp/profiles/vendors/airos_test.exs @@ -1,248 +1,50 @@ defmodule Towerops.Snmp.Profiles.Vendors.AirosTest do - use Towerops.DataCase, async: true - - import Mox + use ExUnit.Case, async: true alias Towerops.Snmp.Profiles.Vendors.Airos - alias Towerops.Snmp.SnmpMock - - setup :verify_on_exit! - - @client_opts [ - ip: "192.168.1.1", - community: "public", - version: "2c", - port: 161, - timeout: 5000 - ] describe "profile_names/0" do - test "returns expected profile names" do + test "returns airos" do assert Airos.profile_names() == ["airos"] end end - describe "detect_hardware/1" do - test "returns product name when SNMP responds" do - expect(SnmpMock, :get_next, fn _, "1.2.840.10036.3.1.2.1.3", _ -> - {:ok, %{oid: "1.2.840.10036.3.1.2.1.3.0", value: "LiteBeam 5AC Gen2"}} - end) - - assert Airos.detect_hardware(@client_opts) == "LiteBeam 5AC Gen2" - end - - test "returns nil when SNMP fails" do - expect(SnmpMock, :get_next, fn _, _, _ -> - {:error, :timeout} - end) - - assert Airos.detect_hardware(@client_opts) == nil - end - - test "returns nil when response is not a string" do - expect(SnmpMock, :get_next, fn _, "1.2.840.10036.3.1.2.1.3", _ -> - {:ok, %{oid: "1.2.840.10036.3.1.2.1.3.0", value: 12_345}} - end) - - assert Airos.detect_hardware(@client_opts) == nil - end - end - describe "wireless_oid_defs/0" do - test "returns list of sensor definitions" do + test "returns list of OID definitions" do defs = Airos.wireless_oid_defs() - assert is_list(defs) - assert length(defs) == 13 + assert defs != [] + + first = hd(defs) + assert Map.has_key?(first, :oid) + assert Map.has_key?(first, :sensor_type) + assert Map.has_key?(first, :sensor_descr) + assert Map.has_key?(first, :sensor_unit) + assert Map.has_key?(first, :sensor_divisor) end - test "each definition has required fields" do + test "includes frequency, power, distance sensors" do + defs = Airos.wireless_oid_defs() + types = defs |> Enum.map(& &1.sensor_type) |> Enum.uniq() + + assert "frequency" in types + assert "power" in types + assert "distance" in types + assert "rssi" in types + assert "ccq" in types + assert "clients" in types + end + + test "each OID has valid structure" do defs = Airos.wireless_oid_defs() - Enum.each(defs, fn def -> - assert Map.has_key?(def, :oid) - assert Map.has_key?(def, :sensor_descr) - assert Map.has_key?(def, :sensor_type) - assert Map.has_key?(def, :sensor_unit) - assert Map.has_key?(def, :sensor_divisor) - end) - end + for d <- defs do + assert String.starts_with?(d.oid, "1.3.6.1.4.1.41112"), + "Expected OID #{d.oid} to start with UBNT prefix" - test "frequency uses ubntRadioFreq OID" do - defs = Airos.wireless_oid_defs() - freq = Enum.find(defs, &(&1.sensor_descr == "Frequency")) - - assert freq.oid == "1.3.6.1.4.1.41112.1.4.1.1.4.1" - assert freq.sensor_type == "frequency" - assert freq.sensor_unit == "MHz" - end - - test "tx power uses ubntRadioTxPower OID" do - defs = Airos.wireless_oid_defs() - sensor = Enum.find(defs, &(&1.sensor_descr == "Tx Power")) - - assert sensor.oid == "1.3.6.1.4.1.41112.1.4.1.1.6.1" - assert sensor.sensor_type == "power" - assert sensor.sensor_unit == "dBm" - end - - test "signal level uses ubntWlStatSignal OID" do - defs = Airos.wireless_oid_defs() - sensor = Enum.find(defs, &(&1.sensor_descr == "Signal Level")) - - assert sensor.oid == "1.3.6.1.4.1.41112.1.4.5.1.5.1" - assert sensor.sensor_type == "power" - assert sensor.sensor_unit == "dBm" - end - - test "RSSI uses ubntWlStatRssi OID (column 6, not Signal column 5)" do - defs = Airos.wireless_oid_defs() - rssi = Enum.find(defs, &(&1.sensor_type == "rssi")) - - assert rssi.oid == "1.3.6.1.4.1.41112.1.4.5.1.6.1" - assert rssi.sensor_unit == "dBm" - end - - test "CCQ uses ubntWlStatCcq OID" do - defs = Airos.wireless_oid_defs() - ccq = Enum.find(defs, &(&1.sensor_descr == "CCQ")) - - assert ccq.oid == "1.3.6.1.4.1.41112.1.4.5.1.7.1" - assert ccq.sensor_type == "ccq" - assert ccq.sensor_unit == "%" - end - - test "tx/rx rate use ubntWlStatTxRate/RxRate OIDs (columns 9/10)" do - defs = Airos.wireless_oid_defs() - tx = Enum.find(defs, &(&1.sensor_descr == "Tx Rate")) - rx = Enum.find(defs, &(&1.sensor_descr == "Rx Rate")) - - assert tx.oid == "1.3.6.1.4.1.41112.1.4.5.1.9.1" - assert rx.oid == "1.3.6.1.4.1.41112.1.4.5.1.10.1" - assert tx.sensor_type == "rate" - assert rx.sensor_type == "rate" - end - - test "quality uses ubntAirMaxQuality OID from airMax table" do - defs = Airos.wireless_oid_defs() - quality = Enum.find(defs, &(&1.sensor_type == "quality")) - - assert quality.oid == "1.3.6.1.4.1.41112.1.4.6.1.3.1" - assert quality.sensor_unit == "%" - end - - test "capacity uses ubntAirMaxCapacity OID from airMax table" do - defs = Airos.wireless_oid_defs() - capacity = Enum.find(defs, &(&1.sensor_descr == "Capacity")) - - assert capacity.oid == "1.3.6.1.4.1.41112.1.4.6.1.4.1" - assert capacity.sensor_type == "capacity" - assert capacity.sensor_unit == "%" - end - - test "utilization uses ubntAirMaxAirtime OID with divisor 10" do - defs = Airos.wireless_oid_defs() - util = Enum.find(defs, &(&1.sensor_type == "utilization")) - - assert util.oid == "1.3.6.1.4.1.41112.1.4.6.1.7.1" - assert util.sensor_divisor == 10 - assert util.sensor_unit == "%" - end - - test "distance uses ubntRadioDistance OID with divisor 1000" do - defs = Airos.wireless_oid_defs() - distance = Enum.find(defs, &(&1.sensor_type == "distance")) - - assert distance.oid == "1.3.6.1.4.1.41112.1.4.1.1.7.1" - assert distance.sensor_divisor == 1000 - assert distance.sensor_unit == "km" - end - - test "no duplicate OIDs" do - defs = Airos.wireless_oid_defs() - oids = Enum.map(defs, & &1.oid) - - assert length(oids) == length(Enum.uniq(oids)), - "Found duplicate OIDs: #{inspect(oids -- Enum.uniq(oids))}" - end - end - - describe "discover_wireless_sensors/1" do - test "discovers sensors when SNMP responds" do - defs = Airos.wireless_oid_defs() - num_defs = length(defs) - - expect(SnmpMock, :get, num_defs, fn _, _oid, _ -> - {:ok, 42} - end) - - sensors = Airos.discover_wireless_sensors(@client_opts) - - assert is_list(sensors) - assert length(sensors) == num_defs - end - - test "returns empty list when no sensors respond" do - defs = Airos.wireless_oid_defs() - num_defs = length(defs) - - expect(SnmpMock, :get, num_defs, fn _, _, _ -> - {:error, :no_such_object} - end) - - sensors = Airos.discover_wireless_sensors(@client_opts) - - assert sensors == [] - end - - test "discovers sensors with correct OIDs and values" do - expect(SnmpMock, :get, 13, fn _target, oid, _opts -> - cond do - # Radio table OIDs - String.contains?(oid, "41112.1.4.1.1.4.1") -> {:ok, 5180} - String.contains?(oid, "41112.1.4.1.1.6.1") -> {:ok, 20} - String.contains?(oid, "41112.1.4.1.1.7.1") -> {:ok, 15_200} - # WlStat table OIDs - String.contains?(oid, "41112.1.4.5.1.5.1") -> {:ok, -62} - String.contains?(oid, "41112.1.4.5.1.6.1") -> {:ok, -60} - String.contains?(oid, "41112.1.4.5.1.7.1") -> {:ok, 85} - String.contains?(oid, "41112.1.4.5.1.8.1") -> {:ok, -95} - String.contains?(oid, "41112.1.4.5.1.9.1") -> {:ok, 300} - String.contains?(oid, "41112.1.4.5.1.10.1") -> {:ok, 300} - String.contains?(oid, "41112.1.4.5.1.15.1") -> {:ok, 12} - # AirMax table OIDs - String.contains?(oid, "41112.1.4.6.1.3.1") -> {:ok, 75} - String.contains?(oid, "41112.1.4.6.1.4.1") -> {:ok, 80} - String.contains?(oid, "41112.1.4.6.1.7.1") -> {:ok, 450} - true -> {:error, :no_such_object} - end - end) - - sensors = Airos.discover_wireless_sensors(@client_opts) - - assert length(sensors) == 13 - - # Verify frequency - freq = Enum.find(sensors, &(&1.sensor_type == "frequency")) - assert freq.last_value == 5180.0 - - # Verify RSSI uses correct OID (column 6) - rssi = Enum.find(sensors, &(&1.sensor_type == "rssi")) - assert rssi.last_value == -60.0 - assert rssi.sensor_oid == "1.3.6.1.4.1.41112.1.4.5.1.6.1" - - # Verify rates - rates = Enum.filter(sensors, &(&1.sensor_type == "rate")) - assert length(rates) == 2 - - # Verify distance with divisor - distance = Enum.find(sensors, &(&1.sensor_type == "distance")) - assert distance.sensor_divisor == 1000 - assert distance.last_value == 15_200.0 - - # Verify utilization with divisor - util = Enum.find(sensors, &(&1.sensor_type == "utilization")) - assert util.sensor_divisor == 10 + assert is_integer(d.sensor_divisor) + assert d.sensor_divisor > 0 + end end end end diff --git a/test/towerops/status_pages/status_page_component_test.exs b/test/towerops/status_pages/status_page_component_test.exs new file mode 100644 index 00000000..d8e24a9c --- /dev/null +++ b/test/towerops/status_pages/status_page_component_test.exs @@ -0,0 +1,40 @@ +defmodule Towerops.StatusPages.StatusPageComponentTest do + use Towerops.DataCase, async: true + + alias Towerops.StatusPages.StatusPageComponent + + describe "valid_statuses/0" do + test "returns list of valid statuses" do + assert StatusPageComponent.valid_statuses() == ~w(operational degraded partial_outage major_outage maintenance) + end + end + + describe "changeset/2" do + test "valid changeset with valid attrs" do + attrs = %{name: "Internet", status_page_config_id: Ecto.UUID.generate()} + changeset = StatusPageComponent.changeset(%StatusPageComponent{}, attrs) + assert changeset.valid? + end + + test "invalid without name" do + attrs = %{status_page_config_id: Ecto.UUID.generate()} + changeset = StatusPageComponent.changeset(%StatusPageComponent{}, attrs) + refute changeset.valid? + assert changeset.errors[:name] + end + + test "invalid without status_page_config_id" do + attrs = %{name: "Internet"} + changeset = StatusPageComponent.changeset(%StatusPageComponent{}, attrs) + refute changeset.valid? + assert changeset.errors[:status_page_config_id] + end + + test "invalid status" do + attrs = %{name: "Internet", status: "invalid_status", status_page_config_id: Ecto.UUID.generate()} + changeset = StatusPageComponent.changeset(%StatusPageComponent{}, attrs) + refute changeset.valid? + assert changeset.errors[:status] + end + end +end diff --git a/test/towerops/uisp/client_test.exs b/test/towerops/uisp/client_test.exs index 8c36b168..aa5611ed 100644 --- a/test/towerops/uisp/client_test.exs +++ b/test/towerops/uisp/client_test.exs @@ -3,153 +3,148 @@ defmodule Towerops.Uisp.ClientTest do alias Towerops.Uisp.Client - @base_url "https://uisp.example.com/nms/api/v2.1" - @api_key "test-api-key" - describe "test_connection/2" do - test "returns ok when API responds with 200" do + test "returns {:ok, body} on 200" do Req.Test.stub(Client, fn conn -> - assert conn.request_path == "/nms/api/v2.1/sites" - assert conn.query_string =~ "count=1" - assert Plug.Conn.get_req_header(conn, "x-auth-token") == [@api_key] - - Req.Test.json(conn, [%{"id" => "site-1", "identification" => %{"name" => "Tower 1"}}]) + Req.Test.json(conn, %{id: 1, name: "Site-A"}) end) - assert {:ok, _body} = Client.test_connection(@base_url, @api_key) + assert {:ok, _} = Client.test_connection("https://uisp.example.com", "key") end - test "returns error for 401 unauthorized" do + test "returns error on 401" do Req.Test.stub(Client, fn conn -> - conn - |> Plug.Conn.put_status(401) - |> Req.Test.json(%{"error" => "unauthorized"}) + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) end) - assert {:error, :unauthorized} = Client.test_connection(@base_url, "bad-key") + assert {:error, :unauthorized} = Client.test_connection("https://uisp.example.com", "bad-key") end - test "returns error for 403 forbidden" do + test "returns error on 403" do Req.Test.stub(Client, fn conn -> - conn - |> Plug.Conn.put_status(403) - |> Req.Test.json(%{"error" => "forbidden"}) + conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{}) end) - assert {:error, :forbidden} = Client.test_connection(@base_url, "restricted-key") + assert {:error, :forbidden} = Client.test_connection("https://uisp.example.com", "bad-key") end - test "returns unexpected_status for other error codes" do + test "returns error on unexpected status" do Req.Test.stub(Client, fn conn -> - conn - |> Plug.Conn.put_status(500) - |> Req.Test.json(%{"error" => "internal server error"}) + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{error: "internal"}) end) - assert {:error, {:unexpected_status, 500}} = Client.test_connection(@base_url, @api_key) + assert {:error, {:unexpected_status, 500}} = Client.test_connection("https://uisp.example.com", "bad-key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.test_connection("https://uisp.example.com", "bad-key") end end describe "list_sites/2" do - test "returns list of sites on success" do + test "returns {:ok, list} with direct array" do Req.Test.stub(Client, fn conn -> - assert conn.request_path == "/nms/api/v2.1/sites" - - Req.Test.json(conn, [ - %{ - "id" => "site-1", - "identification" => %{"id" => "site-1", "name" => "Tower A"}, - "description" => %{"gps" => %{"latitude" => 40.7128, "longitude" => -74.006}} - }, - %{ - "id" => "site-2", - "identification" => %{"id" => "site-2", "name" => "Tower B"}, - "description" => %{"gps" => %{"latitude" => 34.0522, "longitude" => -118.2437}} - } - ]) + Req.Test.json(conn, [%{id: 1, name: "Site-A"}]) end) - assert {:ok, [site1, site2]} = Client.list_sites(@base_url, @api_key) - assert get_in(site1, ["identification", "name"]) == "Tower A" - assert get_in(site2, ["identification", "name"]) == "Tower B" + assert {:ok, [%{"id" => 1}]} = Client.list_sites("https://uisp.example.com", "key") end - test "returns empty list for empty response" do + test "returns {:ok, list} with items wrapper" do Req.Test.stub(Client, fn conn -> - Req.Test.json(conn, []) + Req.Test.json(conn, %{items: [%{id: 1, name: "Site-A"}]}) end) - assert {:ok, []} = Client.list_sites(@base_url, @api_key) + assert {:ok, [%{"id" => 1}]} = Client.list_sites("https://uisp.example.com", "key") end - test "returns error for 401" do + test "returns {:ok, []} for unexpected body shape" do Req.Test.stub(Client, fn conn -> - conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"}) + Req.Test.json(conn, %{unexpected: true}) end) - assert {:error, :unauthorized} = Client.list_sites(@base_url, "bad-key") + assert {:ok, []} = Client.list_sites("https://uisp.example.com", "key") end - test "returns error for 403" do + test "returns error on 401" do Req.Test.stub(Client, fn conn -> - conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{"error" => "forbidden"}) + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) end) - assert {:error, :forbidden} = Client.list_sites(@base_url, "bad-key") + assert {:error, :unauthorized} = Client.list_sites("https://uisp.example.com", "key") + end + + test "returns error on unexpected status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{error: "internal"}) + end) + + assert {:error, {:unexpected_status, 500, %{"error" => "internal"}}} = + Client.list_sites("https://uisp.example.com", "key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.list_sites("https://uisp.example.com", "key") end end describe "list_devices/2" do - test "returns list of devices on success" do + test "returns {:ok, list}" do Req.Test.stub(Client, fn conn -> - assert conn.request_path == "/nms/api/v2.1/devices" - - Req.Test.json(conn, [ - %{ - "identification" => %{ - "id" => "dev-1", - "name" => "AP-Tower-A", - "mac" => "AA:BB:CC:DD:EE:FF", - "type" => "airMax" - }, - "ipAddress" => "10.0.0.1", - "overview" => %{"status" => "active"}, - "site" => %{"id" => "site-1"} - }, - %{ - "identification" => %{ - "id" => "dev-2", - "name" => "AP-Tower-B", - "mac" => "11:22:33:44:55:66", - "type" => "airMax" - }, - "ipAddress" => "10.0.0.2", - "overview" => %{"status" => "active"}, - "site" => %{"id" => "site-2"} - } - ]) + Req.Test.json(conn, [%{id: 1, name: "Device-A"}]) end) - assert {:ok, [dev1, dev2]} = Client.list_devices(@base_url, @api_key) - assert get_in(dev1, ["identification", "name"]) == "AP-Tower-A" - assert dev1["ipAddress"] == "10.0.0.1" - assert get_in(dev2, ["identification", "name"]) == "AP-Tower-B" + assert {:ok, [%{"id" => 1}]} = Client.list_devices("https://uisp.example.com", "key") + end + end + + describe "request_raw/3" do + test "returns {:ok, body} on 200" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{status: "ok"}) + end) + + assert {:ok, %{"status" => "ok"}} = Client.request_raw(:get, "https://uisp.example.com/sites", "key") end - test "returns error for 401" do + test "returns error on 401" do Req.Test.stub(Client, fn conn -> - conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"}) + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) end) - assert {:error, :unauthorized} = Client.list_devices(@base_url, "bad-key") + assert {:error, :unauthorized} = Client.request_raw(:get, "https://uisp.example.com/sites", "key") end - test "normalizes non-list response to empty list" do + test "returns error on 404" do Req.Test.stub(Client, fn conn -> - Req.Test.json(conn, %{"unexpected" => "format"}) + conn |> Plug.Conn.put_status(404) |> Req.Test.json(%{}) end) - assert {:ok, []} = Client.list_devices(@base_url, @api_key) + assert {:error, :not_found} = Client.request_raw(:get, "https://uisp.example.com/sites", "key") + end + + test "returns error on unexpected status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{}) + end) + + assert {:error, {:unexpected_status, 500, %{}}} = Client.request_raw(:get, "https://uisp.example.com/sites", "key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.request_raw(:get, "https://uisp.example.com/sites", "key") end end end diff --git a/test/towerops/url_validator_test.exs b/test/towerops/url_validator_test.exs index d0be664b..6e47dde0 100644 --- a/test/towerops/url_validator_test.exs +++ b/test/towerops/url_validator_test.exs @@ -4,131 +4,42 @@ defmodule Towerops.URLValidatorTest do alias Towerops.URLValidator describe "validate/1" do - test "allows valid HTTP URL" do - assert :ok = URLValidator.validate("http://example.com") - end - - test "allows valid HTTPS URL" do + test "accepts valid https URL" do assert :ok = URLValidator.validate("https://example.com") end - test "allows URL with path" do - assert :ok = URLValidator.validate("https://example.com/path/to/resource") + test "accepts valid http URL" do + assert :ok = URLValidator.validate("http://example.com") end - test "allows URL with query params" do - assert :ok = URLValidator.validate("https://example.com/search?q=test&page=1") - end - - test "allows URL with port" do - assert :ok = URLValidator.validate("https://example.com:8443/api") - end - - # Scheme enforcement - test "rejects FTP scheme" do - assert {:error, "Only http and https schemes are allowed"} = - URLValidator.validate("ftp://example.com") - end - - test "rejects file scheme" do - assert {:error, _} = URLValidator.validate("file:///etc/passwd") - end - - test "rejects javascript scheme" do - assert {:error, _} = URLValidator.validate("javascript:alert(1)") + test "rejects non-string input" do + assert {:error, _} = URLValidator.validate(nil) + assert {:error, _} = URLValidator.validate(123) + assert {:error, _} = URLValidator.validate(:atom) end test "rejects URL without scheme" do - assert {:error, "URL must include a scheme (http or https)"} = - URLValidator.validate("example.com") + assert {:error, _} = URLValidator.validate("example.com") end test "rejects URL without host" do assert {:error, _} = URLValidator.validate("http://") end - # Localhost blocking - test "rejects localhost" do - assert {:error, "Requests to localhost are not allowed"} = - URLValidator.validate("http://localhost") + test "rejects non-http scheme" do + assert {:error, _} = URLValidator.validate("ftp://example.com") + assert {:error, _} = URLValidator.validate("file:///etc/passwd") end - test "rejects localhost with port" do - assert {:error, "Requests to localhost are not allowed"} = - URLValidator.validate("http://localhost:3000") + test "rejects localhost" do + assert {:error, _} = URLValidator.validate("http://localhost") + assert {:error, _} = URLValidator.validate("http://LOCALHOST") + assert {:error, _} = URLValidator.validate("https://localhost:8080") end test "rejects .local domains" do - assert {:error, "Requests to localhost are not allowed"} = - URLValidator.validate("http://myservice.local") - end - - test "rejects LOCALHOST (case insensitive)" do - assert {:error, "Requests to localhost are not allowed"} = - URLValidator.validate("http://LOCALHOST") - end - - # Private IP blocking - test "rejects 127.0.0.1 (loopback)" do - assert {:error, "Requests to private/internal IP addresses are not allowed"} = - URLValidator.validate("http://127.0.0.1") - end - - test "rejects 127.x.x.x range" do - assert {:error, "Requests to private/internal IP addresses are not allowed"} = - URLValidator.validate("http://127.0.0.2") - end - - test "rejects 10.x.x.x (private)" do - assert {:error, "Requests to private/internal IP addresses are not allowed"} = - URLValidator.validate("http://10.0.0.1") - end - - test "rejects 172.16.x.x (private)" do - assert {:error, "Requests to private/internal IP addresses are not allowed"} = - URLValidator.validate("http://172.16.0.1") - end - - test "rejects 192.168.x.x (private)" do - assert {:error, "Requests to private/internal IP addresses are not allowed"} = - URLValidator.validate("http://192.168.1.1") - end - - test "rejects 169.254.x.x (link-local)" do - assert {:error, "Requests to private/internal IP addresses are not allowed"} = - URLValidator.validate("http://169.254.169.254") - end - - test "allows public IP addresses" do - assert :ok = URLValidator.validate("http://8.8.8.8") - end - - test "allows 172.32.x.x (outside private range)" do - assert :ok = URLValidator.validate("http://172.32.0.1") - end - - # Non-string input - test "rejects non-string input" do - assert {:error, "URL must be a string"} = URLValidator.validate(123) - end - - test "rejects nil input" do - assert {:error, "URL must be a string"} = URLValidator.validate(nil) - end - - test "rejects atom input" do - assert {:error, "URL must be a string"} = URLValidator.validate(:not_a_url) - end - - # Edge cases - test "rejects empty string host" do - assert {:error, _} = URLValidator.validate("http:///path") - end - - # IPv6 loopback - test "rejects IPv6 loopback ::1" do - assert {:error, "Requests to private/internal IP addresses are not allowed"} = - URLValidator.validate("http://[::1]") + assert {:error, _} = URLValidator.validate("http://router.local") + assert {:error, _} = URLValidator.validate("https://device.local:8443") end end end diff --git a/test/towerops/visp/client_test.exs b/test/towerops/visp/client_test.exs new file mode 100644 index 00000000..855c422d --- /dev/null +++ b/test/towerops/visp/client_test.exs @@ -0,0 +1,127 @@ +defmodule Towerops.Visp.ClientTest do + use ExUnit.Case, async: true + + alias Towerops.Visp.Client + + describe "test_connection/1" do + test "returns {:ok, _} on successful subscriber fetch" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{data: []}) + end) + + assert {:ok, %{}} = Client.test_connection("api-key") + end + + test "returns error on 401" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{error: "unauthorized"}) + end) + + assert {:error, :unauthorized} = Client.test_connection("bad-key") + end + + test "returns error on 403" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(403) |> Req.Test.json(%{error: "forbidden"}) + end) + + assert {:error, :forbidden} = Client.test_connection("bad-key") + end + + test "returns error on 429" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(429) |> Req.Test.json(%{}) + end) + + assert {:error, {:rate_limited, 60}} = Client.test_connection("bad-key") + end + + test "returns error on unexpected status" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(500) |> Req.Test.json(%{error: "internal"}) + end) + + assert {:error, {:unexpected_status, 500}} = Client.test_connection("bad-key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.test_connection("bad-key") + end + end + + describe "list_subscribers/1" do + test "returns {:ok, list} from single page" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{data: [%{id: 1, email: "a@b.com"}], meta: %{last_page: 1}}) + end) + + assert {:ok, [%{"id" => 1}]} = Client.list_subscribers("key") + end + + test "returns {:ok, list} when response has no meta" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{data: [%{id: 1}]}) + end) + + assert {:ok, [%{"id" => 1}]} = Client.list_subscribers("key") + end + + test "returns {:ok, list} when response is a flat array" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, [%{id: 1}, %{id: 2}]) + end) + + assert {:ok, [%{"id" => 1}, %{"id" => 2}]} = Client.list_subscribers("key") + end + + test "returns error on HTTP failure" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{}) + end) + + assert {:error, :unauthorized} = Client.list_subscribers("key") + end + end + + describe "list_sites/1" do + test "returns {:ok, list}" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{data: [%{id: 1, name: "Site-A"}], meta: %{last_page: 1}}) + end) + + assert {:ok, [%{"id" => 1}]} = Client.list_sites("key") + end + end + + describe "list_equipment/1" do + test "returns {:ok, list}" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{data: [%{id: 1, name: "EQ-1"}], meta: %{last_page: 1}}) + end) + + assert {:ok, [%{"id" => 1}]} = Client.list_equipment("key") + end + end + + describe "list_services/1" do + test "returns {:ok, body}" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{data: [%{id: 1, name: "Plan-A"}]}) + end) + + assert {:ok, %{"data" => [%{"id" => 1}]}} = Client.list_services("key") + end + + test "returns error on transport failure" do + Req.Test.stub(Client, fn conn -> + Req.Test.transport_error(conn, :econnrefused) + end) + + assert {:error, _} = Client.list_services("key") + end + end +end diff --git a/test/towerops_web/controllers/api_docs_controller_test.exs b/test/towerops_web/controllers/api_docs_controller_test.exs index 4dd149de..b1f1f2ff 100644 --- a/test/towerops_web/controllers/api_docs_controller_test.exs +++ b/test/towerops_web/controllers/api_docs_controller_test.exs @@ -1,17 +1,9 @@ defmodule ToweropsWeb.ApiDocsControllerTest do use ToweropsWeb.ConnCase, async: true - describe "index" do - test "renders API docs page for unauthenticated user", %{conn: conn} do - conn = get(conn, ~p"/docs/api") - - assert html_response(conn, 200) =~ "API" - end - - test "renders API docs page for authenticated user", %{conn: conn} do - conn = register_and_log_in_user(%{conn: conn}).conn - conn = get(conn, ~p"/docs/api") - + describe "GET /api/docs" do + test "renders index without login", %{conn: conn} do + conn = get(conn, "/docs/api") assert html_response(conn, 200) =~ "API" end end diff --git a/test/towerops_web/controllers/graphql_docs_controller_test.exs b/test/towerops_web/controllers/graphql_docs_controller_test.exs index 4d4b3794..125094cb 100644 --- a/test/towerops_web/controllers/graphql_docs_controller_test.exs +++ b/test/towerops_web/controllers/graphql_docs_controller_test.exs @@ -1,11 +1,10 @@ defmodule ToweropsWeb.GraphQLDocsControllerTest do - use ToweropsWeb.ConnCase + use ToweropsWeb.ConnCase, async: true - describe "GET /docs/graphql" do - test "renders the docs page for an unauthenticated visitor", %{conn: conn} do - conn = get(conn, ~p"/docs/graphql") - response = response(conn, 200) - assert response =~ "YOUR_API_TOKEN" + describe "GET /graphql/docs" do + test "renders index without login", %{conn: conn} do + conn = get(conn, "/docs/graphql") + assert html_response(conn, 200) =~ "GraphQL" end end end diff --git a/test/towerops_web/controllers/health_controller_test.exs b/test/towerops_web/controllers/health_controller_test.exs index 0ad1ff40..111895d6 100644 --- a/test/towerops_web/controllers/health_controller_test.exs +++ b/test/towerops_web/controllers/health_controller_test.exs @@ -2,18 +2,12 @@ defmodule ToweropsWeb.HealthControllerTest do use ToweropsWeb.ConnCase, async: true describe "GET /health" do - test "returns ok with database and redis connectivity check", %{conn: conn} do - conn = get(conn, ~p"/health") - - response = json_response(conn, 200) - - assert response["status"] == "ok" - assert response["database"] == "connected" - # Redis may be "connected" or "not_configured" depending on environment - assert response["redis"] in ["connected", "not_configured"] - assert response["version"] == "0.1.0" - - assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + test "returns 200 when database is connected", %{conn: conn} do + conn = get(conn, "/health") + body = response(conn, 200) + result = Jason.decode!(body) + assert result["status"] == "ok" + assert result["database"] == "connected" end end end diff --git a/test/towerops_web/endpoint/body_reader_test.exs b/test/towerops_web/endpoint/body_reader_test.exs new file mode 100644 index 00000000..003941d0 --- /dev/null +++ b/test/towerops_web/endpoint/body_reader_test.exs @@ -0,0 +1,25 @@ +defmodule ToweropsWeb.Endpoint.BodyReaderTest do + use ToweropsWeb.ConnCase, async: true + + alias ToweropsWeb.Endpoint.BodyReader + + describe "read_body/2" do + test "passes through non-protobuf body", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> Plug.Conn.put_private(:raw_body, nil) + + conn = Plug.Conn.assign(conn, :plug_body_reader, {BodyReader, :read_body, []}) + + {:ok, body, _conn} = BodyReader.read_body(conn, []) + assert is_binary(body) + end + + test "skips body parsing for protobuf content type", %{conn: conn} do + conn = put_req_header(conn, "content-type", "application/x-protobuf") + + assert {:ok, "", ^conn} = BodyReader.read_body(conn, []) + end + end +end diff --git a/test/towerops_web/live/mobile_qr_live_test.exs b/test/towerops_web/live/mobile_qr_live_test.exs index 55342f09..7fb7d6cd 100644 --- a/test/towerops_web/live/mobile_qr_live_test.exs +++ b/test/towerops_web/live/mobile_qr_live_test.exs @@ -3,88 +3,35 @@ defmodule ToweropsWeb.MobileQRLiveTest do import Phoenix.LiveViewTest - @moduletag :skip + setup :register_and_log_in_user - describe "authenticated access" do - setup :register_and_log_in_user - - test "mounts and shows QR code page", %{conn: conn} do + describe "mount" do + test "renders QR code display", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/mobile/qr-login") - - assert html =~ "Mobile App Login" - assert html =~ "Scan" - assert html =~ "QR" + assert html =~ "Link Mobile App" end - test "displays QR code image", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/mobile/qr-login") - - assert html =~ "qrcode" - assert html =~ " "wired"}) + + assert render(view) + end + + test "search does not crash", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/weathermap") + + render_click(view, "search", %{"query" => "router"}) + + assert render(view) + end + + test "toggle_layout does not crash", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/weathermap") + + render_click(view, "toggle_layout", %{"mode" => "hierarchical"}) + + assert render(view) + end + + test "close_detail_panel does not crash", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/weathermap") + + render_click(view, "close_detail_panel", %{}) + + assert render(view) + end + end + + describe "handle_info" do + test "refresh_data does not crash", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/weathermap") + + send(view.pid, :refresh_data) + Process.sleep(50) + + assert render(view) + end + end +end diff --git a/test/towerops_web/plugs/detect_eu_user_test.exs b/test/towerops_web/plugs/detect_eu_user_test.exs index d3c6a256..1e5cf1ed 100644 --- a/test/towerops_web/plugs/detect_eu_user_test.exs +++ b/test/towerops_web/plugs/detect_eu_user_test.exs @@ -3,149 +3,26 @@ defmodule ToweropsWeb.Plugs.DetectEUUserTest do alias ToweropsWeb.Plugs.DetectEUUser - describe "init/1" do - test "returns options unchanged" do - assert DetectEUUser.init([]) == [] - assert DetectEUUser.init(some: :option) == [some: :option] - end - end - - describe "call/2 - localhost" do - test "always requires consent for localhost" do + describe "call/2" do + test "requires cookie consent on localhost", %{conn: conn} do conn = - build_conn() + conn + |> init_test_session(%{}) |> Map.put(:host, "localhost") - |> Plug.Test.init_test_session(%{}) |> DetectEUUser.call([]) assert conn.assigns.requires_cookie_consent == true - assert get_session(conn, :requires_cookie_consent) == true - assert Process.get(:requires_cookie_consent) == true end - test "always requires consent for 127.0.0.1" do + test "does not require consent when consent cookie is present", %{conn: conn} do conn = - build_conn() - |> Map.put(:host, "127.0.0.1") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - assert conn.assigns.requires_cookie_consent == true - assert get_session(conn, :requires_cookie_consent) == true - end - end - - describe "call/2 - non-localhost (GeoIP integration)" do - test "requires consent when GeoIP returns nil (conservative approach)" do - # GeoIP.lookup will return nil in test environment (no data) - # This tests the conservative default behavior - conn = - build_conn() - |> Map.put(:host, "example.com") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - # Should default to requiring consent when country cannot be determined - assert conn.assigns.requires_cookie_consent == true - end - end - - describe "call/2 - stores in multiple locations" do - test "stores consent requirement in session, assigns, and process dictionary" do - conn = - build_conn() + conn + |> init_test_session(%{}) |> Map.put(:host, "localhost") - |> Plug.Test.init_test_session(%{}) + |> Plug.Conn.put_req_header("cookie", "cookie_consent=accepted") |> DetectEUUser.call([]) - # Check all three storage locations - assert conn.assigns.requires_cookie_consent == true - assert get_session(conn, :requires_cookie_consent) == true - assert Process.get(:requires_cookie_consent) == true - end - end - - describe "call/2 - X-Forwarded-For header" do - test "processes X-Forwarded-For header when present" do - # Just verify the plug doesn't crash with X-Forwarded-For header - conn = - build_conn() - |> Map.put(:host, "example.com") - |> put_req_header("x-forwarded-for", "203.0.113.195") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - # In test env, GeoIP returns nil, so conservative default is true - assert conn.assigns.requires_cookie_consent == true - end - - test "handles comma-separated X-Forwarded-For" do - # Verify the plug handles multiple IPs without crashing - conn = - build_conn() - |> Map.put(:host, "example.com") - |> put_req_header("x-forwarded-for", "203.0.113.195, 10.0.0.1, 192.168.1.1") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - assert conn.assigns.requires_cookie_consent == true - end - end - - describe "call/2 - cookie consent check" do - test "does not require consent when cookie_consent=accepted cookie exists" do - conn = - build_conn() - |> Map.put(:host, "localhost") - |> put_req_cookie("cookie_consent", "accepted") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - # Should not require consent even though localhost normally would assert conn.assigns.requires_cookie_consent == false - assert get_session(conn, :requires_cookie_consent) == false - assert Process.get(:requires_cookie_consent) == false - end - - test "requires consent when cookie_consent cookie is missing" do - conn = - build_conn() - |> Map.put(:host, "localhost") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - # Should require consent (normal localhost behavior) - assert conn.assigns.requires_cookie_consent == true - assert get_session(conn, :requires_cookie_consent) == true - assert Process.get(:requires_cookie_consent) == true - end - - test "requires consent when cookie_consent cookie has wrong value" do - conn = - build_conn() - |> Map.put(:host, "localhost") - |> put_req_cookie("cookie_consent", "rejected") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - # Should require consent (cookie value is not "accepted") - assert conn.assigns.requires_cookie_consent == true - assert get_session(conn, :requires_cookie_consent) == true - assert Process.get(:requires_cookie_consent) == true - end - - test "does not require consent for non-localhost with accepted cookie" do - conn = - build_conn() - |> Map.put(:host, "example.com") - |> put_req_cookie("cookie_consent", "accepted") - |> Plug.Test.init_test_session(%{}) - |> DetectEUUser.call([]) - - # Should not require consent even though GeoIP would return nil (conservative default) - assert conn.assigns.requires_cookie_consent == false - assert get_session(conn, :requires_cookie_consent) == false - assert Process.get(:requires_cookie_consent) == false end end end diff --git a/test/towerops_web/plugs/update_session_activity_test.exs b/test/towerops_web/plugs/update_session_activity_test.exs index 147dc1a2..89ddfcef 100644 --- a/test/towerops_web/plugs/update_session_activity_test.exs +++ b/test/towerops_web/plugs/update_session_activity_test.exs @@ -1,108 +1,31 @@ defmodule ToweropsWeb.Plugs.UpdateSessionActivityTest do use ToweropsWeb.ConnCase, async: true - import Towerops.AccountsFixtures - - alias Towerops.Accounts alias ToweropsWeb.Plugs.UpdateSessionActivity describe "init/1" do - test "returns options unchanged" do - assert UpdateSessionActivity.init([]) == [] - assert UpdateSessionActivity.init(some: :option) == [some: :option] + test "passes through opts unchanged" do + assert UpdateSessionActivity.init(:some_opts) == :some_opts end end describe "call/2" do - test "returns conn unchanged" do - conn = Plug.Test.init_test_session(build_conn(), %{}) + test "passes through when no user_token in session", %{conn: conn} do + conn = + conn + |> init_test_session(%{}) + |> UpdateSessionActivity.call(%{}) - result = UpdateSessionActivity.call(conn, []) - - assert result == conn + assert conn.state == :unset end - test "spawns background task to update session activity" do - user = user_fixture() - {token, user_token} = Accounts.generate_user_session_token_with_record(user) + test "starts background task when user_token present", %{conn: conn} do + conn = + conn + |> init_test_session(%{user_token: "existing-token"}) + |> UpdateSessionActivity.call(%{}) - now = DateTime.utc_now() - - # Create browser session - {:ok, _session} = - Accounts.create_browser_session(%{ - user_id: user.id, - user_token_id: user_token.id, - device_name: "Test Browser", - ip_address: "127.0.0.1", - user_agent: "Mozilla/5.0 Test", - last_activity_at: now, - expires_at: DateTime.add(now, 30, :day) - }) - - # Call plug with session token - should spawn background task without blocking - result = - build_conn() - |> Plug.Test.init_test_session(%{user_token: token}) - |> UpdateSessionActivity.call([]) - - # Should return immediately (not block on background task) - assert result - end - - test "handles missing session token gracefully" do - build_conn() - |> Plug.Test.init_test_session(%{}) - |> UpdateSessionActivity.call([]) - - # Should not raise an error - test passes if no exception - end - - test "handles invalid session token gracefully" do - build_conn() - |> Plug.Test.init_test_session(%{user_token: "invalid-token-does-not-exist"}) - |> UpdateSessionActivity.call([]) - - # Should not raise an error - test passes if no exception - end - - test "does not block the request while updating" do - user = user_fixture() - {token, user_token} = Accounts.generate_user_session_token_with_record(user) - - now = DateTime.utc_now() - - {:ok, _session} = - Accounts.create_browser_session(%{ - user_id: user.id, - user_token_id: user_token.id, - device_name: "Test Browser", - ip_address: "127.0.0.1", - user_agent: "Mozilla/5.0 Test", - last_activity_at: now, - expires_at: DateTime.add(now, 30, :day) - }) - - # Time the request - should be fast even though update happens in background - start_time = System.monotonic_time(:millisecond) - - build_conn() - |> Plug.Test.init_test_session(%{user_token: token}) - |> UpdateSessionActivity.call([]) - - end_time = System.monotonic_time(:millisecond) - duration = end_time - start_time - - # Should complete very quickly since update is async - assert duration < 200 - end - - test "handles nil token gracefully" do - build_conn() - |> Plug.Test.init_test_session(%{user_token: nil}) - |> UpdateSessionActivity.call([]) - - # Should not raise an error - test passes if no exception + assert conn.state == :unset end end end