test: refactor LiveView tests to use HTML-aware element/form helpers

Apply patterns from testingliveview.com — replace render_click/submit/change
event-name calls with element/form-targeted equivalents that validate the
actual DOM wiring, not just handler logic. Adds stable test selectors
(id="...") to interactive elements across 10 LiveView templates.

Surfaces (and fixes) several real issues that the bypass-style tests masked:
- Removes dead regenerate_token handler in agent_live/index.ex (no UI ever
  triggered it) and its corresponding test.
- Removes dead refresh_topology test in network_map_live_test (event was
  referenced only in the test — no handler, no button, no JS anywhere).
- Corrects "shows notification tab with add device modal" — modal lives on
  the security tab; original test mounted ?tab=notifications and only
  worked because direct event calls bypassed tab gating.
- Corrects form input shape mismatches in agent_live/index_test that the
  handler's defensive case clauses had been masking.
- Expands setup data for org/settings_live_test (snmp_community,
  default_agent_token_id, multi-org membership) so the gated buttons
  actually render.

11 direct render_click/submit/change calls remain — all intentional,
documented server-side guard tests (tampered POSTs, race conditions,
defensive idempotent handlers).

Full LiveView test suite: 1300 tests, 0 failures.
This commit is contained in:
Graham McIntire 2026-04-29 11:13:48 -05:00
parent 17bb5a9117
commit aa7fc830e5
21 changed files with 257 additions and 183 deletions

View file

@ -271,7 +271,7 @@ defmodule Towerops.Agents do
Updates an agent token with the given attributes.
Currently only supports updating the name and allow_remote_debug fields.
Other fields like the token itself cannot be changed (use regenerate_token instead).
Other fields like the token itself cannot be changed.
## Examples

View file

@ -162,6 +162,7 @@ defmodule ToweropsWeb.Admin.AuditLive.Index do
<.icon name="hero-magnifying-glass" class="h-4 w-4 mr-2" /> Search
</button>
<button
id="audit-clear-filters"
type="button"
phx-click="clear_filters"
class="inline-flex items-center px-4 py-2 border border-gray-300 dark:border-gray-600 text-sm font-medium rounded-md text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
@ -284,6 +285,7 @@ defmodule ToweropsWeb.Admin.AuditLive.Index do
</div>
<div class="flex gap-2">
<button
id="audit-prev-page"
type="button"
phx-click="prev_page"
disabled={@page == 1}
@ -292,6 +294,7 @@ defmodule ToweropsWeb.Admin.AuditLive.Index do
Previous
</button>
<button
id="audit-next-page"
type="button"
phx-click="next_page"
disabled={length(@audit_logs) < @per_page}

View file

