157 lines
5.9 KiB
Elixir
157 lines
5.9 KiB
Elixir
defmodule Mv.Membership.JoinRequestTest do
|
|
@moduledoc """
|
|
Tests for JoinRequest resource (public join flow, Subtask 1).
|
|
|
|
Covers: submit and confirm actions with actor: nil, validations, idempotent confirm,
|
|
and policy that non-public actions (e.g. read) are Forbidden with actor: nil.
|
|
No UI or email; resource and persistence only.
|
|
|
|
Requires: Mv.Membership.JoinRequest resource and domain functions
|
|
submit_join_request/2, confirm_join_request/2. Policy test requires the resource
|
|
to be loaded; unskip when JoinRequest exists.
|
|
"""
|
|
use Mv.DataCase, async: true
|
|
|
|
alias Mv.Membership
|
|
|
|
# Valid minimal attributes for submit (email required; confirmation_token optional for tests)
|
|
@valid_submit_attrs %{
|
|
email: "join#{System.unique_integer([:positive])}@example.com"
|
|
}
|
|
|
|
describe "submit_join_request/2 (create with actor: nil)" do
|
|
test "creates JoinRequest in pending_confirmation with valid attributes and actor nil" do
|
|
attrs =
|
|
Map.put(
|
|
@valid_submit_attrs,
|
|
:confirmation_token,
|
|
"test-token-#{System.unique_integer([:positive])}"
|
|
)
|
|
|
|
assert {:ok, request} =
|
|
Membership.submit_join_request(attrs, actor: nil)
|
|
|
|
assert request.status == :pending_confirmation
|
|
assert request.email == attrs.email
|
|
assert request.confirmation_token_hash != nil
|
|
assert request.confirmation_token_expires_at != nil
|
|
end
|
|
|
|
test "persists first_name, last_name and form_data when provided" do
|
|
attrs =
|
|
@valid_submit_attrs
|
|
|> Map.put(:confirmation_token, "token-#{System.unique_integer([:positive])}")
|
|
|> Map.put(:first_name, "Jane")
|
|
|> Map.put(:last_name, "Doe")
|
|
|> Map.put(:form_data, %{"city" => "Berlin", "notes" => "Hello"})
|
|
|> Map.put(:schema_version, 1)
|
|
|
|
assert {:ok, request} =
|
|
Membership.submit_join_request(attrs, actor: nil)
|
|
|
|
assert request.first_name == "Jane"
|
|
assert request.last_name == "Doe"
|
|
assert request.form_data == %{"city" => "Berlin", "notes" => "Hello"}
|
|
assert request.schema_version == 1
|
|
end
|
|
|
|
test "returns validation error when email is missing" do
|
|
attrs = %{first_name: "Test"} |> Map.delete(:email)
|
|
|
|
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
|
Membership.submit_join_request(attrs, actor: nil)
|
|
|
|
assert error_message(errors, :email) =~ "must be present"
|
|
end
|
|
end
|
|
|
|
describe "confirm_join_request/2 (update with actor: nil)" do
|
|
test "updates JoinRequest to submitted when given valid token and actor nil" do
|
|
token = "confirm-token-#{System.unique_integer([:positive])}"
|
|
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
|
|
|
|
{:ok, request_before} = Membership.submit_join_request(attrs, actor: nil)
|
|
assert request_before.status == :pending_confirmation
|
|
|
|
assert {:ok, request_after} =
|
|
Membership.confirm_join_request(token, actor: nil)
|
|
|
|
assert request_after.id == request_before.id
|
|
assert request_after.status == :submitted
|
|
assert request_after.submitted_at != nil
|
|
end
|
|
|
|
test "confirm is idempotent when called twice with same token" do
|
|
token = "idempotent-token-#{System.unique_integer([:positive])}"
|
|
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
|
|
|
|
{:ok, _} = Membership.submit_join_request(attrs, actor: nil)
|
|
{:ok, first} = Membership.confirm_join_request(token, actor: nil)
|
|
submitted_at = first.submitted_at
|
|
|
|
assert {:ok, second} = Membership.confirm_join_request(token, actor: nil)
|
|
assert second.status == :submitted
|
|
assert second.submitted_at == submitted_at
|
|
end
|
|
|
|
test "returns error when token is unknown or invalid" do
|
|
assert {:error, _} = Membership.confirm_join_request("nonexistent-token", actor: nil)
|
|
end
|
|
|
|
test "returns error when token is expired" do
|
|
token = "expired-token-#{System.unique_integer([:positive])}"
|
|
attrs = Map.put(@valid_submit_attrs, :confirmation_token, token)
|
|
|
|
{:ok, request} = Membership.submit_join_request(attrs, actor: nil)
|
|
past = DateTime.add(DateTime.utc_now(), -1, :hour)
|
|
id_binary = Ecto.UUID.dump!(request.id)
|
|
|
|
from(j in "join_requests", where: fragment("id = ?", ^id_binary))
|
|
|> Repo.update_all(set: [confirmation_token_expires_at: past])
|
|
|
|
assert {:error, :token_expired} =
|
|
Membership.confirm_join_request(token, actor: nil)
|
|
end
|
|
end
|
|
|
|
describe "policies (actor: nil)" do
|
|
test "read with actor nil returns Forbidden" do
|
|
assert {:error, %Ash.Error.Forbidden{}} =
|
|
Ash.read(Mv.Membership.JoinRequest, actor: nil, domain: Mv.Membership)
|
|
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)
|
|
|> Enum.map(&Map.get(&1, :message, ""))
|
|
|> List.first()
|
|
end
|
|
end
|