towerops/test/towerops_web/live/org/settings_live_test.exs
Graham McIntire aa7fc830e5 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.
2026-04-29 11:14:20 -05:00

405 lines
14 KiB
Elixir

defmodule ToweropsWeb.Org.SettingsLiveTest do
use ToweropsWeb.ConnCase
use ExUnitProperties
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
import Towerops.AgentsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations.Integration
alias Towerops.Organizations
setup do
user = user_fixture(enable_totp: true)
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
describe "Settings page" do
test "displays organization settings form", %{conn: conn, user: user, organization: org} do
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
assert html =~ "Organization Settings"
assert html =~ "Organization Name"
assert html =~ org.name
end
test "shows no agents message when no agents exist", %{
conn: conn,
user: user,
organization: org
} do
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings?tab=agents")
assert html =~ "No agents configured yet"
assert html =~ "Create an agent"
end
test "shows default agent dropdown when agents exist", %{
conn: conn,
user: user,
organization: org
} do
{:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent")
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings?tab=agents")
assert html =~ "Default Remote Agent"
assert html =~ agent_token.name
assert html =~ "No default agent - cloud polling"
end
test "updates organization name successfully", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html =
view
|> form("#organization-form", organization: %{name: "Updated Name"})
|> render_submit()
assert html =~ "Settings saved successfully"
# Verify the organization was actually updated
updated_org = Organizations.get_organization!(org.id)
assert updated_org.name == "Updated Name"
end
test "sets default agent for organization", %{conn: conn, user: user, organization: org} do
{:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent")
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings?tab=agents")
html =
view
|> form("#organization-form", organization: %{default_agent_token_id: agent_token.id})
|> render_submit()
assert html =~ "Settings saved successfully"
# Verify the default agent was set
updated_org = Organizations.get_organization!(org.id)
assert updated_org.default_agent_token_id == agent_token.id
end
test "clears default agent for organization", %{conn: conn, user: user, organization: org} do
{:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent")
# First set a 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?tab=agents")
# Now clear it
html =
view
|> form("#organization-form", organization: %{default_agent_token_id: ""})
|> render_submit()
assert html =~ "Settings saved successfully"
# Verify the default agent was cleared
updated_org = Organizations.get_organization!(org.id)
assert updated_org.default_agent_token_id == nil
end
test "validates organization name", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
# Form requires name field - submitting empty should fail
html =
view
|> form("#organization-form", organization: %{name: ""})
|> render_submit()
# The form should still be displayed (not redirected)
assert html =~ "Organization Name"
end
test "requires authentication", %{conn: conn, organization: org} do
assert {:error, {:redirect, %{to: "/users/log-in"}}} =
live(conn, ~p"/orgs/#{org.slug}/settings")
end
test "validates organization name on change", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html =
view
|> form("#organization-form", organization: %{name: ""})
|> render_change()
assert html =~ "Organization Name"
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?tab=snmp")
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?tab=agents")
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 = view |> element("#toggle-default-org") |> render_click()
assert html =~ "default organization" or html =~ "already your default"
end
end
describe "property-based: organization name validation" do
property "changeset validates arbitrary printable names", %{organization: org} do
check all(name <- string(:printable, max_length: 500), max_runs: 50) do
changeset = Organizations.change_organization(org, %{name: name})
trimmed = String.trim(name)
if String.length(trimmed) < 2 or String.length(trimmed) > 100 do
assert changeset.errors[:name] != nil or not changeset.valid?
else
# Name within bounds — no name-specific error expected
refute Keyword.has_key?(changeset.errors, :name)
end
end
end
property "empty and whitespace-only names are rejected", %{organization: org} do
check all(spaces <- string([?\s, ?\t, ?\n], min_length: 0, max_length: 20), max_runs: 25) do
changeset = Organizations.change_organization(org, %{name: spaces})
refute changeset.valid?
end
end
property "unicode names within length bounds are accepted", %{organization: org} do
check all(name <- string(:printable, min_length: 2, max_length: 100), max_runs: 50) do
# String.length/1 counts grapheme clusters, which may be fewer than code points
# when combining characters are present (e.g. a base char + combining mark = 1 grapheme).
# Only assert when the grapheme count satisfies the validation bounds.
if String.length(name) >= 2 and String.length(name) <= 100 do
changeset = Organizations.change_organization(org, %{name: name})
refute Keyword.has_key?(changeset.errors, :name)
end
end
end
end
describe "property-based: SNMP config validation" do
property "SNMP port validates numeric bounds", %{organization: org} do
check all(
port <- one_of([integer(-1000..70_000), constant(0)]),
max_runs: 50
) do
changeset = Organizations.change_organization(org, %{snmp_port: port})
if is_integer(port) and port > 0 and port < 65_536 do
refute Keyword.has_key?(changeset.errors, :snmp_port)
else
assert changeset.errors[:snmp_port] != nil or not changeset.valid?
end
end
end
property "SNMP community string accepts arbitrary strings", %{organization: org} do
check all(community <- string(:printable, max_length: 200), max_runs: 50) do
changeset =
Organizations.change_organization(org, %{
snmp_version: "2c",
snmp_community: community
})
# Community string has no length validation — should not error on community field
refute Keyword.has_key?(changeset.errors, :snmp_community)
end
end
property "SNMPv3 passwords must be at least 8 bytes", %{organization: org} do
check all(password <- string(:printable, min_length: 0, max_length: 50), max_runs: 50) do
changeset =
Organizations.change_organization(org, %{
snmp_version: "3",
snmpv3_username: "testuser",
snmpv3_auth_protocol: "SHA-256",
snmpv3_auth_password: password,
snmpv3_priv_protocol: "AES",
snmpv3_priv_password: password
})
# The validation uses byte_size, not String.length
if byte_size(password) < 8 do
# At least one password field should have an error
has_error =
Keyword.has_key?(changeset.errors, :snmpv3_auth_password) or
Keyword.has_key?(changeset.errors, :snmpv3_priv_password)
assert has_error
end
end
end
property "SNMP version rejects invalid values", %{organization: org} do
check all(
version <- string(:alphanumeric, min_length: 1, max_length: 10),
version not in ["1", "2c", "3"],
max_runs: 50
) do
changeset = Organizations.change_organization(org, %{snmp_version: version})
assert Keyword.has_key?(changeset.errors, :snmp_version)
end
end
end
describe "property-based: integration validation" do
property "sync_interval_minutes must be positive", %{organization: org} do
check all(
interval <- one_of([integer(-100..200), constant(0), constant(nil)]),
max_runs: 50
) do
changeset =
Integration.changeset(%Integration{}, %{
organization_id: org.id,
provider: "preseem",
sync_interval_minutes: interval
})
if is_integer(interval) and interval > 0 do
refute Keyword.has_key?(changeset.errors, :sync_interval_minutes)
else
if is_integer(interval) do
assert Keyword.has_key?(changeset.errors, :sync_interval_minutes)
end
end
end
end
property "provider must be valid", %{organization: org} do
check all(
provider <- string(:alphanumeric, min_length: 1, max_length: 20),
provider not in ["preseem", "gaiia", "pagerduty"],
max_runs: 50
) do
changeset =
Integration.changeset(%Integration{}, %{
organization_id: org.id,
provider: provider
})
assert Keyword.has_key?(changeset.errors, :provider)
end
end
property "API key credential round-trips through changeset" do
check all(api_key <- string(:printable, min_length: 1, max_length: 200), max_runs: 50) do
credentials = %{"api_key" => api_key, "webhook_secret" => "test"}
changeset =
Integration.changeset(%Integration{}, %{
provider: "preseem",
organization_id: Ecto.UUID.generate(),
credentials: credentials
})
applied = Ecto.Changeset.get_change(changeset, :credentials)
assert applied["api_key"] == api_key
end
end
end
describe "property-based: tab parameter handling" do
property "invalid tab params render without crashing", %{
conn: conn,
user: user,
organization: org
} do
check all(
tab <- string(:alphanumeric, min_length: 1, max_length: 50),
tab not in ["general", "snmp", "mikrotik", "agents", "integrations", "members"],
max_runs: 15
) do
{:ok, view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings?tab=#{tab}")
# Should render without crashing — page header is always visible
assert html =~ "Organization Settings"
# Should not crash or show error page
assert render(view) =~ "Organization Settings"
end
end
property "valid tabs render without error", %{conn: conn, user: user, organization: org} do
check all(
tab <- member_of(["general", "snmp", "agents", "integrations"]),
max_runs: 10
) do
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings?tab=#{tab}")
assert html =~ "Organization Settings"
end
end
end
end