373 lines
11 KiB
Elixir
373 lines
11 KiB
Elixir
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
|