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") # 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") {: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