towerops/test/towerops_web/live/agent_live_test.exs
Graham McIntire b735adc067 feat(insights): RF-aware channel-conflict — multi-source noise + Preseem
Same-channel conflicts at one site are not all equal. A pair of APs
sharing a channel in a clean RF environment is a planning issue;
the same pair in a contaminated environment is an outage waiting to
happen. This commit makes the rule reason across sources before
deciding urgency.

- Towerops.Snmp.RfHealth — cross-source helper that combines:
  * Most recent noise-floor / noise sensor on the AP (dBm)
  * Preseem rf_score and qoe_score from the linked AccessPoint row
  * Average CPE SNR and client count from wireless_clients
  Returns a snapshot map and an interference_severity/1 classifier
  (:critical | :warning | :ok). Critical when noise floor >= -80 dBm
  OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with
  active clients.
- OwnFleetChannelConflict now enriches each conflicting AP with its
  noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and a
  per-AP rf_severity. Urgency promotes to critical when 3+ APs share
  a channel OR any conflicting AP shows critical RF health.
- UI: amber card now renders an evidence table with width, noise,
  rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF
  environment detected" badge when has_critical_rf is true.

Also strengthens the previous flake fix on agent_live_test.exs:567
by switching to Req.Test shared mode (the test is sync), so the
ReleaseChecker stub is visible to the LiveView process regardless of
when it spawns relative to setup. The earlier Req.Test.allow approach
relied on stub timing that didn't always hold under full-suite load.

11 new tests across RfHealth + the enriched rule. LLM enrichment
automatically picks up the richer metadata via the existing Phase 1
worker.
2026-05-09 17:25:26 -05:00

660 lines
23 KiB
Elixir