@ -11,6 +11,7 @@
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8">
<button
id="security-tab-whitelist"
phx-click="change_tab"
phx-value-tab="whitelist"
class={[
@ -26,6 +27,7 @@
</button>
<button
id="security-tab-blocked"
phx-click="change_tab"
phx-value-tab="blocked"
class={[
@ -49,7 +51,7 @@
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{t("Allowed IPs and CIDR Ranges")}
</h2>
<.button phx-click="show_whitelist_form">
<.button id="show-whitelist-form" phx-click="show_whitelist_form">
<.icon name="hero-plus" class="w-4 h-4 mr-1" /> Add to Allowlist
</.button>
</div>
@ -80,7 +82,7 @@
<div class="flex gap-2">
<.button type="submit">{t("Add to Allowlist")}</.button>
<.button type="button" phx-click="hide_whitelist_form">
<.button id="hide-whitelist-form" type="button" phx-click="hide_whitelist_form">
{t("Cancel")}
</.button>
</div>

View file

@ -130,35 +130,6 @@ defmodule ToweropsWeb.AgentLive.Index do
|> push_patch(to: ~p"/agents?modal=setup")}
end
@impl true
def handle_event("regenerate_token", %{"id" => id}, socket) do
agent_token = Agents.get_agent_token!(id)
organization = socket.assigns.current_scope.organization
# Revoke the old token and create a new one
case Agents.revoke_agent_token(id) do
{:ok, _} ->
case Agents.create_agent_token(organization.id, agent_token.name) do
{:ok, new_agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
{:noreply,
socket
|> stream(:agent_tokens, agent_tokens, reset: true)
|> assign(:has_agents, agent_tokens != [])
|> assign(:new_token, %{agent_token: new_agent_token, token: token})
|> assign(:show_token_modal, true)
|> put_flash(:info, t_equipment("New token generated successfully"))}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t_equipment("Failed to generate new token"))}
end
{:error, _} ->
{:noreply, put_flash(socket, :error, t_equipment("Failed to revoke old token"))}
end
end
@impl true
def handle_event("delete_agent", %{"id" => id}, socket) do
agent_token = Agents.get_agent_token!(id)

View file

@ -19,7 +19,7 @@
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t("Create New Agent")}
</h3>
<.form for={@agent_form} phx-submit="create_agent">
<.form id="create-agent-form" for={@agent_form} phx-submit="create_agent">
<div class="space-y-4">
<.input
field={@agent_form[:name]}
@ -143,6 +143,7 @@
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<.button
id={"show-setup-#{agent.id}"}
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
@ -151,6 +152,7 @@
{t("View Setup")}
</.button>
<.button
id={"delete-agent-#{agent.id}"}
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
@ -260,6 +262,7 @@
<%= if agent.enabled do %>
<div class="flex items-center gap-3">
<.button
id={"show-setup-#{agent.id}"}
type="button"
phx-click="show_setup"
phx-value-id={agent.id}
@ -268,6 +271,7 @@
{t("View Setup")}
</.button>
<.button
id={"delete-agent-#{agent.id}"}
type="button"
phx-click="delete_agent"
phx-value-id={agent.id}
@ -303,7 +307,12 @@
)}
</p>
<.form for={%{}} phx-change="update_selected_global_default" class="max-w-md">
<.form
id="global-default-form"
for={%{}}
phx-change="update_selected_global_default"
class="max-w-md"
>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-amber-900 dark:text-amber-200 mb-2">
@ -351,6 +360,7 @@
<div class="flex gap-3">
<.button
id="save-global-default"
type="button"
phx-click="save_global_default"
variant="primary"
@ -529,7 +539,7 @@
</div>
<div class="mt-6 flex justify-end">
<.button phx-click="close_token_modal" variant="primary">
<.button id="close-token-modal" phx-click="close_token_modal" variant="primary">
{t("Close")}
</.button>
</div>

View file

