defmodule ToweropsWeb.AgentLive.IndexTest do use ToweropsWeb.ConnCase, async: true import Phoenix.LiveViewTest import Towerops.AccountsFixtures alias Towerops.Agents setup do user = user_fixture(enable_totp: true) {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) %{user: user, organization: organization} end describe "mount" do test "loads agent tokens for organization", %{conn: conn, user: user, organization: organization} do # Create some agent tokens {:ok, agent_token1, _token1} = Agents.create_agent_token(organization.id, "Agent 1") {:ok, agent_token2, _token2} = Agents.create_agent_token(organization.id, "Agent 2") conn = conn |> log_in_user(user) |> get("/agents") {:ok, _view, html} = live(conn) assert html =~ "Remote Agents" assert html =~ agent_token1.name assert html =~ agent_token2.name end test "shows empty state when no agents exist", %{conn: conn, user: user, organization: _organization} do conn = conn |> log_in_user(user) |> get("/agents") {:ok, _view, html} = live(conn) assert html =~ "Remote Agents" end test "requires authentication", %{conn: conn} do conn = get(conn, "/agents") assert redirected_to(conn) == ~p"/users/log-in" end end describe "create_agent event" do test "creates new agent token and shows modal", %{conn: conn, user: user, organization: organization} do conn = conn |> log_in_user(user) |> get("/agents") {:ok, view, _html} = live(conn) # Trigger create_agent event with nested params html = render_submit(view, "create_agent", %{"agent_form" => %{"name" => "New Agent"}}) assert html =~ "Agent created successfully" assert html =~ "New Agent" # Verify agent was actually created agent_tokens = Agents.list_organization_agent_tokens(organization.id) assert length(agent_tokens) == 1 assert hd(agent_tokens).name == "New Agent" end test "creates agent with flat params format", %{conn: conn, user: user, organization: _organization} do conn = conn |> log_in_user(user) |> get("/agents") {:ok, view, _html} = live(conn) # Trigger create_agent event with flat params html = render_submit(view, "create_agent", %{"name" => "Flat Agent"}) assert html =~ "Agent created successfully" assert html =~ "Flat Agent" end test "shows error on creation failure", %{conn: conn, user: user, organization: _organization} do conn = conn |> log_in_user(user) |> get("/agents") {:ok, view, _html} = live(conn) # Trigger create_agent with invalid params (empty name) html = render_submit(view, "create_agent", %{"agent_form" => %{"name" => ""}}) assert html =~ "Failed to create agent" end end describe "close_token_modal event" do test "closes the token modal", %{conn: conn, user: user, organization: _organization} do conn = conn |> log_in_user(user) |> get("/agents") {:ok, view, _html} = live(conn) # First create an agent to show the modal render_submit(view, "create_agent", %{"name" => "Test Agent"}) # Now close the modal _html = render_click(view, "close_token_modal", %{}) # Modal should be closed - verify by checking that we can still interact with the view assert render(view) =~ "Test Agent" end end describe "delete_agent event" do test "deletes agent token successfully", %{conn: conn, user: user, organization: organization} do {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent to Delete") conn = conn |> log_in_user(user) |> get("/agents") {:ok, view, _html} = live(conn) html = render_click(view, "delete_agent", %{"id" => agent_token.id}) assert html =~ "Agent deleted successfully" # Verify agent was deleted (no longer in list) agent_tokens = Agents.list_organization_agent_tokens(organization.id) assert Enum.empty?(agent_tokens) end end describe "agent status display" do test "shows Never connected for agents without last_seen_at", %{ conn: conn, user: user, organization: organization } do {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Never Seen") # Verify the agent has no last_seen_at assert is_nil(agent_token.last_seen_at) conn = conn |> log_in_user(user) |> get("/agents") {:ok, _view, html} = live(conn) assert html =~ "Never Seen" assert html =~ "Never connected" or html =~ "Never" end test "shows agent status information in the page", %{ conn: conn, user: user, organization: organization } do {:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Status Agent") # Update the agent to have a recent last_seen_at agent_token |> Ecto.Changeset.change(%{last_seen_at: Towerops.Time.now()}) |> Towerops.Repo.update!() conn = conn |> log_in_user(user) |> get("/agents") {:ok, _view, html} = live(conn) assert html =~ "Status Agent" # The status should show something about timing (Online, seconds ago, etc.) assert html =~ "ago" or html =~ "Online" end end describe "handle_params" do test "handles :index action", %{conn: conn, user: user, organization: _organization} do conn = conn |> log_in_user(user) |> get("/agents") {:ok, view, _html} = live(conn) # Verify we can navigate to the index action assert render(view) =~ "Remote Agents" end end describe "cloud poller creation - superadmin only" do setup %{organization: organization} do # Create a superadmin user superadmin = user_fixture(enable_totp: true) superadmin = superadmin |> Ecto.Changeset.change(%{is_superuser: true}) |> Towerops.Repo.update!() # Add superadmin to the organization {:ok, _membership} = Towerops.Organizations.create_membership(%{ organization_id: organization.id, user_id: superadmin.id, role: "admin" }) %{superadmin: superadmin} end test "superadmin can create cloud poller", %{ conn: conn, superadmin: superadmin, organization: _organization } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, html} = live(conn) # Superadmin should see the cloud poller checkbox assert html =~ "Cloud Poller" # Create a cloud poller html = render_submit(view, "create_agent", %{ "agent_form" => %{"name" => "Test Cloud Poller", "is_cloud_poller" => "true"} }) assert html =~ "Cloud poller created successfully" assert html =~ "Test Cloud Poller" # Verify cloud poller was created cloud_pollers = Agents.list_cloud_pollers() assert length(cloud_pollers) == 1 assert hd(cloud_pollers).name == "Test Cloud Poller" assert hd(cloud_pollers).is_cloud_poller == true assert hd(cloud_pollers).organization_id == nil end test "non-superadmin cannot create cloud poller", %{ conn: conn, user: user, organization: organization } do conn = conn |> log_in_user(user) |> get("/agents") {:ok, view, html} = live(conn) # Regular user should NOT see the cloud poller checkbox refute html =~ "Cloud Poller (Application-wide agent" # Attempt to create a cloud poller (bypassing UI) html = render_submit(view, "create_agent", %{ "agent_form" => %{"name" => "Unauthorized Cloud Poller", "is_cloud_poller" => "true"} }) assert html =~ "Only superadmins can create cloud pollers" # Verify cloud poller was NOT created cloud_pollers = Agents.list_cloud_pollers() assert Enum.empty?(cloud_pollers) # Verify organization agent was NOT created org_agents = Agents.list_organization_agent_tokens(organization.id) assert Enum.empty?(org_agents) end test "superadmin can create regular agent by not checking cloud poller", %{ conn: conn, superadmin: superadmin, organization: organization } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, _html} = live(conn) # Create a regular agent (is_cloud_poller = false) html = render_submit(view, "create_agent", %{ "agent_form" => %{"name" => "Regular Agent", "is_cloud_poller" => "false"} }) assert html =~ "Agent created successfully" assert html =~ "Regular Agent" # Verify regular agent was created org_agents = Agents.list_organization_agent_tokens(organization.id) assert length(org_agents) == 1 assert hd(org_agents).name == "Regular Agent" assert hd(org_agents).is_cloud_poller == false assert hd(org_agents).organization_id == organization.id # Verify cloud poller was NOT created cloud_pollers = Agents.list_cloud_pollers() assert Enum.empty?(cloud_pollers) end end describe "cloud poller visibility" do setup %{organization: organization} do # Create a superadmin user superadmin = user_fixture(enable_totp: true) superadmin = superadmin |> Ecto.Changeset.change(%{is_superuser: true}) |> Towerops.Repo.update!() # Add superadmin to the organization {:ok, _membership} = Towerops.Organizations.create_membership(%{ organization_id: organization.id, user_id: superadmin.id, role: "admin" }) # Create some cloud pollers {:ok, cloud1, _} = Agents.create_cloud_poller("Cloud Poller 1") {:ok, cloud2, _} = Agents.create_cloud_poller("Cloud Poller 2") # Create organization agents {:ok, org_agent, _} = Agents.create_agent_token(organization.id, "Org Agent") %{superadmin: superadmin, cloud1: cloud1, cloud2: cloud2, org_agent: org_agent} end test "superadmin sees cloud pollers in separate section", %{ conn: conn, superadmin: superadmin, organization: _organization, cloud1: cloud1, cloud2: cloud2, org_agent: org_agent } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, _view, html} = live(conn) # Should see cloud pollers section header assert html =~ "Cloud Pollers" # Should see both cloud pollers assert html =~ cloud1.name assert html =~ cloud2.name # Should see organization agent in main section assert html =~ org_agent.name # Should see visual distinction for cloud pollers assert html =~ "Cloud Poller" or html =~ "cloud-poller" end test "non-superadmin does not see cloud pollers section", %{ conn: conn, user: user, organization: _organization, cloud1: cloud1, cloud2: cloud2, org_agent: org_agent } do conn = conn |> log_in_user(user) |> get("/agents") {:ok, _view, html} = live(conn) # Should NOT see cloud pollers section header refute html =~ "Cloud Pollers" # Should NOT see cloud poller names refute html =~ cloud1.name refute html =~ cloud2.name # Should see organization agent in main section assert html =~ org_agent.name end end describe "cloud poller management" do setup %{organization: organization} do # Create a superadmin user superadmin = user_fixture(enable_totp: true) superadmin = superadmin |> Ecto.Changeset.change(%{is_superuser: true}) |> Towerops.Repo.update!() # Add superadmin to the organization {:ok, _membership} = Towerops.Organizations.create_membership(%{ organization_id: organization.id, user_id: superadmin.id, role: "admin" }) {:ok, cloud_poller, _} = Agents.create_cloud_poller("Test Cloud Poller") %{superadmin: superadmin, cloud_poller: cloud_poller} end test "superadmin can delete cloud poller", %{ conn: conn, superadmin: superadmin, organization: _organization, cloud_poller: cloud_poller } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, _html} = live(conn) html = render_click(view, "delete_agent", %{"id" => cloud_poller.id}) assert html =~ "Agent deleted successfully" # Verify cloud poller was deleted cloud_pollers = Agents.list_cloud_pollers() assert Enum.empty?(cloud_pollers) end test "superadmin can regenerate cloud poller token", %{ conn: conn, superadmin: superadmin, organization: _organization, cloud_poller: cloud_poller } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, _html} = live(conn) html = render_click(view, "regenerate_token", %{"id" => cloud_poller.id}) assert html =~ "New token generated successfully" assert html =~ "Test Cloud Poller" end test "superadmin can view cloud poller setup instructions", %{ conn: conn, superadmin: superadmin, organization: _organization, cloud_poller: cloud_poller } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, _html} = live(conn) html = render_click(view, "show_setup", %{"id" => cloud_poller.id}) assert html =~ "Test Cloud Poller" # Setup modal should show token and instructions assert html =~ cloud_poller.token or html =~ "docker run" or html =~ "Setup" end end describe "global default cloud poller management" do setup %{organization: organization} do # Create a superadmin user superadmin = user_fixture(enable_totp: true) superadmin = superadmin |> Ecto.Changeset.change(%{is_superuser: true}) |> Towerops.Repo.update!() # Add superadmin to the organization {:ok, _membership} = Towerops.Organizations.create_membership(%{ organization_id: organization.id, user_id: superadmin.id, role: "admin" }) {:ok, cloud_poller, _} = Agents.create_cloud_poller("Test Cloud Poller") %{superadmin: superadmin, cloud_poller: cloud_poller} end test "superadmin can set global default cloud poller with validation", %{ conn: conn, superadmin: superadmin, organization: _organization, cloud_poller: cloud_poller } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, html} = live(conn) # Should see global default section assert html =~ "Global Default Cloud Poller" # Update dropdown selection (doesn't save yet) render_change(view, "update_selected_global_default", %{ "agent_token_id" => cloud_poller.id }) # Click save button html = render_click(view, "save_global_default", %{}) assert html =~ "Global default cloud poller set successfully" # Verify it was saved assert Towerops.Settings.get_global_default_cloud_poller() == cloud_poller.id end test "superadmin cannot save non-existent agent as global default", %{ conn: conn, superadmin: superadmin, organization: _organization } do conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, _html} = live(conn) fake_id = Ecto.UUID.generate() # Update dropdown to fake ID render_change(view, "update_selected_global_default", %{"agent_token_id" => fake_id}) # Attempt to save html = render_click(view, "save_global_default", %{}) assert html =~ "Selected agent no longer exists" # Verify it was NOT saved refute Towerops.Settings.get_global_default_cloud_poller() == fake_id end test "superadmin can clear global default cloud poller", %{ conn: conn, superadmin: superadmin, organization: _organization, cloud_poller: cloud_poller } do # First set a global default Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id) conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, html} = live(conn) # Should show current default assert html =~ "Currently using" # Select "No global default" render_change(view, "update_selected_global_default", %{"agent_token_id" => ""}) # Save html = render_click(view, "save_global_default", %{}) assert html =~ "Global default cloud poller cleared" # Verify it was cleared assert Towerops.Settings.get_global_default_cloud_poller() == nil end test "deleting cloud poller clears it from global default", %{ conn: conn, superadmin: superadmin, organization: _organization, cloud_poller: cloud_poller } do # Set as global default Towerops.Settings.set_global_default_cloud_poller(cloud_poller.id) conn = conn |> log_in_user(superadmin) |> get("/agents") {:ok, view, _html} = live(conn) # Delete the cloud poller html = render_click(view, "delete_agent", %{"id" => cloud_poller.id}) assert html =~ "Agent deleted successfully" # Verify global default was cleared (CASCADE) assert Towerops.Settings.get_global_default_cloud_poller() == nil # Verify the UI reflects this assert html =~ "No global default" or html =~ "direct Phoenix" end test "non-superadmin cannot see global default section", %{ conn: conn, user: user, organization: _organization } do conn = conn |> log_in_user(user) |> get("/agents") {:ok, _view, html} = live(conn) # Should NOT see global default section refute html =~ "Global Default Cloud Poller" end end end