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
5 changed files with 173 additions and 4 deletions
Showing only changes of commit eadf90b5fc - Show all commits

View file

@ -36,7 +36,9 @@ This document lists all protected routes, which permission set may access them,
## Public Paths (no permission check) ## Public Paths (no permission check)
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale` - `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`, **`/join`**
The public join page `GET /join` is explicitly public (Subtask 4); unauthenticated access returns 200 when join form is enabled, 404 when disabled. Unit test: `test/mv_web/plugs/check_page_permission_test.exs` (plug allows /join); integration: `test/mv_web/live/join_live_test.exs`.
The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration). The join confirmation route `GET /confirm_join/:token` is public (matched by `/confirm*`). Unit tests: `test/mv_web/controllers/join_confirm_controller_test.exs` (stubbed callback, no integration).

View file

@ -121,6 +121,32 @@ defmodule Mv.Membership.JoinRequestTest do
end end
end end
describe "allowlist (server-side field filter)" 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"],
join_form_field_required: %{"email" => true, "first_name" => false}
})
attrs = %{
email: "allowlist#{System.unique_integer([:positive])}@example.com",
first_name: "Allowed",
confirmation_token: "tok-#{System.unique_integer([:positive])}",
form_data: %{"city" => "Berlin", "internal_or_secret" => "must not persist"},
schema_version: 1
}
assert {:ok, request} = Membership.submit_join_request(attrs, actor: nil)
assert request.email == attrs.email
assert request.first_name == attrs.first_name
refute Map.has_key?(request.form_data || %{}, "internal_or_secret")
assert (request.form_data || %{})["city"] == "Berlin"
end
end
defp error_message(errors, field) do defp error_message(errors, field) do
errors errors
|> Enum.filter(fn err -> Map.get(err, :field) == field end) |> Enum.filter(fn err -> Map.get(err, :field) == field end)

View file

@ -60,13 +60,18 @@ defmodule MvWeb.JoinConfirmControllerTest do
end end
@tag role: :unauthenticated @tag role: :unauthenticated
test "expired token returns 200 with expired message", %{conn: conn} do test "expired token returns 200 with expired message and instructs to submit form again", %{
conn: conn
} do
Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub) Application.put_env(:mv, :join_confirm_callback, JoinConfirmExpiredStub)
conn = get(conn, "/confirm_join/expired-token") conn = get(conn, "/confirm_join/expired-token")
body = response(conn, 200)
assert response(conn, 200) =~ "expired" assert body =~ "expired"
assert response(conn, 200) =~ "submit" assert body =~ "submit"
# Concept §2.5: clear message + "submit form again"
assert body =~ "form" or body =~ "again"
end end
@tag role: :unauthenticated @tag role: :unauthenticated

View file

@ -0,0 +1,130 @@
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.
"""
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",
"honeypot" => ""
})
|> 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",
"honeypot" => "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
enable_join_form(true)
# 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()
{:ok, view, _html} = live(conn, "/join")
# Exhaust limit with valid submits
for i <- 0..1 do
view
|> form("#join-form", %{
"email" => "#{i}-#{base_email}",
"first_name" => "User",
"last_name" => "Test",
"honeypot" => ""
})
|> render_submit()
end
# Next submit should be rate limited
result =
view
|> form("#join-form", %{
"email" => "third-#{base_email}",
"first_name" => "Third",
"last_name" => "User",
"honeypot" => ""
})
|> render_submit()
assert count_join_requests() == count_before + 2
assert result =~ "rate limit" or 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
enable_join_form(true)
:ok
end
defp count_join_requests do
Repo.one(from j in "join_requests", select: count(j.id)) || 0
end
end

View file

@ -204,6 +204,12 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
refute conn.halted refute conn.halted
end end
test "unauthenticated user can access /join (public join page, no redirect)" do
conn = conn_without_user("/join") |> CheckPagePermission.call([])
refute conn.halted
end
end end
describe "error handling" do describe "error handling" do