diff --git a/test/towerops_web/live/org/settings_live_test.exs b/test/towerops_web/live/org/settings_live_test.exs index 96c063b8..7b3cad68 100644 --- a/test/towerops_web/live/org/settings_live_test.exs +++ b/test/towerops_web/live/org/settings_live_test.exs @@ -1,11 +1,16 @@ 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 + alias Towerops.Organizations.Organization + setup do user = user_fixture() organization = organization_fixture(user.id) @@ -188,4 +193,184 @@ defmodule ToweropsWeb.Org.SettingsLiveTest do 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(:utf8, min_length: 2, max_length: 100), max_runs: 50 do + changeset = Organizations.change_organization(org, %{name: name}) + # Should not have a name error (length is valid) + refute Keyword.has_key?(changeset.errors, :name) + 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), constant(nil)]), + 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 chars", %{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 + }) + + if String.length(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"], + 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 default to general gracefully", %{ + 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"], + max_runs: 15 do + {:ok, view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings?tab=#{tab}") + + # Should render without crashing — shows the general tab content + assert html =~ "Organization Settings" + # The general tab content (org name field) should be visible + assert html =~ "Organization Name" + # 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