@ -339,6 +339,7 @@
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8" aria-label={t("Monitoring mode")}>
<button
id="monitoring-mode-snmp-and-icmp"
type="button"
phx-click="switch_monitoring_mode"
phx-value-mode="snmp_and_icmp"
@ -353,6 +354,7 @@
{t("SNMP & ICMP")}
</button>
<button
id="monitoring-mode-icmp-only"
type="button"
phx-click="switch_monitoring_mode"
phx-value-mode="icmp_only"
@ -573,6 +575,7 @@
<%= if @live_action == :edit do %>
<.button
id="trigger-discovery"
type="button"
phx-click="trigger_discovery"
phx-disable-with={t("Discovering...")}
@ -778,6 +781,7 @@
)}
</p>
<.button
id="delete-device"
phx-click="delete"
data-confirm={
t(

View file

@ -47,6 +47,7 @@
<%= if @has_devices do %>
<%= if @reorder_mode do %>
<.button
id="reset-order"
type="button"
phx-click="reset_order"
data-confirm={t("Reset all sites and devices to alphabetical order?")}
@ -60,6 +61,7 @@
</.button>
<% else %>
<.button
id="toggle-reorder-mode"
type="button"
phx-click="toggle_reorder_mode"
title="Reorder devices and sites"
@ -75,6 +77,7 @@
</.button>
<%= if @has_devices and not @reorder_mode do %>
<.button
id="force-rediscover-all"
type="button"
phx-click="force_rediscover_all"
title="Rediscover all devices"

View file

@ -1507,6 +1507,7 @@
{capacity_source_label(interface.capacity_source)}
</span>
<button
id={"clear-capacity-#{interface.id}"}
phx-click="clear_capacity"
phx-value-interface_id={interface.id}
class="text-gray-400 hover:text-red-500 transition-colors ml-0.5"
@ -1517,6 +1518,7 @@
</div>
<% else %>
<button
id={"set-capacity-#{interface.id}"}
phx-click={
JS.push("set_capacity",
value: %{

View file

@ -329,6 +329,7 @@
{t("Node Details")}
</h3>
<button
id="close-detail-panel"
phx-click="close_detail_panel"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>

View file

@ -158,6 +158,7 @@
</div>
<% else %>
<button
id="toggle-default-org"
type="button"
phx-click="toggle_default_org"
class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 dark:bg-indigo-500 dark:shadow-none dark:hover:bg-indigo-400"
@ -487,6 +488,7 @@
)}
</p>
<button
id="apply-snmp-to-all"
type="button"
phx-click="apply_snmp_to_all"
data-confirm={
@ -674,6 +676,7 @@
)}
</p>
<button
id="apply-agent-to-all"
type="button"
phx-click="apply_agent_to_all"
data-confirm={

View file

@ -675,6 +675,7 @@
</p>
<div class="mt-6">
<button
id="show-add-token-modal-empty"
type="button"
phx-click="show_add_token_modal"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
@ -1204,6 +1205,7 @@
</p>
<div class="mt-6">
<button
id="show-add-device-modal"
type="button"
phx-click="show_add_device_modal"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
@ -1265,6 +1267,7 @@
<div class="mt-6 flex">
<button
id="show-add-device-modal-with-devices"
type="button"
phx-click="show_add_device_modal"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:bg-indigo-500 dark:hover:bg-indigo-400"
@ -1440,7 +1443,7 @@
</span>
<div class="relative z-10 inline-block transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left align-bottom shadow-xl transition-all dark:bg-gray-800/95 sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<.form for={%{}} as={:token} phx-submit="create_api_token">
<.form id="create-api-token-form" for={%{}} as={:token} phx-submit="create_api_token">
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900">
<.icon name="hero-code-bracket" class="h-6 w-6 text-blue-600 dark:text-blue-400" />
@ -1502,6 +1505,7 @@
{t("Create Token")}
</button>
<button
id="cancel-add-token"
type="button"
phx-click="cancel_add_token"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"
@ -1660,6 +1664,7 @@
{t("Continue")}
</button>
<button
id="cancel-add-device"
type="button"
phx-click="cancel_add_device"
class="mt-3 inline-flex w-full justify-center rounded-lg bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:hover:bg-gray-800 sm:col-start-1 sm:mt-0"

View file

@ -128,7 +128,7 @@ defmodule ToweropsWeb.Admin.AuditLive.IndexTest do
|> render_submit()
# Then clear filters
html = render_click(view, "clear_filters")
html = view |> element("#audit-clear-filters") |> render_click()
# Both logs should be visible again
assert html =~ "Impersonate start"
@ -151,7 +151,7 @@ defmodule ToweropsWeb.Admin.AuditLive.IndexTest do
assert html =~ "Page 1"
html = render_click(view, "next_page")
html = view |> element("#audit-next-page") |> render_click()
assert html =~ "Page 2"
end
@ -167,8 +167,8 @@ defmodule ToweropsWeb.Admin.AuditLive.IndexTest do
{:ok, view, _html} = live(conn, ~p"/admin/audit")
render_click(view, "next_page")
html = render_click(view, "prev_page")
view |> element("#audit-next-page") |> render_click()
html = view |> element("#audit-prev-page") |> render_click()
assert html =~ "Page 1"
end
@ -183,6 +183,8 @@ defmodule ToweropsWeb.Admin.AuditLive.IndexTest do
{:ok, view, _html} = live(conn, ~p"/admin/audit")
# The "Previous" button is disabled when @page == 1, so the UI prevents
# navigation. Test the server-side guard against a tampered event.
html = render_click(view, "prev_page")
assert html =~ "Page 1"

View file

@ -62,7 +62,7 @@ defmodule ToweropsWeb.Admin.SecurityLive.IndexTest do
test "change_tab event patches URL", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/security")
render_click(view, "change_tab", %{"tab" => "blocked"})
view |> element("#security-tab-blocked") |> render_click()
assert_patch(view, ~p"/admin/security?tab=blocked")
end
@ -87,7 +87,7 @@ defmodule ToweropsWeb.Admin.SecurityLive.IndexTest do
test "shows whitelist form when button clicked", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/security")
html = render_click(view, "show_whitelist_form")
html = view |> element("#show-whitelist-form") |> render_click()
assert html =~ "IP Address or CIDR"
assert html =~ "Description"
@ -96,12 +96,12 @@ defmodule ToweropsWeb.Admin.SecurityLive.IndexTest do
test "hides whitelist form when cancel clicked", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/security")
render_click(view, "show_whitelist_form")
view |> element("#show-whitelist-form") |> render_click()
# Verify form is visible
assert render(view) =~ "IP Address or CIDR"
render_click(view, "hide_whitelist_form")
view |> element("#hide-whitelist-form") |> render_click()
html = render(view)
# The form fields should no longer be present
@ -111,7 +111,7 @@ defmodule ToweropsWeb.Admin.SecurityLive.IndexTest do
test "add_whitelist form renders with correct fields", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/security")
render_click(view, "show_whitelist_form")
view |> element("#show-whitelist-form") |> render_click()
# Known issue: the form uses to_form(%{}) without an :as option, producing
# flat field names, but the handler expects %{"whitelist" => params}.

View file

@ -57,11 +57,13 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
# Trigger create_agent event with nested params
html = render_submit(view, "create_agent", %{"agent_form" => %{"name" => "New Agent"}})
html =
view
|> form("#create-agent-form", %{"name" => "New Agent"})
|> render_submit()
assert has_element?(view, "#token-modal", "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)
@ -69,21 +71,6 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
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
@ -92,8 +79,10 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
# Trigger create_agent with invalid params (empty name)
html = render_submit(view, "create_agent", %{"agent_form" => %{"name" => ""}})
html =
view
|> form("#create-agent-form", %{"name" => ""})
|> render_submit()
assert html =~ "Failed to create agent"
end
@ -108,14 +97,16 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
# First create an agent to show the modal
render_submit(view, "create_agent", %{"name" => "Test Agent"})
view
|> form("#create-agent-form", %{"name" => "Test Agent"})
|> render_submit()
# Now close the modal
_html = render_click(view, "close_token_modal", %{})
assert has_element?(view, "#token-modal")
# Modal should be closed - verify by checking that we can still interact with the view
assert render(view) =~ "Test Agent"
view |> element("#close-token-modal") |> render_click()
refute has_element?(view, "#token-modal")
assert has_element?(view, "#agents-table", "Test Agent")
end
end
@ -130,7 +121,10 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
html = render_click(view, "delete_agent", %{"id" => agent_token.id})
html =
view
|> element("#delete-agent-#{agent_token.id}")
|> render_click()
assert html =~ "Agent deleted successfully"
@ -239,9 +233,9 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
# Create a cloud poller
html =
render_submit(view, "create_agent", %{
"agent_form" => %{"name" => "Test Cloud Poller", "is_cloud_poller" => "true"}
})
view
|> form("#create-agent-form", %{"name" => "Test Cloud Poller", "is_cloud_poller" => "true"})
|> render_submit()
assert html =~ "Cloud poller created successfully"
assert html =~ "Test Cloud Poller"
@ -269,7 +263,9 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
# Regular user should NOT see the cloud poller checkbox
refute html =~ "Cloud Poller (Application-wide agent"
# Attempt to create a cloud poller (bypassing UI)
# The cloud_poller checkbox is intentionally not rendered for non-superadmins, so
# form/3 cannot set it (which validates the UI-level guard works). To test the
# server-side guard against a tampered POST, bypass the form helper here.
html =
render_submit(view, "create_agent", %{
"agent_form" => %{"name" => "Unauthorized Cloud Poller", "is_cloud_poller" => "true"}
@ -298,11 +294,11 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
# Create a regular agent (is_cloud_poller = false)
# Create a regular agent (is_cloud_poller defaults to false from the hidden input)
html =
render_submit(view, "create_agent", %{
"agent_form" => %{"name" => "Regular Agent", "is_cloud_poller" => "false"}
})
view
|> form("#create-agent-form", %{"name" => "Regular Agent"})
|> render_submit()
assert html =~ "Agent created successfully"
assert html =~ "Regular Agent"
@ -440,7 +436,10 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
html = render_click(view, "delete_agent", %{"id" => cloud_poller.id})
html =
view
|> element("#delete-agent-#{cloud_poller.id}")
|> render_click()
assert html =~ "Agent deleted successfully"
@ -449,25 +448,6 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
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,
@ -481,11 +461,13 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
html = render_click(view, "show_setup", %{"id" => cloud_poller.id})
html =
view
|> element("#show-setup-#{cloud_poller.id}")
|> render_click()
assert html =~ "Test Cloud Poller"
# Setup modal should show token and instructions
assert html =~ cloud_poller.token or html =~ "docker run" or html =~ "Setup"
assert has_element?(view, "#token-modal")
end
end
@ -529,12 +511,11 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
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
})
view
|> form("#global-default-form", %{"agent_token_id" => cloud_poller.id})
|> render_change()
# Click save button
html = render_click(view, "save_global_default", %{})
html = view |> element("#save-global-default") |> render_click()
assert html =~ "Global default cloud poller set successfully"
@ -556,10 +537,10 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
fake_id = Ecto.UUID.generate()
# Update dropdown to fake ID
# Tests a race-condition guard: the agent was deleted between page load and save.
# form/3 validates against the rendered <option> list, which by definition can't
# include the deleted agent — so we drive the events directly to reach the guard.
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"
@ -588,10 +569,11 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
assert html =~ "Currently using"
# Select "No global default"
render_change(view, "update_selected_global_default", %{"agent_token_id" => ""})
view
|> form("#global-default-form", %{"agent_token_id" => ""})
|> render_change()
# Save
html = render_click(view, "save_global_default", %{})
html = view |> element("#save-global-default") |> render_click()
assert html =~ "Global default cloud poller cleared"
@ -616,7 +598,10 @@ defmodule ToweropsWeb.AgentLive.IndexTest do
{:ok, view, _html} = live(conn)
# Delete the cloud poller
html = render_click(view, "delete_agent", %{"id" => cloud_poller.id})
html =
view
|> element("#delete-agent-#{cloud_poller.id}")
|> render_click()
assert html =~ "Agent deleted successfully"

View file

@ -311,11 +311,13 @@ defmodule ToweropsWeb.AgentLiveTest do
# 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
# 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", %{})
# Should get an error or be rejected
assert result =~ "Only superadmins can set the global default cloud poller" or
result =~ "Remote Agents"
end)

View file

@ -196,7 +196,11 @@ defmodule ToweropsWeb.DeviceLive.FormTest do
test "switches monitoring mode", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/devices/new")
html = render_click(view, "switch_monitoring_mode", %{"mode" => "icmp_only"})
html =
view
|> element("#monitoring-mode-icmp-only")
|> render_click()
assert html =~ "New Device"
end
end
@ -257,7 +261,7 @@ defmodule ToweropsWeb.DeviceLive.FormTest do
test "deletes device", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
result = render_click(view, "delete")
result = view |> element("#delete-device") |> render_click()
case result do
{:error, {:live_redirect, %{to: "/devices" <> _}}} ->
@ -281,6 +285,9 @@ defmodule ToweropsWeb.DeviceLive.FormTest do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}/edit")
# The "Rediscover" button is hidden when monitoring_mode == "icmp_only" (i.e.
# when SNMP is disabled), so it cannot be clicked through the UI in this state.
# This test exercises the handler's defensive guard against being invoked anyway.
html = render_click(view, "trigger_discovery")
assert html =~ "SNMP is not enabled"
end

View file

@ -272,7 +272,7 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
{:ok, view, _html} = live(conn, ~p"/devices")
html = render_click(view, "toggle_reorder_mode")
html = view |> element("#toggle-reorder-mode") |> render_click()
assert html =~ "Reset"
end
@ -287,7 +287,9 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
{:ok, view, _html} = live(conn, ~p"/devices")
html = render_click(view, "reset_order")
# Reset button only renders inside reorder mode — toggle in first.
view |> element("#toggle-reorder-mode") |> render_click()
html = view |> element("#reset-order") |> render_click()
assert html =~ "Order reset to alphabetical"
end
@ -303,15 +305,32 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
{:ok, view, _html} = live(conn, ~p"/devices")
html = render_click(view, "force_rediscover_all")
html = view |> element("#force-rediscover-all") |> render_click()
assert html =~ "No SNMP-enabled devices found"
end
test "reorder_device with invalid device_id shows error", %{conn: conn} do
test "reorder_device with invalid device_id shows error", %{
conn: conn,
site: site,
organization: organization
} do
# The #device-list hook target only renders when has_devices is true.
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
# Fired by the DeviceListReorder JS hook on drag-drop. Using render_hook
# validates the hook is wired to #device-list, even though it can't run JS.
html =
render_click(view, "reorder_device", %{
view
|> element("#device-list")
|> render_hook("reorder_device", %{
"device_id" => Ecto.UUID.generate(),
"new_position" => "1"
})
@ -319,11 +338,25 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
assert html =~ "not found"
end
test "reorder_site with invalid site_id shows error", %{conn: conn} do
test "reorder_site with invalid site_id shows error", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
html =
render_click(view, "reorder_site", %{
view
|> element("#device-list")
|> render_hook("reorder_site", %{
"site_id" => Ecto.UUID.generate(),
"new_position" => "1"
})

View file

@ -504,7 +504,31 @@ defmodule ToweropsWeb.DeviceLive.ShowTest do
assert html =~ "Utilization"
end
test "set capacity via event handler", %{
test "set capacity via UI button", %{
conn: conn,
user: user,
device: device,
interface: interface
} do
# The "Set" button only appears when the interface has no configured_capacity yet.
# Clear it first so the button is visible, then click — the button submits the
# if_speed-derived value (1_000_000_000 / 1M = 1000 Mbps).
interface
|> Interface.changeset(%{configured_capacity_bps: nil, capacity_source: "if_speed"})
|> Towerops.Repo.update!()
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
html =
view
|> element("#set-capacity-#{interface.id}")
|> render_click()
assert html =~ "1000.0 Mbps" or html =~ "1.0 Gbps"
end
test "clear capacity via UI button", %{
conn: conn,
user: user,
device: device,
@ -514,24 +538,9 @@ defmodule ToweropsWeb.DeviceLive.ShowTest do
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
html =
render_click(view, "set_capacity", %{
"interface_id" => interface.id,
"capacity_mbps" => "500"
})
assert html =~ "500.0 Mbps"
end
test "clear capacity via event handler", %{
conn: conn,
user: user,
device: device,
interface: interface
} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
html = render_click(view, "clear_capacity", %{"interface_id" => interface.id})
view
|> element("#clear-capacity-#{interface.id}")
|> render_click()
# After clearing, the capacity badge should not show 300 Mbps anymore
refute html =~ "300.0 Mbps"

View file

@ -86,16 +86,6 @@ defmodule ToweropsWeb.NetworkMapLiveTest do
end
end
describe "events" do
test "refresh_topology reloads data and shows flash", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/network-map")
html = render_click(view, "refresh_topology")
assert html =~ "Network map refreshed"
end
end
describe "topology update events" do
test "pushes update_topology event on mount", %{conn: conn, organization: organization} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: organization.id})
@ -166,22 +156,28 @@ defmodule ToweropsWeb.NetworkMapLiveTest do
test "node_clicked event assigns selected node detail", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/network-map")
html = render_click(view, "node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
html =
view
|> element("#cy-container")
|> render_hook("node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
assert html =~ "Test-Router"
assert has_element?(view, "#node-detail-panel", "Test-Router")
assert html =~ "10.0.0.1"
end
test "close button clears selection", %{conn: conn, device: device} do
{:ok, view, _html} = live(conn, ~p"/network-map")
# Open panel
render_click(view, "node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
# Open panel via the JS hook (cytoscape graph node click)
view
|> element("#cy-container")
|> render_hook("node_clicked", %{"node_id" => device.id, "node_type" => "managed"})
# Close panel
html = render_click(view, "close_detail_panel")
assert has_element?(view, "#node-detail-panel")
refute html =~ "Node Details"
view |> element("#close-detail-panel") |> render_click()
refute has_element?(view, "#node-detail-panel")
end
test "?node=<id> param opens detail panel on mount", %{conn: conn, device: device} do

View file

@ -161,34 +161,49 @@ defmodule ToweropsWeb.Org.SettingsLiveTest do
end
test "applies SNMP config to all devices", %{conn: conn, user: user, organization: org} do
# The "Apply to All" button is only shown when org has an snmp_community set,
# otherwise there's nothing to apply.
{:ok, org} = Organizations.update_organization(org, %{snmp_community: "public"})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
|> live(~p"/orgs/#{org.slug}/settings?tab=snmp")
html = render_click(view, "apply_snmp_to_all")
html = view |> element("#apply-snmp-to-all") |> render_click()
assert html =~ "Applied SNMP configuration to"
end
test "applies default agent to all devices", %{conn: conn, user: user, organization: org} do
# The "Apply to All" button is only shown when org has a default agent set.
{:ok, agent_token, _} = Towerops.Agents.create_agent_token(org.id, "Default Agent")
{:ok, org} = Organizations.update_organization(org, %{default_agent_token_id: agent_token.id})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
|> live(~p"/orgs/#{org.slug}/settings?tab=agents")
html = render_click(view, "apply_agent_to_all")
html = view |> element("#apply-agent-to-all") |> render_click()
assert html =~ "Applied default agent to"
end
test "toggles default organization", %{conn: conn, user: user, organization: org} do
# The toggle button only renders when (a) @user_orgs_count > 1 and (b) the
# current membership is NOT the user's default. Create a second org and clear
# the default flag on this org's membership so the button is visible.
_other_org = organization_fixture(user.id)
membership = Organizations.get_membership!(org.id, user.id)
{:ok, _} = Organizations.update_membership(membership, %{is_default: false})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html = render_click(view, "toggle_default_org")
html = view |> element("#toggle-default-org") |> render_click()
# Either sets as default or shows already default message
assert html =~ "default organization" or html =~ "already your default"
end
end

View file

@ -143,6 +143,10 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
test "opens and cancels revoke all modal", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=sessions")
# The "Revoke All Other Sessions" button only renders when there are 2+ active
# browser sessions for the user (UX: nothing to revoke if you only have one).
# The test fixture creates a single session, so the trigger button isn't in the
# DOM. We exercise the modal-open/cancel handler logic directly here.
html = render_click(view, "show_revoke_all_modal")
assert html =~ "Revoke All"
@ -169,7 +173,7 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
test "opens add token modal", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
html = render_click(view, "show_add_token_modal")
html = view |> element("#show-add-token-modal-empty") |> render_click()
assert html =~ "Create API Token"
end
@ -177,8 +181,8 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
test "cancels add token modal", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
render_click(view, "show_add_token_modal")
html = render_click(view, "cancel_add_token")
view |> element("#show-add-token-modal-empty") |> render_click()
html = view |> element("#cancel-add-token") |> render_click()
refute html =~ "Create API Token"
end
@ -208,12 +212,12 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
render_click(view, "show_add_token_modal")
view |> element("#show-add-token-modal-empty") |> render_click()
html =
render_click(view, "create_api_token", %{
"token" => %{"name" => "My Token", "organization_id" => org.id}
})
view
|> form("#create-api-token-form", %{"name" => "My Token", "organization_id" => org.id})
|> render_submit()
assert html =~ "My Token" or html =~ "API Tokens"
end
@ -221,6 +225,9 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
test "create_api_token without name shows error", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
# Defensive handler test: the UI marks `name` as `required`, so a real browser
# blocks empty submission. The handler still has to defend against tampered POSTs
# — so we drive the event directly to verify the server-side guard.
html = render_click(view, "create_api_token", %{})
assert html =~ "required" or html =~ "API Tokens"
end
@ -228,6 +235,9 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
test "close_token_modal clears token", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=api")
# The Close button only renders when the token-display modal is open (which only
# happens after a token is successfully created). This tests the handler's idempotent
# behavior when the close event fires without the modal being open.
html = render_click(view, "close_token_modal")
assert html =~ "API Tokens"
end
@ -236,14 +246,19 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
describe "UserSettingsLive session management with sudo mode" do
setup :register_and_log_in_user_with_sudo
test "shows notification tab with add device modal", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=notifications")
test "shows security tab with add device modal", %{conn: conn} do
# show_add_device_modal is on the SECURITY tab (TOTP authenticator devices),
# not notifications. Original test mounted ?tab=notifications and only worked
# because direct render_click bypassed tab gating.
# The fixture user has enable_totp: true (one TOTP device), so the
# "with-devices" Add button renders, not the empty-state one.
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=security")
html = render_click(view, "show_add_device_modal")
assert html =~ "Mobile" or html =~ "Device"
html = view |> element("#show-add-device-modal") |> render_click()
assert html =~ "Device" or html =~ "authenticator"
html = render_click(view, "cancel_add_device")
assert html =~ "Mobile Devices"
html = view |> element("#cancel-add-device") |> render_click()
assert html =~ "TOTP" or html =~ "authenticator" or html =~ "Devices"
end
end
@ -253,13 +268,19 @@ defmodule ToweropsWeb.UserSettingsLiveTest do
test "check_password_breach with empty value", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account")
html = render_click(view, "check_password_breach", %{"value" => ""})
# Fired by phx-blur on #new-password — render_blur drives that path through the
# actual element, validating the wiring. Original test used render_click which
# silently bypassed the blur attribute.
html = view |> element("#new-password") |> render_blur(%{"value" => ""})
assert html =~ "Account Settings"
end
test "check_password_breach with no value param", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/users/settings?tab=account")
# Defensive handler test: the phx-blur event always carries the input value, so
# an empty params map can't be produced from the UI. Drive directly to verify
# the no-value branch of the handler.
html = render_click(view, "check_password_breach", %{})
assert html =~ "Account Settings"
end