From 6d8c3f932b90e6607b7177aa6fbe09dc82833db0 Mon Sep 17 00:00:00 2001 From: Graham McIntie Date: Sat, 14 Feb 2026 10:23:30 -0600 Subject: [PATCH] test: add stream_data property tests for org settings and integrations - Organization changeset: name length, SNMP port range, version enum, SNMPv3 fields - Integration changeset: sync interval validation, provider enum - Settings LiveView: tab routing with garbage params, arbitrary name/port/community input - All capped at max_runs: 10-50 for fast execution --- .../integration_property_test.exs | 56 ++++++++ .../organization_property_test.exs | 127 ++++++++++++++++++ .../live/org/settings_live_property_test.exs | 115 ++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 test/towerops/integrations/integration_property_test.exs create mode 100644 test/towerops/organizations/organization_property_test.exs create mode 100644 test/towerops_web/live/org/settings_live_property_test.exs diff --git a/test/towerops/integrations/integration_property_test.exs b/test/towerops/integrations/integration_property_test.exs new file mode 100644 index 00000000..ffc28bbc --- /dev/null +++ b/test/towerops/integrations/integration_property_test.exs @@ -0,0 +1,56 @@ +defmodule Towerops.Integrations.IntegrationPropertyTest do + @moduledoc "Property-based tests for Integration changeset validation." + use Towerops.DataCase + use ExUnitProperties + + alias Towerops.Integrations.Integration + + describe "sync_interval_minutes validation" do + property "accepts positive integers" do + check all interval <- positive_integer(), max_runs: 50 do + changeset = + Integration.changeset(%Integration{}, %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + sync_interval_minutes: interval + }) + + refute Keyword.has_key?(changeset.errors, :sync_interval_minutes) + end + end + + property "rejects zero and negative values" do + check all interval <- integer(-1000..0), max_runs: 30 do + changeset = + Integration.changeset(%Integration{}, %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + sync_interval_minutes: interval + }) + |> Map.put(:action, :insert) + + assert Keyword.has_key?(changeset.errors, :sync_interval_minutes) + end + end + end + + describe "provider validation" do + property "rejects arbitrary strings as provider" do + valid = MapSet.new(["preseem", "gaiia"]) + + check all provider <- string(:alphanumeric, min_length: 1, max_length: 20), + not MapSet.member?(valid, provider), + max_runs: 50 do + changeset = + Integration.changeset(%Integration{}, %{ + organization_id: Ecto.UUID.generate(), + provider: provider, + sync_interval_minutes: 10 + }) + |> Map.put(:action, :insert) + + assert Keyword.has_key?(changeset.errors, :provider) + end + end + end +end diff --git a/test/towerops/organizations/organization_property_test.exs b/test/towerops/organizations/organization_property_test.exs new file mode 100644 index 00000000..952caf88 --- /dev/null +++ b/test/towerops/organizations/organization_property_test.exs @@ -0,0 +1,127 @@ +defmodule Towerops.Organizations.OrganizationPropertyTest do + @moduledoc "Property-based tests for Organization changeset validation." + use Towerops.DataCase + use ExUnitProperties + + alias Towerops.Organizations.Organization + + describe "name validation" do + property "accepts valid names (2-100 printable chars)" do + check all name <- string(:printable, min_length: 2, max_length: 100), max_runs: 50 do + changeset = Organization.changeset(%Organization{}, %{name: name}) + # Name field itself should be valid (other fields may cause errors) + refute Keyword.has_key?(changeset.errors, :name) + end + end + + property "rejects names shorter than 2 characters" do + check all name <- string(:printable, min_length: 0, max_length: 1), max_runs: 25 do + changeset = + Organization.changeset(%Organization{}, %{name: name}) + |> Map.put(:action, :insert) + + assert Keyword.has_key?(changeset.errors, :name) + end + end + + property "rejects names longer than 100 characters" do + check all name <- string(:ascii, min_length: 101, max_length: 300), max_runs: 25 do + changeset = + Organization.changeset(%Organization{}, %{name: name}) + |> Map.put(:action, :insert) + + assert {:name, _} = List.keyfind(changeset.errors, :name, 0) + end + end + end + + describe "SNMP port validation" do + property "accepts valid ports (1-65535)" do + check all port <- integer(1..65_535), max_runs: 50 do + changeset = Organization.changeset(%Organization{}, %{name: "Test Org", snmp_port: port}) + refute Keyword.has_key?(changeset.errors, :snmp_port) + end + end + + property "rejects ports outside valid range" do + check all port <- one_of([integer(-1000..0), integer(65_536..100_000)]), max_runs: 50 do + changeset = + Organization.changeset(%Organization{}, %{name: "Test Org", snmp_port: port}) + |> Map.put(:action, :insert) + + assert Keyword.has_key?(changeset.errors, :snmp_port) + end + end + end + + describe "SNMP version validation" do + property "rejects arbitrary strings as SNMP version" do + valid = MapSet.new(["1", "2c", "3"]) + + check all version <- string(:alphanumeric, min_length: 1, max_length: 10), + not MapSet.member?(valid, version), + max_runs: 50 do + changeset = + Organization.changeset(%Organization{}, %{name: "Test Org", snmp_version: version}) + |> Map.put(:action, :insert) + + assert Keyword.has_key?(changeset.errors, :snmp_version) + end + end + + test "accepts valid SNMP versions" do + for version <- ["1", "2c", "3"] do + changeset = Organization.changeset(%Organization{}, %{name: "Test Org", snmp_version: version}) + refute Keyword.has_key?(changeset.errors, :snmp_version) + end + end + end + + describe "SNMPv3 fields" do + property "v3 requires username regardless of input" do + check all username <- one_of([constant(nil), constant("")]), + max_runs: 10 do + changeset = + Organization.changeset(%Organization{}, %{ + name: "Test Org", + snmp_version: "3", + snmpv3_username: username + }) + |> Map.put(:action, :insert) + + assert Keyword.has_key?(changeset.errors, :snmpv3_username) + end + end + + property "v3 auth password must be at least 8 chars when auth protocol set" do + check all password <- string(:ascii, min_length: 1, max_length: 7), max_runs: 30 do + changeset = + Organization.changeset(%Organization{}, %{ + name: "Test Org", + snmp_version: "3", + snmpv3_username: "testuser", + snmpv3_auth_protocol: "SHA-256", + snmpv3_auth_password: password + }) + |> Map.put(:action, :insert) + + assert Keyword.has_key?(changeset.errors, :snmpv3_auth_password) + end + end + end + + describe "MikroTik port validation" do + property "accepts valid MikroTik ports" do + check all port <- integer(1..65_535), max_runs: 50 do + changeset = + Organization.changeset(%Organization{}, %{ + name: "Test Org", + mikrotik_enabled: true, + mikrotik_port: port + }) + + refute Keyword.has_key?(changeset.errors, :mikrotik_port) + end + end + end +end diff --git a/test/towerops_web/live/org/settings_live_property_test.exs b/test/towerops_web/live/org/settings_live_property_test.exs new file mode 100644 index 00000000..b877eef0 --- /dev/null +++ b/test/towerops_web/live/org/settings_live_property_test.exs @@ -0,0 +1,115 @@ +defmodule ToweropsWeb.Org.SettingsLivePropertyTest do + @moduledoc "Property-based tests for org settings tab routing and input handling." + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + use ExUnitProperties + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{user: user, organization: organization} + end + + describe "tab parameter handling" do + @valid_tabs ["general", "snmp", "mikrotik", "agents", "integrations"] + + property "invalid tab params default to general tab gracefully", %{ + conn: conn, + user: user, + organization: org + } do + check all tab <- string(:alphanumeric, min_length: 1, max_length: 30), + tab not in @valid_tabs, + max_runs: 10 do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings?tab=#{tab}") + + # Should render without crashing — defaults to general tab content + assert html =~ "Organization Settings" + end + end + + test "all valid tabs render without error", %{conn: conn, user: user, organization: org} do + for tab <- @valid_tabs do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings?tab=#{tab}") + + assert html =~ "Organization Settings" + end + end + end + + describe "organization name input" do + property "changeset validation handles arbitrary name input", %{ + conn: conn, + user: user, + organization: org + } do + check all name <- string(:printable, max_length: 200), max_runs: 15 do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings") + + # Should not crash on any printable input + html = + view + |> form("#organization-form", organization: %{name: name}) + |> render_change() + + assert html =~ "Organization Settings" + end + end + end + + describe "SNMP config input" do + property "SNMP port handles arbitrary numeric input", %{ + conn: conn, + user: user, + organization: org + } do + check all port <- one_of([integer(-100..100_000), constant(0)]), max_runs: 15 do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings?tab=snmp") + + # Should not crash + html = + view + |> form("#organization-form", organization: %{snmp_port: port}) + |> render_change() + + assert html =~ "Organization Settings" + end + end + + property "community string handles arbitrary input", %{ + conn: conn, + user: user, + organization: org + } do + check all community <- string(:printable, max_length: 100), max_runs: 15 do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings?tab=snmp") + + html = + view + |> form("#organization-form", organization: %{snmp_community: community}) + |> render_change() + + assert html =~ "Organization Settings" + end + end + end +end