add public join form #466

Merged
simon merged 3 commits from feature/308-web-form into main 2026-03-10 23:08:27 +01:00
19 changed files with 547 additions and 15 deletions
Showing only changes of commit f1d0526209 - Show all commits

View file

@ -99,6 +99,19 @@
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session] { display: contents }
/* Honeypot: off-screen and minimal size so bots fill it, humans never see it (best practice) */
.join-form-helper {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.join-form-helper .join-form-helper-input {
position: absolute;
left: -9999px;
}
/* WCAG 1.4.12 Text Spacing: allow user stylesheets to adjust text spacing in popovers.
Popover content (e.g. from DaisyUI dropdown) must not rely on non-overridable inline
spacing; use inherited values so custom stylesheets can override. */

View file

@ -93,6 +93,9 @@ config :mv, Mv.Mailer, adapter: Swoosh.Adapters.Local
# user confirmation, password reset). Override in config/runtime.exs from ENV.
config :mv, :mail_from, {"Mila", "noreply@example.com"}
# Join form rate limiting (Hammer). scale_ms: window in ms, limit: max submits per window per IP.
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 10
# Configure esbuild (the version is required)
config :esbuild,
version: "0.17.11",

View file

@ -55,3 +55,6 @@ config :mv, :default_locale, "en"
# Enable SQL Sandbox for async LiveView tests
# This flag controls sync vs async behavior in CycleGenerator after_action hooks
config :mv, :sql_sandbox, true
# Join form rate limit: low limit so tests can trigger rate limiting (e.g. 2 per minute)
config :mv, :join_rate_limit, scale_ms: 60_000, limit: 2

View file

@ -9,6 +9,7 @@ defmodule Mv.Application do
alias Mv.Repo
alias Mv.Vereinfacht.SyncFlash
alias MvWeb.Endpoint
alias MvWeb.JoinRateLimit
alias MvWeb.Telemetry
@impl true
@ -18,6 +19,7 @@ defmodule Mv.Application do
children = [
Telemetry,
Repo,
{JoinRateLimit, [clean_period: :timer.minutes(1)]},
{Task.Supervisor, name: Mv.TaskSupervisor},
{DNSCluster, query: Application.get_env(:mv, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: Mv.PubSub},

View file

@ -82,7 +82,25 @@ defmodule MvWeb.Layouts do
</div>
</div>
<% else %>
<!-- Not logged in -->
<!-- Unauthenticated: simple header (logo, club name, language selector; same classes as sidebar header) -->
<header class="flex items-center gap-3 p-4 border-b border-base-300 bg-base-100">
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
<span class="menu-label text-lg font-bold truncate flex-1">
{@club_name}
</span>
<form method="post" action={~p"/set_locale"} class="shrink-0">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<select
name="locale"
onchange="this.form.submit()"
class="select select-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
aria-label={gettext("Select language")}
>
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
</select>
</form>
</header>
<main class="px-4 py-8 sm:px-6">
<div class="mx-auto space-y-4 max-full">
{render_slot(@inner_block)}

View file

@ -12,8 +12,8 @@ defmodule MvWeb.Endpoint do
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
websocket: [connect_info: [:peer_data, session: @session_options]],
longpoll: [connect_info: [:peer_data, session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#

View file

@ -0,0 +1,27 @@
defmodule MvWeb.JoinRateLimit do
@moduledoc """
Rate limiting for the public join form (submit action).
Uses Hammer with ETS backend. Key is derived from client IP so each IP
is limited independently. Config from :mv :join_rate_limit (scale_ms, limit).
"""
use Hammer, backend: :ets
@doc """
Checks if the given key (e.g. client IP) is within rate limit for join form submit.
Returns:
- `:allow` - submission allowed
- `{:deny, _retry_after_ms}` - rate limit exceeded
"""
def check(key) when is_binary(key) do
config = Application.get_env(:mv, :join_rate_limit, [])
scale_ms = Keyword.get(config, :scale_ms, 60_000)
limit = Keyword.get(config, :limit, 10)
case hit(key, scale_ms, limit) do
{:allow, _count} -> :allow
{:deny, retry_after} -> {:deny, retry_after}
end
end
end

View file

@ -0,0 +1,239 @@
defmodule MvWeb.JoinLive do
@moduledoc """
Public join page (unauthenticated). Renders form from allowlist, handles submit
with honeypot and rate limiting; shows success copy after submit.
"""
use MvWeb, :live_view
alias Mv.Membership
alias MvWeb.JoinRateLimit
alias MvWeb.Translations.MemberFields
# Honeypot field name (legitimate-sounding to avoid bot detection)
@honeypot_field "website"
@impl true
def mount(_params, _session, socket) do
allowlist = Membership.get_join_form_allowlist()
join_fields = build_join_fields_with_labels(allowlist)
client_ip = client_ip_from_socket(socket)
socket =
socket
|> assign(:join_fields, join_fields)
|> assign(:submitted, false)
|> assign(:rate_limit_error, nil)
|> assign(:client_ip, client_ip)
|> assign(:honeypot_field, @honeypot_field)
|> assign(:form, to_form(initial_form_params(join_fields)))
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="max-w-xl mx-auto mt-8 space-y-6">
<.header>
{gettext("Become a member")}
</.header>
<p class="text-base-content/80">
{gettext("Please enter your details for the membership application here.")}
</p>
<%= if @submitted do %>
<div data-testid="join-success-message" class="alert alert-success">
<p class="font-medium">
{gettext(
"We have saved your details. To complete your request, please click the link we sent to your email."
)}
</p>
</div>
<% else %>
<.form
for={@form}
id="join-form"
phx-submit="submit"
class="space-y-4"
>
<%= if @rate_limit_error do %>
<div class="alert alert-error">
{@rate_limit_error}
</div>
<% end %>
<%= for field <- @join_fields do %>
<div>
<label for={"join-field-#{field.id}"} class="label">
<span class="label-text">{field.label}{if field.required, do: " *"}</span>
</label>
<input
type={input_type(field.id)}
name={field.id}
id={"join-field-#{field.id}"}
value={@form.params[field.id]}
required={field.required}
class="input input-bordered w-full"
/>
</div>
<% end %>
<%!--
Honeypot (best practice): legit field name "website", type="text", no inline CSS,
hidden via class in app.css (off-screen + 1px). tabindex=-1, autocomplete=off,
aria-hidden so screen readers skip. If filled silent failure (same success UI).
--%>
<div class="join-form-helper" aria-hidden="true">
<label for="join-website" class="sr-only">{gettext("Website")}</label>
<input
type="text"
name={@honeypot_field}
id="join-website"
tabindex="-1"
autocomplete="off"
class="join-form-helper-input"
/>
</div>
<p class="text-sm text-base-content/70">
{gettext(
"By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
)}
</p>
<p class="text-xs text-base-content/60">
{gettext(
"Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
)}
</p>
<div>
<button type="submit" class="btn btn-primary">
{gettext("Submit request")}
</button>
</div>
</.form>
<% end %>
</div>
</Layouts.app>
"""
end
@impl true
def handle_event("submit", params, socket) do
# Honeypot: if filled, treat as bot show same success UI, do not create (silent failure)
honeypot_value = String.trim(params[@honeypot_field] || "")
if honeypot_value != "" do
{:noreply, assign(socket, :submitted, true)}
else
key = "join:#{socket.assigns.client_ip}"
case JoinRateLimit.check(key) do
:allow -> do_submit(socket, params)
{:deny, _retry_after} -> rate_limited_reply(socket, params)
end
end
end
defp do_submit(socket, params) do
case build_submit_attrs(params, socket.assigns.join_fields) do
{:ok, attrs} ->
case Membership.submit_join_request(attrs, actor: nil) do
{:ok, _} -> {:noreply, assign(socket, :submitted, true)}
{:error, _} -> validation_error_reply(socket, params)
end
{:error, message} ->
{:noreply,
socket
|> put_flash(:error, message)
|> assign(:form, to_form(params, as: "join"))}
end
end
defp validation_error_reply(socket, params) do
{:noreply,
socket
|> put_flash(:error, gettext("Please check your entries. Email is required."))
|> assign(:form, to_form(params, as: "join"))}
end
defp rate_limited_reply(socket, params) do
{:noreply,
socket
|> assign(:rate_limit_error, gettext("Too many requests. Please try again later."))
|> assign(:form, to_form(params, as: "join"))}
end
defp build_join_fields_with_labels(allowlist) do
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
Enum.map(allowlist, fn %{id: id, required: required} ->
label =
if id in member_field_strings do
MemberFields.label(String.to_existing_atom(id))
else
gettext("Field")
end
%{id: id, label: label, required: required}
end)
end
defp initial_form_params(join_fields) do
join_fields
|> Enum.map(fn f -> {f.id, ""} end)
|> Map.new()
|> Map.put(@honeypot_field, "")
end
defp input_type("email"), do: "email"
defp input_type(_), do: "text"
defp build_submit_attrs(params, join_fields) do
allowlist_ids = MapSet.new(Enum.map(join_fields, & &1.id))
typed = ["email", "first_name", "last_name"]
email = String.trim(params["email"] || "")
if email == "" do
{:error, gettext("Email is required.")}
else
attrs = %{
email: email,
first_name: optional_param(params, "first_name"),
last_name: optional_param(params, "last_name"),
form_data: %{},
schema_version: 1
}
form_data =
params
|> Enum.filter(fn {key, _} -> key in allowlist_ids and key not in typed end)
|> Map.new()
|> Enum.map(fn {k, v} -> {k, String.trim(to_string(v))} end)
|> Map.new()
attrs = %{attrs | form_data: form_data}
{:ok, attrs}
end
end
defp optional_param(params, key) do
v = params[key]
if is_binary(v), do: String.trim(v), else: nil
end
defp client_ip_from_socket(socket) do
case get_connect_info(socket, :peer_data) do
%{address: address} when is_tuple(address) ->
address |> Tuple.to_list() |> Enum.join(".")
_ ->
"unknown"
end
end
end

View file

@ -107,6 +107,9 @@ defmodule MvWeb.MemberLive.Index do
{:error, _} -> %{member_field_visibility: %{}}
end
# Ensure nested module is loaded (can be missing after code reload in dev if load order changes)
Code.ensure_loaded!(FieldSelection)
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)

View file

@ -80,7 +80,7 @@ defmodule MvWeb.Plugs.CheckPagePermission do
Used by LiveView hook to skip redirect on sign-in etc.
"""
def public_path?(path) when is_binary(path) do
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out", "/join"] or
String.starts_with?(path, "/auth") or
String.starts_with?(path, "/confirm") or
String.starts_with?(path, "/password-reset")

View file

@ -0,0 +1,33 @@
defmodule MvWeb.Plugs.JoinFormEnabled do
@moduledoc """
For GET /join: returns 404 when the join form is disabled in settings.
No-op for other paths.
"""
import Plug.Conn
alias Mv.Membership
def init(opts), do: opts
def call(conn, _opts) do
if join_path?(conn), do: maybe_404(conn), else: conn
end
defp join_path?(conn) do
conn.request_path == "/join" and conn.method == "GET"
end
defp maybe_404(conn) do
case Membership.get_settings() do
{:ok, %{join_form_enabled: true}} -> conn
_ -> send_404(conn)
end
end
defp send_404(conn) do
conn
|> put_resp_content_type("text/html")
|> send_resp(404, "Not Found")
|> halt()
end
end

View file

@ -15,6 +15,7 @@ defmodule MvWeb.Router do
plug :load_from_session
plug :set_locale
plug MvWeb.Plugs.CheckPagePermission
plug MvWeb.Plugs.JoinFormEnabled
end
pipeline :api do
@ -126,6 +127,12 @@ defmodule MvWeb.Router do
overrides: [MvWeb.AuthOverrides, AshAuthentication.Phoenix.Overrides.DaisyUI],
gettext_backend: {MvWeb.Gettext, "auth"}
# Public join page (no auth required)
live_session :public_join,
on_mount: [{MvWeb.LiveUserAuth, :live_user_optional}] do
live "/join", JoinLive, :index
end
# Public join confirmation (double opt-in); /confirm* is already public in CheckPagePermission
get "/confirm_join/:token", JoinConfirmController, :confirm

View file

@ -82,7 +82,8 @@ defmodule Mv.MixProject do
{:ecto_commons, "~> 0.3"},
{:slugify, "~> 1.3"},
{:nimble_csv, "~> 1.0"},
{:imprintor, "~> 0.5.0"}
{:imprintor, "~> 0.5.0"},
{:hammer, "~> 7.0"}
]
end

View file

@ -37,6 +37,7 @@
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"hammer": {:hex, :hammer, "7.2.0", "73113eca87f0fd20a6d3679c1182e8c4c1778266f61de4e9dc8c589dee156c30", [:mix], [], "hexpm", "c50fa865ddfe7b3d4f8a6941f56940679e02a9a1465b00668a95d140b101d828"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},

View file

@ -1792,6 +1792,7 @@ msgid "Email is invalid."
msgstr "E-Mail ist ungültig."
#: lib/mv/membership/import/member_csv.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr "E-Mail ist erforderlich."
@ -3348,6 +3349,7 @@ msgid "Could not save join form settings."
msgstr "Beitrittsformular-Einstellungen konnten nicht gespeichert werden."
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr "Feld"
@ -3401,3 +3403,48 @@ msgstr "Umordnen"
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "Die Reihenfolge der Zeilen bestimmt die Reihenfolge der Felder im Beitrittsformular."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Become a member"
msgstr "Mitglied werden"
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please check your entries. Email is required."
msgstr "Bitte prüfe deine Angaben. E-Mail ist erforderlich."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Submit request"
msgstr "Antrag absenden"
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Too many requests. Please try again later."
msgstr "Zu viele Anfragen. Bitte versuche es später erneut."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We have saved your details. To complete your request, please click the link we sent to your email."
msgstr "Wir haben deine Angaben gespeichert. Um deinen Antrag abzuschließen, klicke bitte auf den Link in der E-Mail, die wir dir geschickt haben."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Website"
msgstr "Webseite"
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please enter your details for the membership application here."
msgstr "Bitte gib hier die Daten für deinen Mitgliedsantrag an."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
msgstr "Deine Angaben werden nur zur Bearbeitung deines Mitgliedsantrags und zur Kontaktaufnahme genutzt. Zur Absicherung gegen Missbrauch verarbeiten wir zusätzlich technische Daten (z. B. IP-Adresse) nur im dafür nötigen Umfang."

View file

@ -514,6 +514,7 @@ msgstr ""
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/sidebar.ex
#, elixir-autogen, elixir-format
msgid "Select language"
@ -1793,6 +1794,7 @@ msgid "Email is invalid."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr ""
@ -3348,6 +3350,7 @@ msgid "Could not save join form settings."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr ""
@ -3401,3 +3404,48 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Become a member"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please check your entries. Email is required."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Submit request"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Too many requests. Please try again later."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We have saved your details. To complete your request, please click the link we sent to your email."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Website (leave empty)"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please enter your details for the membership application here."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
msgstr ""

View file

@ -1793,6 +1793,7 @@ msgid "Email is invalid."
msgstr ""
#: lib/mv/membership/import/member_csv.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Email is required."
msgstr ""
@ -3348,6 +3349,7 @@ msgid "Could not save join form settings."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Field"
msgstr ""
@ -3401,3 +3403,48 @@ msgstr "Reorder"
#, elixir-autogen, elixir-format
msgid "The order of rows determines the field order in the join form."
msgstr "The order of rows determines the field order in the join form."
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Become a member"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please check your entries. Email is required."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Submit request"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Too many requests. Please try again later."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "We have saved your details. To complete your request, please click the link we sent to your email."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Website (leave empty)"
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Please enter your details for the membership application here."
msgstr ""
#: lib/mv_web/live/join_live.ex
#, elixir-autogen, elixir-format
msgid "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."
msgstr "Your details are only used to process your membership application and to contact you. To prevent abuse we also process technical data (e.g. IP address) only as necessary."

View file

@ -125,6 +125,7 @@ defmodule Mv.Membership.JoinRequestTest do
test "submit with non-allowlisted form_data keys does not persist those keys" do
# Allowlist restricts which fields are accepted; extra keys must not be stored.
{:ok, settings} = Membership.get_settings()
Mv.Membership.update_settings(settings, %{
join_form_enabled: true,
join_form_field_ids: ["email", "first_name"],

View file

@ -5,6 +5,9 @@ defmodule MvWeb.JoinLiveTest do
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
@ -44,7 +47,7 @@ defmodule MvWeb.JoinLiveTest do
"email" => "newuser#{System.unique_integer([:positive])}@example.com",
"first_name" => "Jane",
"last_name" => "Doe",
"honeypot" => ""
"website" => ""
})
|> render_submit()
@ -66,7 +69,7 @@ defmodule MvWeb.JoinLiveTest do
"email" => "bot#{System.unique_integer([:positive])}@example.com",
"first_name" => "Bot",
"last_name" => "User",
"honeypot" => "filled-by-bot"
"website" => "filled-by-bot"
})
|> render_submit()
@ -79,38 +82,66 @@ defmodule MvWeb.JoinLiveTest do
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]
{:ok, view, _html} = live(conn, "/join")
# Exhaust limit with valid submits
# 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",
"honeypot" => ""
"website" => ""
})
|> render_submit()
end
# Next submit should be rate limited
# 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",
"honeypot" => ""
"website" => ""
})
|> render_submit()
assert count_join_requests() == count_before + 2
assert result =~ "rate limit" or result =~ "too many" or result =~ "429"
assert result =~ "rate limit" or String.downcase(result) =~ "too many" or result =~ "429"
end
end
@ -120,7 +151,15 @@ defmodule MvWeb.JoinLiveTest do
end
defp enable_join_form_for_test(_context) do
enable_join_form(true)
{: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