- Wrap agent name + icon inside the offline amber badge so it's clear what is offline on the device detail page - Add "Agent:" label before the agent indicator for all states - Fix TimeSeriesTest deadlock: switch to async: false since check_results is a TimescaleDB hypertable and concurrent chunk creation deadlocks - Fix unicode property test: String.length/1 counts grapheme clusters, not code points; combining chars can make a 2-codepoint string have only 1 grapheme, legitimately failing the min: 2 validation Reviewed-on: graham/towerops-web#83
390 lines
13 KiB
Elixir
390 lines
13 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
|
|
{:ok, view, _html} =
|
|
conn
|
|
|> log_in_user(user)
|
|
|> live(~p"/orgs/#{org.slug}/settings")
|
|
|
|
html = render_click(view, "apply_snmp_to_all")
|
|
assert html =~ "Applied SNMP configuration to"
|
|
end
|
|
|
|
test "applies default agent to all devices", %{conn: conn, user: user, organization: org} do
|
|
{:ok, view, _html} =
|
|
conn
|
|
|> log_in_user(user)
|
|
|> live(~p"/orgs/#{org.slug}/settings")
|
|
|
|
html = render_click(view, "apply_agent_to_all")
|
|
assert html =~ "Applied default agent to"
|
|
end
|
|
|
|
test "toggles default organization", %{conn: conn, user: user, organization: org} do
|
|
{:ok, view, _html} =
|
|
conn
|
|
|> log_in_user(user)
|
|
|> live(~p"/orgs/#{org.slug}/settings")
|
|
|
|
html = render_click(view, "toggle_default_org")
|
|
|
|
# Either sets as default or shows already default message
|
|
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
|