defmodule ToweropsWeb.DeviceLive.FormTest do use ToweropsWeb.ConnCase, async: true import Phoenix.LiveViewTest alias Towerops.Snmp.Device setup :register_and_log_in_user setup %{user: user} do {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) {:ok, site} = Towerops.Sites.create_site(%{ name: "Test Site", organization_id: organization.id }) %{organization: organization, site: site} end describe "edit page rendering" do test "renders edit page when device has no SNMP discovery data (bug fix for nil boolean)", %{ conn: conn, site: site, organization: organization } do # Regression test for production bug where visiting edit page for a device # with no SNMP discovery data would crash with "expected boolean, got nil" # because mikrotik_device?/1 could return nil instead of false. # Create a device without any snmp_device association (before SNMP discovery) {:ok, device} = Towerops.Devices.create_device(%{ name: "Test Device", ip_address: "192.168.1.1", site_id: site.id, organization_id: organization.id, snmp_enabled: true, snmp_version: "2c", snmp_port: 161, monitoring_enabled: true, check_interval_seconds: 300 }) # Ensure device has no snmp_device (this is the bug condition) device_with_snmp = Towerops.Repo.preload(device, :snmp_device) assert is_nil(device_with_snmp.snmp_device) # Visit edit page - should not crash with "expected boolean, got nil" error assert {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") # Page should render successfully assert html =~ "Edit Device" assert html =~ device.name end test "MikroTik section is hidden even for MikroTik devices (currently disabled)", %{ conn: conn, site: site, organization: organization } do # Create a device with MikroTik SNMP discovery data {:ok, device} = Towerops.Devices.create_device(%{ name: "MikroTik Router", ip_address: "192.168.1.254", site_id: site.id, organization_id: organization.id, snmp_enabled: true, snmp_version: "2c", snmp_port: 161, monitoring_enabled: true, check_interval_seconds: 300 }) # Create SNMP device with MikroTik manufacturer (using Towerops.Snmp.Device schema) _snmp_device = %Device{} |> Device.changeset(%{ device_id: device.id, manufacturer: "MikroTik", sys_descr: "RouterOS 7.8", sys_object_id: "1.3.6.1.4.1.14988.1", sys_uptime: 0, sys_name: "test-router" }) |> Towerops.Repo.insert!() # Visit edit page {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") # Page should render successfully assert html =~ "Edit Device" assert html =~ device.name # MikroTik section is currently disabled in the template refute html =~ "MikroTik API Configuration" end test "does not render MikroTik section when device is not MikroTik", %{ conn: conn, site: site, organization: organization } do alias Device, as: SnmpDevice # Create a device with non-MikroTik SNMP discovery data {:ok, device} = Towerops.Devices.create_device(%{ name: "Cisco Switch", ip_address: "192.168.1.253", site_id: site.id, organization_id: organization.id, snmp_enabled: true, snmp_version: "2c", snmp_port: 161, monitoring_enabled: true, check_interval_seconds: 300 }) # Create SNMP device with Cisco manufacturer _snmp_device = %SnmpDevice{} |> SnmpDevice.changeset(%{ device_id: device.id, manufacturer: "Cisco Systems", sys_descr: "Cisco IOS", sys_object_id: "1.3.6.1.4.1.9", sys_uptime: 0, sys_name: "test-switch" }) |> Towerops.Repo.insert!() # Visit edit page {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") # Page should render successfully assert html =~ "Edit Device" assert html =~ device.name # MikroTik section SHOULD NOT be visible for non-MikroTik devices refute html =~ "MikroTik API Configuration" end end describe "new device form" do test "renders new device page", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/devices/new") assert html =~ "New Device" end test "pre-fills from query params", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/devices/new?name=Prefilled&ip_address=10.0.0.1") assert html =~ "Prefilled" assert html =~ "10.0.0.1" end test "validates device form on change", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/devices/new") html = view |> form("#device-form", device: %{name: "", ip_address: ""}) |> render_change() assert html =~ "New Device" end test "submits new device form", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/devices/new") result = view |> form("#device-form", device: %{ name: "New Test Device", ip_address: "10.0.0.100" } ) |> render_submit() case result do {:error, {:live_redirect, %{to: to}}} -> assert to =~ "tab=overview" html when is_binary(html) -> # Form re-rendered (possibly validation), page still alive assert html =~ "New Device" or html =~ "New Test Device" end end test "switches monitoring mode", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/devices/new") html = view |> element("#monitoring-mode-icmp-only") |> render_click() assert html =~ "New Device" end end describe "edit device form" do setup %{site: site, organization: organization} do {:ok, device} = Towerops.Devices.create_device(%{ name: "Edit Me", ip_address: "192.168.1.50", site_id: site.id, organization_id: organization.id, snmp_enabled: true }) %{device: device} end test "renders edit page with current values", %{conn: conn, device: device} do {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") assert html =~ "Edit Device" assert html =~ "Edit Me" end test "validates edit form on change", %{conn: conn, device: device} do {:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit") html = view |> form("#device-form", device: %{name: "Updated Name"}) |> render_change() # The validation should not produce errors refute html =~ "can't be blank" refute html =~ "can't be blank" # The form should accept the valid input and redirect to show page assert {:error, {:live_redirect, %{to: to}}} = view |> form("#device-form", device: %{name: "Updated Name"}) |> render_submit() assert to =~ ~r{/devices/[0-9a-f-]+} end test "saves edited device", %{conn: conn, device: device} do {:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit") assert {:error, {:live_redirect, %{to: to}}} = view |> form("#device-form", device: %{name: "Updated Router"}) |> render_submit() assert to =~ device.id end test "deletes device", %{conn: conn, device: device} do {:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit") result = view |> element("#delete-device") |> render_click() case result do {:error, {:live_redirect, %{to: "/devices" <> _}}} -> assert true html when is_binary(html) -> # If it redirects via push_navigate, the view dies refute Process.alive?(view.pid) end end test "trigger_discovery with SNMP disabled shows error", %{conn: conn, site: site, organization: organization} do {:ok, device} = Towerops.Devices.create_device(%{ name: "No SNMP", ip_address: "192.168.1.99", site_id: site.id, organization_id: organization.id, snmp_enabled: false }) {:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit") # The "Rediscover" button is hidden when monitoring_mode == "icmp_only" (i.e. # when SNMP is disabled), so it cannot be clicked through the UI in this state. # This test exercises the handler's defensive guard against being invoked anyway. html = render_click(view, "trigger_discovery") assert html =~ "SNMP is not enabled" end end describe "device role selector" do setup %{site: site, organization: organization} do {:ok, device} = Towerops.Devices.create_device(%{ name: "Role Test Device", ip_address: "192.168.1.60", site_id: site.id, organization_id: organization.id, snmp_enabled: true }) %{device: device} end test "new device form shows device type dropdown", %{conn: conn} do {:ok, _view, html} = live(conn, ~p"/devices/new") assert html =~ "Device Type" assert html =~ "Select device type" assert html =~ "Router" assert html =~ "Switch" assert html =~ "Server" assert html =~ "Access Point" assert html =~ "Backhaul" assert html =~ "Other" end test "edit device form shows device type dropdown", %{conn: conn, device: device} do {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") assert html =~ "Device Type" assert html =~ "Select device type" end test "selecting a role saves the device role", %{conn: conn, device: device} do {:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit") assert {:error, {:live_redirect, %{to: to}}} = view |> form("#device-form", device: %{device_role: "router"}) |> render_submit() assert to =~ device.id updated_device = Towerops.Devices.get_device!(device.id) assert updated_device.device_role == "router" end test "edit form shows current role as selected", %{ conn: conn, site: site, organization: organization } do {:ok, device} = Towerops.Devices.create_device(%{ name: "Router Device", ip_address: "192.168.1.62", site_id: site.id, organization_id: organization.id, snmp_enabled: true, device_role: "router" }) {:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/edit") # The select should show the current role selected assert html =~ "device_role" assert html =~ "router" end end describe "non_routable_ip?/1" do # Access private function via Module.get_attribute or test via public interface # Since these are private functions, we test through the validation behavior test "RFC1918 10.0.0.0/8 range" do assert non_routable?("10.0.0.1") assert non_routable?("10.255.255.255") assert non_routable?("10.100.50.25") end test "RFC1918 172.16.0.0/12 range" do assert non_routable?("172.16.0.1") assert non_routable?("172.31.255.255") assert non_routable?("172.20.10.5") refute non_routable?("172.15.0.1") refute non_routable?("172.32.0.1") end test "RFC1918 192.168.0.0/16 range" do assert non_routable?("192.168.0.1") assert non_routable?("192.168.255.255") assert non_routable?("192.168.1.100") refute non_routable?("192.167.1.1") refute non_routable?("192.169.1.1") end test "RFC6598 CGNAT 100.64.0.0/10 range" do assert non_routable?("100.64.0.1") assert non_routable?("100.127.255.255") assert non_routable?("100.100.50.25") refute non_routable?("100.63.255.255") refute non_routable?("100.128.0.1") end test "public IPs are not flagged" do refute non_routable?("8.8.8.8") refute non_routable?("1.1.1.1") refute non_routable?("203.0.113.1") refute non_routable?("198.51.100.1") end test "invalid IPs are not flagged" do refute non_routable?("invalid") refute non_routable?("999.999.999.999") refute non_routable?("") end test "IPv6 addresses are not flagged" do refute non_routable?("::1") refute non_routable?("fe80::1") refute non_routable?("2001:db8::1") end end # Helper to test the private function logic # Duplicated here for testing since the actual function is private defp non_routable?(ip_string) do case ip_string |> String.to_charlist() |> :inet.parse_address() do {:ok, {a, b, _c, _d}} -> non_routable_ipv4_range?(a, b) _ -> false end end defp non_routable_ipv4_range?(10, _b), do: true defp non_routable_ipv4_range?(172, b) when b >= 16 and b <= 31, do: true defp non_routable_ipv4_range?(192, 168), do: true defp non_routable_ipv4_range?(100, b) when b >= 64 and b <= 127, do: true defp non_routable_ipv4_range?(_a, _b), do: false end