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.
660 lines
23 KiB
Elixir
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'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
|