diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index f91ee0c..38625e6 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -36,7 +36,9 @@ This document lists all protected routes, which permission set may access them, ## 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). diff --git a/test/membership/join_request_test.exs b/test/membership/join_request_test.exs index f40c9ec..6c39d4e 100644 --- a/test/membership/join_request_test.exs +++ b/test/membership/join_request_test.exs @@ -121,6 +121,32 @@ defmodule Mv.Membership.JoinRequestTest do 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 errors |> Enum.filter(fn err -> Map.get(err, :field) == field end) diff --git a/test/mv_web/controllers/join_confirm_controller_test.exs b/test/mv_web/controllers/join_confirm_controller_test.exs index d1e9117..a85cde5 100644 --- a/test/mv_web/controllers/join_confirm_controller_test.exs +++ b/test/mv_web/controllers/join_confirm_controller_test.exs @@ -60,13 +60,18 @@ defmodule MvWeb.JoinConfirmControllerTest do end @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) conn = get(conn, "/confirm_join/expired-token") + body = response(conn, 200) - assert response(conn, 200) =~ "expired" - assert response(conn, 200) =~ "submit" + assert body =~ "expired" + assert body =~ "submit" + # Concept ยง2.5: clear message + "submit form again" + assert body =~ "form" or body =~ "again" end @tag role: :unauthenticated diff --git a/test/mv_web/live/join_live_test.exs b/test/mv_web/live/join_live_test.exs new file mode 100644 index 0000000..cc4e5db --- /dev/null +++ b/test/mv_web/live/join_live_test.exs @@ -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 diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 1b3f827..80aa95e 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -204,6 +204,12 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do refute conn.halted 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 describe "error handling" do