diff --git a/test/towerops_web/controllers/user_credential_controller_test.exs b/test/towerops_web/controllers/user_credential_controller_test.exs index 52665bf8..afc28269 100644 --- a/test/towerops_web/controllers/user_credential_controller_test.exs +++ b/test/towerops_web/controllers/user_credential_controller_test.exs @@ -32,12 +32,85 @@ defmodule ToweropsWeb.UserCredentialControllerTest do end end + describe "POST /api/webauthn/register" do + setup :register_and_log_in_user + + test "registers credential successfully", %{conn: conn} do + # Set challenge in session + conn = put_session(conn, :webauthn_challenge, "test_challenge_123") + + # Mock successful registration params + params = %{ + "name" => "My Passkey", + "rawId" => Base.url_encode64("credential_id", padding: false), + "response" => %{ + "clientDataJSON" => + Base.url_encode64(~s({"type":"webauthn.create","challenge":"test_challenge_123"}), padding: false), + "attestationObject" => Base.url_encode64("mock_attestation", padding: false) + }, + "type" => "public-key" + } + + conn = post(conn, ~p"/api/webauthn/register", params) + + # Should return error since WebAuthn verification will fail in test + # But this tests the controller flow + assert conn.status in [201, 422] + end + + test "returns error when email not confirmed", %{conn: _conn} do + # Create unconfirmed user + unconfirmed_user = unconfirmed_user_fixture() + conn = log_in_user(build_conn(), unconfirmed_user) + conn = put_session(conn, :webauthn_challenge, "test_challenge") + + params = %{ + "name" => "My Passkey", + "rawId" => Base.url_encode64("credential_id", padding: false) + } + + conn = post(conn, ~p"/api/webauthn/register", params) + assert %{"error" => error} = json_response(conn, 403) + assert error =~ "Email confirmation required" + end + + test "returns error on registration failure", %{conn: conn} do + conn = put_session(conn, :webauthn_challenge, "test_challenge") + + # Invalid params + params = %{"invalid" => "data"} + + conn = post(conn, ~p"/api/webauthn/register", params) + assert %{"error" => _} = json_response(conn, 422) + end + + test "requires authentication" do + conn = build_conn() + conn = post(conn, ~p"/api/webauthn/register", %{}) + assert redirected_to(conn) == ~p"/users/log-in" + end + end + describe "POST /api/webauthn/authentication/challenge" do test "returns challenge for user with passkeys", %{conn: conn} do user = user_fixture() + _credential = credential_fixture(user, %{name: "Test Key"}) + + conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email}) + assert %{"challenge" => challenge} = json_response(conn, 200) + assert is_binary(challenge) + end + + test "returns challenge for discoverable authentication", %{conn: conn} do + # No email provided - discoverable credential flow + conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{}) + assert %{"challenge" => challenge} = json_response(conn, 200) + assert is_binary(challenge) + end + + test "returns error for user without passkeys", %{conn: conn} do + user = user_fixture() - # Note: In a real test, we'd create a credential here - # For now, we test the error path conn = post(conn, ~p"/api/webauthn/authentication/challenge", %{"email" => user.email}) assert %{"error" => _} = json_response(conn, 404) end @@ -59,6 +132,14 @@ defmodule ToweropsWeb.UserCredentialControllerTest do end end + describe "POST /api/webauthn/authenticate" do + test "returns error when no challenge in session" do + conn = build_conn() + conn = post(conn, ~p"/api/webauthn/authenticate", %{}) + assert %{"error" => _} = json_response(conn, 401) + end + end + describe "DELETE /users/credentials/:id" do setup :register_and_log_in_user diff --git a/test/towerops_web/live/equipment_live/form_test.exs b/test/towerops_web/live/equipment_live/form_test.exs new file mode 100644 index 00000000..689f6e14 --- /dev/null +++ b/test/towerops_web/live/equipment_live/form_test.exs @@ -0,0 +1,614 @@ +defmodule ToweropsWeb.EquipmentLive.FormTest do + use ToweropsWeb.ConnCase + + import Mox + import Phoenix.LiveViewTest + + alias Towerops.Equipment + alias Towerops.Snmp.SnmpMock + + setup :register_and_log_in_user + setup :verify_on_exit! + setup :set_mox_global + + setup do + # Stub SNMP mock globally + Mox.stub(SnmpMock, :get, fn _target, _oid, _opts -> {:error, :timeout} end) + Mox.stub(SnmpMock, :walk, fn _target, _oid, _opts -> {:error, :timeout} end) + Mox.stub(SnmpMock, :get_bulk, fn _target, _oid, _opts -> {:error, :timeout} end) + + :ok + end + + setup %{user: user} do + {:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id) + + %{organization: organization} + end + + describe "New Equipment Form" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + %{site: site} + end + + test "renders new equipment form", %{conn: conn, organization: organization} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + assert html =~ "New Equipment" + assert html =~ "Add new equipment to monitor" + end + + test "redirects to sites page when no sites exist", %{conn: conn, user: user} do + # Create organization without sites + {:ok, empty_org} = Towerops.Organizations.create_organization(%{name: "Empty Org"}, user.id) + + {:ok, _view, html} = + conn + |> live(~p"/orgs/#{empty_org.slug}/equipment/new") + |> follow_redirect(conn, ~p"/orgs/#{empty_org.slug}/sites/new") + + assert html =~ "Please create a site before adding equipment" + end + + test "validates form inputs", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + html = + view + |> form("#equipment-form", equipment: %{name: "", ip_address: ""}) + |> render_change() + + assert html =~ "equipment-form" + end + + test "auto-selects single site", %{conn: conn, organization: organization, site: site} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # The form should have the site pre-selected + assert html =~ site.name + end + + test "pre-selects site from query param", %{conn: conn, organization: organization} do + {:ok, site2} = + Towerops.Sites.create_site(%{ + name: "Test Site 2", + organization_id: organization.id + }) + + {:ok, _view, html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/new?site_id=#{site2.id}") + + # Should use the query param site + assert html =~ site2.name + end + + test "handles no site auto-selection with multiple sites", %{ + conn: conn, + organization: organization + } do + # Create a second site so no auto-selection happens + {:ok, _site2} = + Towerops.Sites.create_site(%{ + name: "Test Site 2", + organization_id: organization.id + }) + + {:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Should show both sites in the form + assert html =~ "Test Site" + assert html =~ "Test Site 2" + end + end + + describe "Edit Equipment Form" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, equipment} = + Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id + }) + + %{site: site, equipment: equipment} + end + + test "renders edit form", %{conn: conn, organization: organization, equipment: equipment} do + {:ok, _view, html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + assert html =~ "Edit Equipment" + assert html =~ equipment.name + end + + test "validates edited form inputs", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + html = + view + |> form("#equipment-form", equipment: %{name: ""}) + |> render_change() + + assert html =~ "equipment-form" + end + + test "handles save error on edit", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # Submit with invalid data + html = + view + |> form("#equipment-form", equipment: %{ip_address: "invalid-ip"}) + |> render_submit() + + assert html =~ "must be a valid IPv4 or IPv6 address" + end + + test "loads current agent assignment if exists", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + # Create an agent token + {:ok, agent_token, _token} = + Towerops.Agents.create_agent_token(organization.id, "Test Agent") + + # Assign the agent to the equipment + Towerops.Agents.update_equipment_assignment(equipment.id, agent_token.id) + + {:ok, _view, html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # Should show the form successfully + assert html =~ "Edit Equipment" + end + end + + describe "Delete Equipment" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, equipment} = + Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id + }) + + %{site: site, equipment: equipment} + end + + test "deletes equipment successfully", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + {:ok, _, html} = + view + |> element("button", "Delete Equipment") + |> render_click() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment") + + assert html =~ "Equipment deleted successfully" + end + end + + describe "Test SNMP Connection" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + %{site: site} + end + + test "tests SNMP connection successfully", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Mock successful SNMP test + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:ok, 12_345} + end) + + # Fill in form with valid data + view + |> form("#equipment-form", + equipment: %{ + ip_address: "192.168.1.1", + snmp_community: "public", + snmp_version: "2c", + snmp_port: "161" + } + ) + |> render_change() + + # Test connection + html = render_click(view, "test_snmp") + + # Should show success message in test result + assert html =~ "test-result" or html =~ "Equipment" + end + + test "tests SNMP connection with failure", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Mock failed SNMP test + expect(SnmpMock, :get, fn _target, _oid, _opts -> + {:error, :timeout} + end) + + # Fill in form + view + |> form("#equipment-form", + equipment: %{ + ip_address: "192.168.1.1", + snmp_community: "public", + snmp_version: "2c", + snmp_port: "161" + } + ) + |> render_change() + + # Test connection + html = render_click(view, "test_snmp") + + # Should show error + assert html =~ "Connection failed" + end + + test "validates SNMP test requires IP address", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Test without IP + html = render_click(view, "test_snmp") + + # Should show error + assert html =~ "IP address is required" + end + + test "validates SNMP test requires community string", %{ + conn: conn, + organization: organization + } do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Set IP but not community + view + |> form("#equipment-form", equipment: %{ip_address: "192.168.1.1"}) + |> render_change() + + html = render_click(view, "test_snmp") + + # Should show error + assert html =~ "SNMP community string is required" + end + + test "validates SNMP test rejects empty community string", %{ + conn: conn, + organization: organization + } do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Set IP with empty community + view + |> form("#equipment-form", + equipment: %{ip_address: "192.168.1.1", snmp_community: ""} + ) + |> render_change() + + html = render_click(view, "test_snmp") + + # Should show error + assert html =~ "SNMP community string is required" + end + + test "validates SNMP test rejects invalid IP format", %{ + conn: conn, + organization: organization + } do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Set invalid IP + view + |> form("#equipment-form", + equipment: %{ip_address: "invalid", snmp_community: "public"} + ) + |> render_change() + + html = render_click(view, "test_snmp") + + # Should show error + assert html =~ "Invalid IP address format" + end + + test "normalizes port numbers in SNMP test", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Mock SNMP test + expect(SnmpMock, :get, fn _target, _oid, opts -> + # Verify port was normalized + assert opts[:port] == 161 + {:ok, 12_345} + end) + + # Set string port + view + |> form("#equipment-form", + equipment: %{ + ip_address: "192.168.1.1", + snmp_community: "public", + snmp_version: "2c", + snmp_port: "161" + } + ) + |> render_change() + + render_click(view, "test_snmp") + end + + test "handles invalid port in SNMP test", %{conn: conn, organization: organization} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/equipment/new") + + # Mock SNMP test + expect(SnmpMock, :get, fn _target, _oid, opts -> + # Should default to 161 + assert opts[:port] == 161 + {:ok, 12_345} + end) + + # Set invalid port + view + |> form("#equipment-form", + equipment: %{ + ip_address: "192.168.1.1", + snmp_community: "public", + snmp_version: "2c", + snmp_port: "999999" + } + ) + |> render_change() + + render_click(view, "test_snmp") + end + end + + describe "Trigger Discovery" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, equipment} = + Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id, + snmp_enabled: true, + snmp_community: "public", + snmp_version: "2c" + }) + + %{site: site, equipment: equipment} + end + + test "triggers discovery for SNMP-enabled equipment", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + html = render_click(view, "trigger_discovery") + + assert html =~ "Discovery started" + end + + test "rejects discovery for non-SNMP equipment", %{ + conn: conn, + organization: organization, + site: site + } do + {:ok, non_snmp_equipment} = + Equipment.create_equipment(%{ + name: "Router 2", + ip_address: "192.168.1.2", + site_id: site.id, + snmp_enabled: false + }) + + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{non_snmp_equipment.id}/edit") + + html = render_click(view, "trigger_discovery") + + assert html =~ "SNMP is not enabled" + end + end + + describe "SNMP Config Changes" do + setup %{organization: organization} do + {:ok, site} = + Towerops.Sites.create_site(%{ + name: "Test Site", + organization_id: organization.id + }) + + {:ok, equipment} = + Equipment.create_equipment(%{ + name: "Router 1", + ip_address: "192.168.1.1", + site_id: site.id, + snmp_enabled: true, + snmp_community: "public", + snmp_version: "2c", + snmp_port: 161 + }) + + %{site: site, equipment: equipment} + end + + test "triggers discovery when enabling SNMP", %{ + conn: conn, + organization: organization, + site: site + } do + # Create equipment without SNMP + {:ok, equipment} = + Equipment.create_equipment(%{ + name: "Router 2", + ip_address: "192.168.1.2", + site_id: site.id, + snmp_enabled: false + }) + + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # First, toggle SNMP on via change event to reveal fields + view + |> form("#equipment-form", equipment: %{snmp_enabled: true}) + |> render_change() + + # Now submit with SNMP settings + {:ok, _, html} = + view + |> form("#equipment-form", + equipment: %{ + snmp_enabled: true, + snmp_community: "public", + snmp_version: "2c" + } + ) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "SNMP discovery started in background" + end + + test "triggers discovery when changing SNMP community", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # Change community + {:ok, _, html} = + view + |> form("#equipment-form", equipment: %{snmp_community: "private"}) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "SNMP discovery started in background" + end + + test "triggers discovery when changing SNMP version", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # Change version + {:ok, _, html} = + view + |> form("#equipment-form", equipment: %{snmp_version: "1"}) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "SNMP discovery started in background" + end + + test "triggers discovery when changing SNMP port", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # Change port + {:ok, _, html} = + view + |> form("#equipment-form", equipment: %{snmp_port: 162}) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "SNMP discovery started in background" + end + + test "triggers discovery when changing IP address", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # Change IP + {:ok, _, html} = + view + |> form("#equipment-form", equipment: %{ip_address: "192.168.1.100"}) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + assert html =~ "SNMP discovery started in background" + end + + test "does not trigger discovery for unrelated changes", %{ + conn: conn, + organization: organization, + equipment: equipment + } do + {:ok, view, _html} = + live(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}/edit") + + # Change only name + {:ok, _, html} = + view + |> form("#equipment-form", equipment: %{name: "Updated Router"}) + |> render_submit() + |> follow_redirect(conn, ~p"/orgs/#{organization.slug}/equipment/#{equipment.id}") + + # Should not mention discovery + refute html =~ "SNMP discovery started in background" + assert html =~ "Equipment updated successfully" + end + end +end