towerops/test/towerops_web/live/graph_live/show_events_test.exs
2026-06-15 11:15:46 -05:00

650 lines
19 KiB
Elixir

defmodule ToweropsWeb.GraphLive.ShowEventsTest do
@moduledoc """
Additional coverage for `ToweropsWeb.GraphLive.Show` focusing on:
* check-based graph paths (`check_id`)
* range switching across all valid ranges
* handle_info handlers (live_poll, sensor/neighbor/status events)
* terminate cleanup
* less-common sensor types (voltage, storage volume, count, wireless, unknown)
* access control / mismatched check_device
"""
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
alias Towerops.Monitoring
alias Towerops.Monitoring.Check
alias Towerops.Monitoring.CheckResult
alias Towerops.Organizations
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Snmp
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
alias Towerops.Snmp.Storage
alias Towerops.Snmp.StorageReading
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} = Organizations.create_organization(%{name: "Events Org"}, user.id)
{:ok, site} =
Sites.create_site(%{name: "Events Site", organization_id: organization.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Events Router",
ip_address: "192.168.50.1",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true
})
snmp_device =
%SnmpDevice{}
|> SnmpDevice.changeset(%{
device_id: device.id,
sys_name: "events-router",
sys_descr: "Events Router",
sys_object_id: "1.3.6.1.4.1.9"
})
|> Repo.insert!()
%{organization: organization, site: site, device: device, snmp_device: snmp_device}
end
defp default_config_for("http"), do: %{"url" => "https://example.com"}
defp default_config_for("tcp"), do: %{"host" => "example.com", "port" => 80}
defp default_config_for("dns"), do: %{"hostname" => "example.com"}
defp default_config_for("ssl"), do: %{"host" => "example.com"}
defp default_config_for("ping"), do: %{"host" => "example.com"}
defp default_config_for(_), do: %{}
defp insert_check!(device, attrs) do
check_type = Map.get(attrs, :check_type, "http")
config = Map.get(attrs, :config, default_config_for(check_type))
base = %{
device_id: device.id,
organization_id: device.organization_id,
name: "Test Check",
check_type: check_type,
enabled: true,
interval_seconds: 60,
config: config
}
%Check{}
|> Check.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
defp insert_check_result!(check, attrs) do
base = %{
check_id: check.id,
organization_id: check.organization_id,
checked_at: DateTime.truncate(DateTime.utc_now(), :second),
status: 0,
value: 12.5
}
%CheckResult{}
|> CheckResult.changeset(Map.merge(base, attrs))
|> Repo.insert!()
end
describe "check-based graph (check_id route)" do
test "renders http check graph with results", %{conn: conn, device: device} do
check = insert_check!(device, %{name: "HTTP up", check_type: "http"})
insert_check_result!(check, %{value: 25.4})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
assert html =~ "HTTP up"
assert html =~ device.name
end
test "renders dns check graph (dual axis with status)", %{conn: conn, device: device} do
check = insert_check!(device, %{name: "DNS lookup", check_type: "dns"})
insert_check_result!(check, %{status: 0, value: 8.2})
insert_check_result!(check, %{status: 2, value: nil})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
assert html =~ "DNS lookup"
end
test "renders dns check graph with no results (empty path)", %{conn: conn, device: device} do
check = insert_check!(device, %{name: "Empty DNS", check_type: "dns"})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
assert html =~ "Empty DNS"
end
test "renders snmp_processor check graph (no auto scale)", %{conn: conn, device: device} do
check = insert_check!(device, %{name: "CPU check", check_type: "snmp_processor"})
insert_check_result!(check, %{value: 55.0})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
assert html =~ "CPU check"
end
test "renders snmp_sensor check using sensor_unit from config", %{conn: conn, device: device} do
check =
insert_check!(device, %{
name: "Sensor C",
check_type: "snmp_sensor",
config: %{"sensor_unit" => "Celsius"}
})
insert_check_result!(check, %{value: 30.0})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
assert html =~ "Sensor C"
end
test "redirects when check is not found", %{conn: conn, device: device} do
fake_check_id = Ecto.UUID.generate()
assert {:error, {:live_redirect, %{to: target}}} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{fake_check_id}")
assert target =~ "/devices/#{device.id}"
end
test "redirects when check belongs to different device", %{
conn: conn,
device: device,
organization: organization,
site: site
} do
{:ok, other_device} =
Towerops.Devices.create_device(%{
name: "Other",
ip_address: "10.0.0.2",
site_id: site.id,
organization_id: organization.id,
snmp_enabled: true
})
check = insert_check!(other_device, %{name: "Other Check"})
assert {:error, {:live_redirect, %{to: target}}} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
assert target =~ "/devices/#{device.id}"
end
test "honors range param on check graph", %{conn: conn, device: device} do
check = insert_check!(device, %{name: "HTTP w/ range", check_type: "http"})
insert_check_result!(check, %{value: 10.0})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}&range=7d")
assert html =~ "7 Days"
assert html =~ "HTTP w/ range"
end
test "change_range on check graph patches with check_id preserved", %{
conn: conn,
device: device
} do
check = insert_check!(device, %{name: "Range Patch", check_type: "http"})
{:ok, view, _html} =
live(conn, ~p"/devices/#{device.id}/graph/check?check_id=#{check.id}")
html =
view
|> element("button", "1 Hour")
|> render_click()
assert html =~ "1 Hour"
assert html =~ "Range Patch"
end
end
describe "Range switching" do
test "supports all valid ranges via URL parameter", %{conn: conn, device: device} do
cases = [
{"1h", "1 Hour"},
{"6h", "6 Hours"},
{"12h", "12 Hours"},
{"24h", "24 Hours"},
{"7d", "7 Days"},
{"30d", "30 Days"}
]
for {range, label} <- cases do
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/latency?range=#{range}")
assert html =~ label, "expected range=#{range} to render '#{label}'"
end
end
test "unknown range falls back gracefully (defaults to 24h behavior)", %{
conn: conn,
device: device
} do
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/latency?range=bogus")
# Page renders without crashing
assert html =~ device.name
end
test "change_range click for 7d updates label", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
html =
view
|> element("button", "7 Days")
|> render_click()
assert html =~ "7 Days"
end
test "change_range preserves sensor_id when present", %{
conn: conn,
device: device,
snmp_device: snmp_device
} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "voltage",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.9.9.13.1.2.1.3.1",
sensor_descr: "PSU1 V",
sensor_unit: "V"
})
|> Repo.insert!()
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 12.0,
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
})
{:ok, view, _html} =
live(conn, ~p"/devices/#{device.id}/graph/voltage?sensor_id=#{sensor.id}")
html =
view
|> element("button", "1 Hour")
|> render_click()
assert html =~ "1 Hour"
assert html =~ "PSU1 V"
end
test "change_range preserves storage_id when present", %{
conn: conn,
device: device,
snmp_device: snmp_device
} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 1,
description: "/",
storage_type: "fixed_disk",
total_bytes: 1_000_000,
used_bytes: 500_000
})
|> Repo.insert!()
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 500_000,
total_bytes: 1_000_000,
usage_percent: 50.0,
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.insert!()
{:ok, view, _html} =
live(conn, ~p"/devices/#{device.id}/graph/storage_volume?storage_id=#{storage.id}")
html =
view
|> element("button", "6 Hours")
|> render_click()
assert html =~ "6 Hours"
end
end
describe "Sensor type rendering" do
test "renders voltage chart config", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/voltage")
assert html =~ "Voltage"
end
test "renders count chart config", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/count")
assert html =~ "Count"
end
test "renders pppoe_sessions chart config", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/pppoe_sessions")
assert html =~ "PPPoE Sessions"
end
test "renders connections chart config", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/connections")
assert html =~ "Connections"
end
test "renders wireless chart config", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/wireless")
assert html =~ "Wireless Metrics"
end
test "humanizes unknown sensor_type", %{conn: conn, device: device} do
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}/graph/foo_bar_baz")
assert html =~ "Foo Bar Baz"
end
test "renders storage_volume graph for given storage", %{
conn: conn,
device: device,
snmp_device: snmp_device
} do
storage =
%Storage{}
|> Storage.changeset(%{
snmp_device_id: snmp_device.id,
storage_index: 2,
description: "/data",
storage_type: "fixed_disk",
total_bytes: 10_000,
used_bytes: 2_000
})
|> Repo.insert!()
now = DateTime.truncate(DateTime.utc_now(), :second)
%StorageReading{}
|> StorageReading.changeset(%{
storage_id: storage.id,
used_bytes: 2_000,
total_bytes: 10_000,
usage_percent: 20.0,
checked_at: now
})
|> Repo.insert!()
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/storage_volume?storage_id=#{storage.id}")
assert html =~ "Storage Usage"
assert html =~ "/data"
end
end
describe "handle_info / live polling" do
test "device_status_changed reloads device assign", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
send(view.pid, {:device_status_changed, device.id, :down, 9999})
# Process should still be alive
assert Process.alive?(view.pid)
assert render(view) =~ device.name
end
test "state_sensors_updated triggers reload without crashing", %{
conn: conn,
device: device,
snmp_device: snmp_device
} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "cpu_load",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.9.9.109.1.1.1.1.8.1",
sensor_descr: "CPU 0",
sensor_unit: "%"
})
|> Repo.insert!()
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 30.0,
checked_at: DateTime.truncate(DateTime.utc_now(), :second)
})
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/processors")
send(view.pid, {:state_sensors_updated, device.id})
assert render(view) =~ "Processor Usage"
end
test "neighbors_updated is a no-op", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
send(view.pid, {:neighbors_updated, device.id})
assert render(view) =~ device.name
end
test "live_poll while not in live mode is ignored", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
send(view.pid, :live_poll)
assert render(view) =~ device.name
end
test "unrecognized info message is ignored (catch-all)", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
send(view.pid, :something_unexpected)
assert render(view) =~ device.name
end
test "terminate cancels any live polling timer", %{conn: conn, device: device} do
Process.flag(:trap_exit, true)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/graph/latency")
pid = view.pid
assert Process.alive?(pid)
# cancel_live_polling_timer/1 must safely handle the case where no timer
# was set (the latency graph isn't in live mode). terminate/2 calls it.
ref = Process.monitor(pid)
GenServer.stop(pid, :shutdown, 1_000)
assert_receive {:DOWN, ^ref, :process, _, _}, 1_000
end
end
describe "access control extras" do
test "redirects when device belongs to a different organization", %{
conn: conn,
user: user
} do
# Create a separate org/site/device the current user does not belong to
other_user = Towerops.AccountsFixtures.user_fixture()
{:ok, other_org} =
Organizations.create_organization(%{name: "Other Org"}, other_user.id)
{:ok, other_site} =
Sites.create_site(%{name: "Other Site", organization_id: other_org.id})
{:ok, foreign_device} =
Towerops.Devices.create_device(%{
name: "Forbidden",
ip_address: "10.20.30.40",
site_id: other_site.id,
organization_id: other_org.id,
snmp_enabled: true
})
_ = user
assert {:error, {:live_redirect, %{to: "/devices"}}} =
live(conn, ~p"/devices/#{foreign_device.id}/graph/latency")
end
test "redirects when sensor-route device id is not found", %{conn: conn} do
fake_id = Ecto.UUID.generate()
assert {:error, {:live_redirect, %{to: "/devices"}}} =
live(conn, ~p"/devices/#{fake_id}/graph/processors")
end
test "renders gracefully when sensor_id query param does not exist", %{
conn: conn,
device: device
} do
fake_sensor_id = Ecto.UUID.generate()
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/temperature?sensor_id=#{fake_sensor_id}")
assert is_binary(html) and byte_size(html) > 0
end
test "renders gracefully when storage_id query param does not exist", %{
conn: conn,
device: device
} do
fake_storage_id = Ecto.UUID.generate()
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/storage_volume?storage_id=#{fake_storage_id}")
assert is_binary(html) and byte_size(html) > 0
end
test "renders interface_errors with non-existent interface id", %{
conn: conn,
device: device
} do
fake_interface_id = Ecto.UUID.generate()
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/interface_errors?interface_id=#{fake_interface_id}")
assert is_binary(html) and byte_size(html) > 0
end
test "renders traffic with non-existent interface id", %{
conn: conn,
device: device
} do
fake_interface_id = Ecto.UUID.generate()
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/traffic?interface_id=#{fake_interface_id}")
assert is_binary(html) and byte_size(html) > 0
end
end
describe "interface_errors graph data shapes" do
setup %{snmp_device: snmp_device} do
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 2,
if_descr: "eth1",
if_name: "eth1",
if_oper_status: "up",
if_speed: 1_000_000_000
})
|> Repo.insert!()
%{interface: interface}
end
test "computes error rate-of-change between two stats", %{
conn: conn,
device: device,
interface: interface
} do
base = DateTime.truncate(DateTime.utc_now(), :second)
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
checked_at: DateTime.add(base, -60, :second)
})
Snmp.create_interface_stat(%{
interface_id: interface.id,
if_in_errors: 12,
if_out_errors: 6,
if_in_discards: 3,
if_out_discards: 0,
checked_at: base
})
{:ok, _view, html} =
live(conn, ~p"/devices/#{device.id}/graph/interface_errors?interface_id=#{interface.id}")
assert html =~ "Interface Errors &amp; Discards"
end
end
describe "monitoring helper coverage" do
test "get_check_graph_data returns rows for snmp_sensor with source backfill path", %{
device: device,
snmp_device: snmp_device
} do
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: snmp_device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.3.6.1.4.1.1",
sensor_descr: "Temp1",
sensor_unit: "C"
})
|> Repo.insert!()
check =
insert_check!(device, %{
name: "SNMP backfill",
check_type: "snmp_sensor",
source_id: sensor.id,
config: %{"sensor_unit" => "Celsius"}
})
Snmp.create_sensor_reading(%{
sensor_id: sensor.id,
value: 22.5,
checked_at: DateTime.add(DateTime.utc_now(), -1, :hour)
})
from = DateTime.add(DateTime.utc_now(), -2, :hour)
to = DateTime.utc_now()
points = Monitoring.get_check_graph_data(check.id, from, to)
assert is_list(points)
end
end
end