towerops/test/towerops_web/live/agent_live_test.exs
2026-02-03 10:43:30 -06:00

496 lines
17 KiB
Elixir

defmodule ToweropsWeb.AgentLiveTest do
use ToweropsWeb.ConnCase
import Ecto.Query
import ExUnit.CaptureLog
import Phoenix.LiveViewTest
alias Towerops.Agents
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"
# Try to trigger save_global_default event directly - should be blocked by permission check
# This simulates an attacker trying to call the event handler
capture_log(fn ->
result = render_click(view, "save_global_default", %{})
# Should get an error or be rejected
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")
# Explicitly scope session to the first organization (not other_org)
# to ensure the test is deterministic regardless of which org is picked by default
conn = Plug.Test.init_test_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
end