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". """ use MvWeb.ConnCase, async: true import Phoenix.LiveViewTest import Ecto.Query alias Mv.Membership alias Mv.Repo 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 :enable_join_form_for_test @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 count_join_requests() == count_before + 1 assert view |> element("[data-testid='join-success-message']") |> has_element?() 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 # Reset rate limit state so this test is independent of others (same key in test) try do :ets.delete_all_objects(MvWeb.JoinRateLimit) rescue ArgumentError -> :ok end enable_join_form(true) # Set allowlist so form has email, first_name, last_name {:ok, settings} = Membership.get_settings() Membership.update_settings(settings, %{ join_form_field_ids: ["email", "first_name", "last_name"], join_form_field_required: %{"email" => true, "first_name" => false, "last_name" => false} }) # Rely on test config: join rate limit low (e.g. 2 per window) 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 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 Repo.one(from j in "join_requests", select: count(j.id)) || 0 end end