defmodule MvWeb.JoinLiveTest do @moduledoc """ Tests for the public join page (Subtask 4: Public join page and anti-abuse). Covers: public path /join (unauthenticated 200), 404 when join disabled, submit creates JoinRequest and shows success copy, honeypot prevents create, rate limiting rejects excess submits. Uses unauthenticated conn; no User/Member. Honeypot: form param `"website"` (legit-sounding name per best practice; not "honeypot"). Field is hidden via CSS class in app.css (off-screen, no inline styles), type="text". """ # async: false → shared sandbox; all processes (including LiveView) share the DB connection. use MvWeb.ConnCase, async: false import Phoenix.LiveViewTest alias Mv.Membership alias Mv.Membership.JoinRequest describe "GET /join" do @tag role: :unauthenticated test "unauthenticated GET /join returns 200 when join form is enabled", %{conn: conn} do enable_join_form(true) conn = get(conn, "/join") assert conn.status == 200 end @tag role: :unauthenticated test "unauthenticated GET /join returns 404 when join form is disabled", %{conn: conn} do enable_join_form(false) conn = get(conn, "/join") assert conn.status == 404 end end describe "submit join form" do setup context do reset_rate_limiter() enable_join_form_for_test(context) end @tag role: :unauthenticated test "submit with valid allowlist data creates one JoinRequest and shows success copy", %{ conn: conn } do count_before = count_join_requests() {:ok, view, _html} = live(conn, "/join") view |> form("#join-form", %{ "email" => "newuser#{System.unique_integer([:positive])}@example.com", "first_name" => "Jane", "last_name" => "Doe", "website" => "" }) |> render_submit() assert_eventually(fn -> count_join_requests() == count_before + 1 end) assert_eventually(fn -> view |> element("[data-testid='join-success-message']") |> has_element?() end) assert render(view) =~ "saved your details" assert render(view) =~ "click the link" end @tag role: :unauthenticated test "submit with honeypot filled does not create JoinRequest but shows same success copy", %{ conn: conn } do count_before = count_join_requests() {:ok, view, _html} = live(conn, "/join") view |> form("#join-form", %{ "email" => "bot#{System.unique_integer([:positive])}@example.com", "first_name" => "Bot", "last_name" => "User", "website" => "filled-by-bot" }) |> render_submit() assert count_join_requests() == count_before assert view |> element("[data-testid='join-success-message']") |> has_element?() end @tag role: :unauthenticated @tag :slow test "after rate limit exceeded submit returns 429 or error and no new JoinRequest", %{ conn: conn } do base_email = "ratelimit#{System.unique_integer([:positive])}@example.com" count_before = count_join_requests() sandbox = conn.private[:ecto_sandbox] # Exhaust limit with 2 valid submits (each needs a fresh session because form disappears after submit) for i <- 0..1 do c = build_conn() |> Phoenix.ConnTest.init_test_session(%{}) |> Plug.Conn.put_private(:ecto_sandbox, sandbox) {:ok, view, _} = live(c, "/join") view |> form("#join-form", %{ "email" => "#{i}-#{base_email}", "first_name" => "User", "last_name" => "Test", "website" => "" }) |> render_submit() end # Next submit (new session) should be rate limited c = build_conn() |> Phoenix.ConnTest.init_test_session(%{}) |> Plug.Conn.put_private(:ecto_sandbox, sandbox) {:ok, view, _} = live(c, "/join") result = view |> form("#join-form", %{ "email" => "third-#{base_email}", "first_name" => "Third", "last_name" => "User", "website" => "" }) |> render_submit() assert count_join_requests() == count_before + 2 assert result =~ "rate limit" or String.downcase(result) =~ "too many" or result =~ "429" end end describe "join field labels" do @tag role: :unauthenticated test "renders custom field name as label for custom field IDs", %{conn: conn} do {:ok, settings} = Membership.get_settings() system_actor = Mv.Helpers.SystemActor.get_system_actor() {:ok, custom_field} = Membership.create_custom_field( %{ name: "Preferred Pronouns", value_type: :string }, actor: system_actor ) {:ok, _} = Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", custom_field.id], join_form_field_required: %{"email" => true, custom_field.id => false} }) {:ok, view, _html} = live(conn, "/join") assert has_element?( view, "label[for='join-field-#{custom_field.id}'] .label-text", custom_field.name ) end end describe "join field input types" do @tag role: :unauthenticated test "renders boolean custom field as checkbox input", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() {:ok, settings} = Membership.get_settings() {:ok, boolean_field} = Membership.create_custom_field( %{ name: "Subscribe to newsletter", value_type: :boolean }, actor: system_actor ) {:ok, _} = Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", boolean_field.id], join_form_field_required: %{"email" => true, boolean_field.id => false} }) {:ok, view, _html} = live(conn, "/join") assert has_element?(view, "#join-form") assert has_element?( view, "input#join-field-#{boolean_field.id}[name='#{boolean_field.id}']" ) assert has_element?(view, "input#join-field-#{boolean_field.id}[type='checkbox']") refute has_element?(view, "input#join-field-#{boolean_field.id}[type='text']") end @tag role: :unauthenticated test "renders typed custom fields with matching HTML input types", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() {:ok, settings} = Membership.get_settings() {:ok, integer_field} = Membership.create_custom_field(%{name: "Lucky number", value_type: :integer}, actor: system_actor ) {:ok, date_field} = Membership.create_custom_field(%{name: "Birth date", value_type: :date}, actor: system_actor ) {:ok, email_field} = Membership.create_custom_field(%{name: "Secondary email", value_type: :email}, actor: system_actor ) {:ok, _} = Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", integer_field.id, date_field.id, email_field.id], join_form_field_required: %{ "email" => true, integer_field.id => false, date_field.id => false, email_field.id => false } }) {:ok, view, _html} = live(conn, "/join") assert has_element?(view, "input#join-field-#{integer_field.id}[type='number']") assert has_element?(view, "input#join-field-#{date_field.id}[type='date']") assert has_element?(view, "input#join-field-#{email_field.id}[type='email']") end @tag role: :unauthenticated test "renders standard date member fields with date input type", %{conn: conn} do {:ok, settings} = Membership.get_settings() {:ok, _} = Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "join_date"], join_form_field_required: %{"email" => true, "join_date" => false} }) {:ok, view, _html} = live(conn, "/join") assert has_element?(view, "input#join-field-join_date[type='date']") refute has_element?(view, "input#join-field-join_date[type='text']") end end describe "submit join form with typed custom fields" do setup do reset_rate_limiter() :ok end @tag role: :unauthenticated test "persists checked boolean custom field and ignores non-allowlisted field", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor() {:ok, settings} = Membership.get_settings() {:ok, boolean_field} = Membership.create_custom_field( %{ name: "Receive announcements", value_type: :boolean }, actor: system_actor ) {:ok, _} = Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", boolean_field.id], join_form_field_required: %{"email" => true, boolean_field.id => false} }) count_before = count_join_requests() {:ok, view, _html} = live(conn, "/join") view |> element("#join-form") |> render_submit(%{ "email" => "typed#{System.unique_integer([:positive])}@example.com", "website" => "", boolean_field.id => "on", "not_allowlisted" => "should-not-be-persisted" }) assert_eventually(fn -> count_join_requests() == count_before + 1 end) assert_eventually(fn -> view |> element("[data-testid='join-success-message']") |> has_element?() end) form_data = latest_join_request_form_data() assert Map.get(form_data, boolean_field.id) == "on" refute Map.has_key?(form_data, "not_allowlisted") end end defp enable_join_form(enabled) do {:ok, settings} = Membership.get_settings() {:ok, _} = Membership.update_settings(settings, %{join_form_enabled: enabled}) end defp enable_join_form_for_test(_context) do {:ok, settings} = Membership.get_settings() {:ok, _} = Membership.update_settings(settings, %{ join_form_enabled: true, join_form_field_ids: ["email", "first_name", "last_name"], join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} }) :ok end defp count_join_requests do case Ash.count(JoinRequest, domain: Membership, authorize?: false) do {:ok, count} -> count _ -> 0 end end defp latest_join_request_form_data do query = JoinRequest |> Ash.Query.sort(inserted_at: :desc) |> Ash.Query.limit(1) case Ash.read(query, domain: Membership, authorize?: false) do {:ok, [request]} -> request.form_data || %{} _ -> %{} end end defp assert_eventually(fun, timeout_ms \\ 1500) when is_function(fun, 0) do deadline = System.monotonic_time(:millisecond) + timeout_ms do_assert_eventually(fun, deadline) end defp do_assert_eventually(fun, deadline) do if fun.() do true else if System.monotonic_time(:millisecond) < deadline do Process.sleep(25) do_assert_eventually(fun, deadline) else assert fun.() end end end defp reset_rate_limiter do :ets.delete_all_objects(MvWeb.JoinRateLimit) rescue ArgumentError -> :ok end end