defmodule ToweropsWeb.AgentLiveTest do
use ToweropsWeb.ConnCase
import Ecto.Query
import ExUnit.CaptureLog
import Phoenix.LiveViewTest
alias Towerops.Agents
alias Towerops.Agents.ReleaseChecker
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization}
end
describe "Index - Basic Functionality" do
test "lists all agent tokens", %{conn: conn, organization: organization} do
{:ok, _agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "Remote Agents"
assert html =~ "Agent 1"
end
test "displays empty state when no agents", %{conn: conn, organization: _organization} do
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "No agents"
assert html =~ "Get started by creating your first remote agent"
end
test "creates new agent and shows token modal", %{conn: conn, organization: _organization} do
{:ok, view, _html} = live(conn, ~p"/agents")
html =
view
|> form("#new-agent-form form", %{name: "Test Agent"})
|> render_submit()
assert html =~ "Agent Setup Instructions"
assert html =~ "Test Agent"
assert html =~ "Authentication Token"
end
test "deletes agent", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
{:ok, view, _html} = live(conn, ~p"/agents")
html =
view
|> element("button", "Delete")
|> render_click()
assert html =~ "Agent deleted successfully"
# Verify agent was actually deleted
assert is_nil(Towerops.Repo.get(Agents.AgentToken, agent_token.id))
end
test "closes token modal", %{conn: conn, organization: _organization} do
{:ok, view, _html} = live(conn, ~p"/agents")
# Create agent to show modal
view
|> form("#new-agent-form form", %{name: "Test Agent"})
|> render_submit()
# Close modal by clicking the primary button (bg-blue-600 class indicates primary button)
html =
view
|> element("button.bg-blue-600[phx-click='close_token_modal']")
|> render_click()
refute html =~ "Agent Created Successfully"
end
test "requires authentication", %{organization: _organization} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/agents")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
test "displays agent with warning status", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
# Update last_seen_at to 3 minutes ago (warning status)
three_minutes_ago = DateTime.add(DateTime.utc_now(), -180, :second)
Agents.update_agent_token_heartbeat(agent_token.id, "127.0.0.1")
Towerops.Repo.update_all(
from(t in Agents.AgentToken, where: t.id == ^agent_token.id),
set: [last_seen_at: three_minutes_ago]
)
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "Warning"
assert html =~ "3m ago"
end
test "displays agent with offline status", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
# Update last_seen_at to 10 minutes ago (offline status)
ten_minutes_ago = DateTime.add(DateTime.utc_now(), -600, :second)
Agents.update_agent_token_heartbeat(agent_token.id, "127.0.0.1")
Towerops.Repo.update_all(
from(t in Agents.AgentToken, where: t.id == ^agent_token.id),
set: [last_seen_at: ten_minutes_ago]
)
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "Offline"
assert html =~ "10m ago"
end
test "displays agent last seen time in hours", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
# Update last_seen_at to 2 hours ago
two_hours_ago = DateTime.add(DateTime.utc_now(), -7200, :second)
Agents.update_agent_token_heartbeat(agent_token.id, "127.0.0.1")
Towerops.Repo.update_all(
from(t in Agents.AgentToken, where: t.id == ^agent_token.id),
set: [last_seen_at: two_hours_ago]
)
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "2h ago"
end
test "displays agent last seen time in days", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
# Update last_seen_at to 2 days ago
two_days_ago = DateTime.add(DateTime.utc_now(), -172_800, :second)
Agents.update_agent_token_heartbeat(agent_token.id, "127.0.0.1")
Towerops.Repo.update_all(
from(t in Agents.AgentToken, where: t.id == ^agent_token.id),
set: [last_seen_at: two_days_ago]
)
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "2d ago"
end
end
describe "Index - Token Management" do
test "shows setup instructions for existing agent", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, view, _html} = live(conn, ~p"/agents")
html =
view
|> element("button[phx-click='show_setup'][phx-value-id='#{agent_token.id}']")
|> render_click()
assert html =~ "Agent Setup Instructions"
assert html =~ "Test Agent"
assert html =~ "Authentication Token"
end
end
describe "Index - Cloud Pollers (Superuser)" do
setup %{user: user} do
# Make user a superuser
Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
:ok
end
test "superuser can create cloud poller", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/agents")
html =
view
|> form("#new-agent-form form", %{name: "Cloud Poller 1", is_cloud_poller: "true"})
|> render_submit()
assert html =~ "Cloud poller created successfully"
assert html =~ "Agent Setup Instructions"
assert html =~ "Cloud Poller 1"
end
test "superuser can see cloud pollers section", %{conn: conn} do
{:ok, _cloud_poller, _token} = Agents.create_cloud_poller("Cloud Poller 1")
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "Cloud Pollers"
assert html =~ "Cloud Poller 1"
end
test "superuser can set global default cloud poller", %{conn: conn} do
{:ok, cloud_poller, _token} = Agents.create_cloud_poller("Default Poller")
{:ok, view, _html} = live(conn, ~p"/agents")
# Update the selected dropdown value using the event handler
render_hook(view, "update_selected_global_default", %{"agent_token_id" => cloud_poller.id})
# Save the selection
html =
view
|> element("button[phx-click='save_global_default']")
|> render_click()
assert html =~ "Global default cloud poller set successfully"
end
test "superuser can clear global default cloud poller", %{conn: conn} do
{:ok, cloud_poller, _token} = Agents.create_cloud_poller("Default Poller")
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
{:ok, view, _html} = live(conn, ~p"/agents")
# Select empty value using the event handler
render_hook(view, "update_selected_global_default", %{"agent_token_id" => ""})
# Save the selection
html =
view
|> element("button[phx-click='save_global_default']")
|> render_click()
assert html =~ "Global default cloud poller cleared"
end
test "superuser sees error when setting non-existent agent as default", %{conn: conn} do
non_existent_id = Ecto.UUID.generate()
{:ok, view, _html} = live(conn, ~p"/agents")
# Update dropdown to non-existent ID
render_hook(view, "update_selected_global_default", %{"agent_token_id" => non_existent_id})
# Try to save - should fail validation
html =
view
|> element("button[phx-click='save_global_default']")
|> render_click()
assert html =~ "Selected agent no longer exists"
end
test "deleting global default cloud poller clears the setting", %{conn: conn} do
{:ok, cloud_poller, _token} = Agents.create_cloud_poller("Default Poller")
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
{:ok, view, _html} = live(conn, ~p"/agents")
# Delete the cloud poller
html =
view
|> element("button[phx-click='delete_agent'][phx-value-id='#{cloud_poller.id}']")
|> render_click()
assert html =~ "Agent deleted successfully"
# Verify global default was cleared
assert Towerops.Settings.get_global_default_cloud_poller() == nil
end
end
describe "Index - Cloud Pollers (Regular User)" do
test "regular user cannot create cloud poller", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/agents")
# Regular users don't have the is_cloud_poller checkbox in the form,
# so we need to test the backend permission check directly
# by triggering the event with is_cloud_poller parameter
html = render_hook(view, "create_agent", %{"name" => "Test", "is_cloud_poller" => "true"})
assert html =~ "Only superadmins can create cloud pollers"
end
test "regular user does not see cloud pollers section", %{conn: conn} do
{:ok, _cloud_poller, _token} = Agents.create_cloud_poller("Cloud Poller 1")
{:ok, _view, html} = live(conn, ~p"/agents")
refute html =~ "Cloud Pollers"
refute html =~ "Global Default Cloud Poller"
end
test "regular user cannot set global default cloud poller", %{conn: conn} do
{:ok, cloud_poller, _token} = Agents.create_cloud_poller("Default Poller")
# First make the user a superuser temporarily to set the global default
# Then we'll test that a regular user can't access the save_global_default event
{:ok, _} = Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id)
{:ok, view, html} = live(conn, ~p"/agents")
# Regular user should not see the cloud poller UI at all
refute html =~ "Global Default Cloud Poller"
# Tests a server-side guard: the Global Default UI isn't rendered for non-superadmins
# (verified by the refute above). To exercise the handler's permission check against
# a tampered POST, drive the event directly — bypassing form/element wiring is the
# whole point of this test.
capture_log(fn ->
result = render_click(view, "save_global_default", %{})
assert result =~ "Only superadmins can set the global default cloud poller" or
result =~ "Remote Agents"
end)
end
end
describe "Index - Device Counts" do
test "displays device counts for agents", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
# Create a site and device assigned to this agent
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
address: "123 Test St",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.100",
site_id: site.id,
organization_id: organization.id
})
Agents.assign_device_to_agent(agent_token.id, device.id)
{:ok, _view, html} = live(conn, ~p"/agents")
# Should show device count (displays as "1 total" in template)
assert html =~ "1 total"
assert html =~ "1 direct"
end
test "displays zero when no devices assigned", %{conn: conn, organization: organization} do
{:ok, _agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, _view, html} = live(conn, ~p"/agents")
# Displays as "0 total" in template
assert html =~ "0 total"
assert html =~ "0 direct"
end
end
describe "Index - Error Handling" do
test "handles agent creation failure", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/agents")
# Try to create agent with empty name
html =
view
|> form("#new-agent-form form", %{name: ""})
|> render_submit()
assert html =~ "Failed to create agent"
end
end
describe "Index - Multiple Agents" do
test "displays multiple agents", %{conn: conn, organization: organization} do
{:ok, _agent1, _token1} = Agents.create_agent_token(organization.id, "Agent 1")
{:ok, _agent2, _token2} = Agents.create_agent_token(organization.id, "Agent 2")
{:ok, _agent3, _token3} = Agents.create_agent_token(organization.id, "Agent 3")
{:ok, _view, html} = live(conn, ~p"/agents")
assert html =~ "Agent 1"
assert html =~ "Agent 2"
assert html =~ "Agent 3"
end
test "deleting one agent keeps others", %{conn: conn, organization: organization} do
{:ok, agent1, _token1} = Agents.create_agent_token(organization.id, "Agent 1")
{:ok, _agent2, _token2} = Agents.create_agent_token(organization.id, "Agent 2")
{:ok, view, _html} = live(conn, ~p"/agents")
html =
view
|> element("button[phx-click='delete_agent'][phx-value-id='#{agent1.id}']")
|> render_click()
assert html =~ "Agent deleted successfully"
assert html =~ "Agent 2"
refute html =~ "Agent 1"
end
end
describe "Edit" do
test "loads edit page with existing agent name", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Old Agent Name")
{:ok, _view, html} = live(conn, ~p"/agents/#{agent_token.id}/edit")
assert html =~ "Edit Agent"
assert html =~ "Old Agent Name"
assert html =~ "Update the agent name"
end
test "validates empty name shows error on submit", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}/edit")
html =
view
|> form("#edit-agent-form", agent_token: %{name: ""})
|> render_submit()
assert html =~ "can&#39;t be blank"
# Verify agent was not updated
agent = Agents.get_agent_token!(agent_token.id)
assert agent.name == "Test Agent"
end
test "successful save updates agent and redirects to show page", %{
conn: conn,
organization: organization
} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Old Name")
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}/edit")
view
|> form("#edit-agent-form", agent_token: %{name: "New Name"})
|> render_submit()
# Verify redirect to show page with flash message
assert_redirect(view, ~p"/agents/#{agent_token.id}")
# Verify agent was updated
updated_agent = Agents.get_agent_token!(agent_token.id)
assert updated_agent.name == "New Name"
end
test "cancel button navigates back to show page", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, _view, html} = live(conn, ~p"/agents/#{agent_token.id}/edit")
assert html =~ "Cancel"
# Find cancel link and verify it points to show page
assert html =~
~p"/agents/#{agent_token.id}"
end
test "requires authentication", %{organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
conn = build_conn()
{:error, redirect} = live(conn, ~p"/agents/#{agent_token.id}/edit")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
test "verifies agent belongs to organization", %{conn: conn, organization: organization, user: user} do
# Create another organization
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id, bypass_limits: true)
{:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Agent")
# Scope session to the first organization while preserving auth
conn = Plug.Conn.put_session(conn, "current_organization_id", organization.id)
# Try to edit agent from other organization - capture_log suppresses expected Router exception log
capture_log(fn ->
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/agents/#{agent_token.id}/edit")
end
end)
end
end
describe "Show - Superadmin Cross-Org Access" do
setup %{user: user} do
Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
:ok
end
test "superadmin can view agent from another organization", %{conn: conn, organization: organization, user: user} do
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id, bypass_limits: true)
{:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Org Agent")
conn = Plug.Conn.put_session(conn, "current_organization_id", organization.id)
{:ok, _view, html} = live(conn, ~p"/agents/#{agent_token.id}")
assert html =~ "Other Org Agent"
end
test "superadmin can edit agent from another organization", %{conn: conn, organization: organization, user: user} do
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id, bypass_limits: true)
{:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Org Agent")
conn = Plug.Conn.put_session(conn, "current_organization_id", organization.id)
{:ok, _view, html} = live(conn, ~p"/agents/#{agent_token.id}/edit")
assert html =~ "Edit Agent"
assert html =~ "Other Org Agent"
end
end
describe "Show - Restart Agent" do
test "superuser sees restart button and can restart agent", %{conn: conn, organization: organization, user: user} do
Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, view, html} = live(conn, ~p"/agents/#{agent_token.id}")
assert html =~ "Restart"
# Subscribe to lifecycle topic to verify broadcast
Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle")
view
|> element("button", "Restart")
|> render_click()
assert_receive :restart_requested
assert render(view) =~ "Restart command sent"
end
test "regular user does not see restart button", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, _view, html} = live(conn, ~p"/agents/#{agent_token.id}")
refute html =~ "Restart"
end
end
describe "Show - Update Agent" do
test "superuser sees update button", %{conn: conn, organization: organization, user: user} do
Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
assert has_element?(view, "button", "Update")
end
test "superuser clicking update shows error when no releases available", %{
conn: conn,
organization: organization,
user: user
} do
Towerops.Repo.update!(Ecto.Changeset.change(user, %{is_superuser: true}))
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
# Force a transport error from the release-checker HTTP client so the
# test is deterministic. Without an explicit stub, behavior depends on
# whether other tests have left a Req.Test stub registered for this
# module — which makes this test flaky under the full suite.
#
# Use shared mode so the stub is visible to the LiveView process
# regardless of when it spawns relative to this test's setup, and
# regardless of whether the handler hops through a Task. This test
# is sync (use ToweropsWeb.ConnCase without async: true), so shared
# mode is safe.
Req.Test.set_req_test_to_shared(%{async: false})
Req.Test.stub(ReleaseChecker, fn conn ->
Req.Test.transport_error(conn, :econnrefused)
end)
on_exit(fn -> Req.Test.set_req_test_to_private(%{}) end)
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
html =
view
|> element("button", "Update")
|> render_click()
assert html =~ "Failed to fetch latest release"
end
test "regular user does not see update button", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Test Agent")
{:ok, view, _html} = live(conn, ~p"/agents/#{agent_token.id}")
refute has_element?(view, "button", "Update")
end
end
describe "Show - Regular User Cross-Org Access" do
test "regular user cannot view agent from another organization", %{conn: conn, organization: organization, user: user} do
{:ok, other_org} = Towerops.Organizations.create_organization(%{name: "Other Org"}, user.id, bypass_limits: true)
{:ok, agent_token, _token} = Agents.create_agent_token(other_org.id, "Other Org Agent")
conn = Plug.Conn.put_session(conn, "current_organization_id", organization.id)
capture_log(fn ->
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/agents/#{agent_token.id}")
end
end)
end
end
describe "Index - Timer Cleanup (Reliability Fix)" do
test "cleans up timer on LiveView terminate", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/agents")
# Verify LiveView mounted and is running
assert render(view) =~ "Remote Agents"
# Stop the LiveView process to trigger terminate/2
stop_live(view)
# The test passing means terminate/2 didn't crash
# In production, this prevents memory leaks from orphaned timers
end
test "handles nil timer ref in terminate gracefully", %{conn: conn} do
# Test the case where timer_ref might be nil
{:ok, view, _html} = live(conn, ~p"/agents")
# Even if timer_ref is nil, terminate should handle it gracefully
stop_live(view)
# No crash = success
end
defp stop_live(view) do
# Stop the LiveView process
Process.exit(view.pid, :kill)
# Wait for process to die
ref = Process.monitor(view.pid)
assert_receive {:DOWN, ^ref, :process, _, _}, 1000
end
end
end