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