Harden public contact verification lifecycle

This commit is contained in:
SimpleTest 2026-08-03 02:01:09 +03:00
parent accc0845bd
commit 7be5fcf478
16 changed files with 471 additions and 24 deletions

View File

@ -227,6 +227,11 @@ SUPPORT_INBOX_ADDRESS=
# staff workspace is the canonical queue. Set immediate only when a monitored
# mailbox should receive one metadata-only alert for each verified case/update.
SUPPORT_OPERATOR_EMAIL_MODE=disabled
# Pilot policy: an anonymous support/removal email must be confirmed within one
# day. Confirmed case links remain usable for one year. Both values are seconds
# and can be changed without rebuilding the release.
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS=86400
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS=31536000
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.

View File

@ -30,6 +30,8 @@ x-app-environment: &app-environment
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS: ${PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS:-86400}
PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS: ${PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS:-31536000}
MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png}
GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}

View File

@ -34,6 +34,8 @@ config :who_need_help,
e2e_routes: false,
secure_cookies: false,
rate_limit_policies: %{},
public_contact_verification_max_age_seconds: 86_400,
public_case_access_max_age_seconds: 31_536_000,
tracking_presence_cleanup_grace_ms: 5_000,
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
android_app_links: nil,

View File

@ -82,6 +82,12 @@ positive_integer_with_default = fn name, default ->
optional_positive_integer.(name) || default
end
config :who_need_help,
public_contact_verification_max_age_seconds:
positive_integer_with_default.("PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS", 86_400),
public_case_access_max_age_seconds:
positive_integer_with_default.("PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS", 31_536_000)
if config_env() == :prod do
config :who_need_help, Oban,
queues: [

View File

@ -101,6 +101,10 @@ spec:
value: {{ $root.Values.app.codexSessionId | quote }}
- name: RATE_LIMIT_POLICIES_JSON
value: {{ $root.Values.app.rateLimitPoliciesJson | quote }}
- name: PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS
value: {{ $root.Values.app.publicContactVerificationMaxAgeSeconds | quote }}
- name: PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS
value: {{ $root.Values.app.publicCaseAccessMaxAgeSeconds | quote }}
- name: MAP_TILE_URL
value: {{ $root.Values.app.mapTileUrl | quote }}
- name: DNS_CLUSTER_QUERY

View File

@ -32,6 +32,8 @@ app:
codexSessionId: not-configured
# Shared limits are opt-in; set only after product policy thresholds are approved.
rateLimitPoliciesJson: "{}"
publicContactVerificationMaxAgeSeconds: "86400"
publicCaseAccessMaxAgeSeconds: "31536000"
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
emailDeliveryProvider: smtp
smtpRelay: mailpit

View File

@ -119,6 +119,16 @@ intake and reporter acknowledgement still work, but no operator inbox alert is
sent. The configured inbox must be monitored operationally; the application
cannot prove staffing or response availability.
Anonymous submissions use two distinct private links. The confirmation link is
valid for `PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS` (the initial pilot
policy is 24 hours). Confirming it moves the record into the permission-scoped
staff queue and sends a second status link. The status link lifetime is
`PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS` (the initial pilot policy is one year).
Expired unconfirmed records are removed by the maintenance worker together with
their creation audit entry; confirmed support and legal records are outside this
cleanup. Each deployment can change both values through its own `.env` without
rebuilding the release.
## Account deletion and data export
The web application and Android WebView expose the account-deletion request from

View File

@ -12,6 +12,7 @@ defmodule WhoNeedHelp.ContentRemoval do
alias WhoNeedHelp.Trust.RateLimiter
@access_salt "content-removal-access"
@confirmation_salt "content-removal-confirmation"
@urgent_categories [
:non_consensual_intimate_media,
:child_sexual_abuse_material,
@ -88,9 +89,15 @@ defmodule WhoNeedHelp.ContentRemoval do
def get_by_access_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000),
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: case_access_max_age_seconds()
),
%Notice{} = notice <- Repo.get(Notice, id) do
verify_contact(notice)
if notice.contact_verified_at do
{:ok, notice}
else
verify_legacy_confirmation(notice, token)
end
else
_ -> {:error, :not_found}
end
@ -98,8 +105,31 @@ defmodule WhoNeedHelp.ContentRemoval do
def get_by_access_token(_id, _token), do: {:error, :not_found}
def verify_by_confirmation_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @confirmation_salt, token,
max_age: contact_verification_max_age_seconds()
),
%Notice{} = notice <- Repo.get(Notice, id) do
verify_contact(notice)
else
_ -> {:error, :not_found}
end
end
def verify_by_confirmation_token(_id, _token), do: {:error, :not_found}
def access_token(%Notice{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id)
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id,
max_age: case_access_max_age_seconds()
)
end
def confirmation_token(%Notice{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @confirmation_salt, id,
max_age: contact_verification_max_age_seconds()
)
end
def status_url(%Notice{} = notice) do
@ -109,6 +139,13 @@ defmodule WhoNeedHelp.ContentRemoval do
"/legal/content-removal/#{notice.id}?token=#{URI.encode_www_form(token)}"
end
def confirmation_url(%Notice{} = notice) do
token = confirmation_token(notice)
WhoNeedHelpWeb.Endpoint.url() <>
"/legal/content-removal/#{notice.id}/verify?token=#{URI.encode_www_form(token)}"
end
def paginate_for_staff(%Scope{user: user}, options \\ []) do
if Accounts.authorized?(user, :legal_view) do
limit = Pagination.limit(options)
@ -194,7 +231,7 @@ defmodule WhoNeedHelp.ContentRemoval do
defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice})
when is_binary(email) and email != "" do
case Notifier.deliver_confirmation(notice, status_url(notice)) do
case Notifier.deliver_confirmation(notice, confirmation_url(notice)) do
{:ok, _metadata} -> mark_acknowledgement_sent(notice)
_error -> {:ok, notice}
end
@ -270,6 +307,7 @@ defmodule WhoNeedHelp.ContentRemoval do
defp verify_contact(%Notice{} = notice), do: {:ok, notice}
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
_ = Notifier.deliver_received(notice, status_url(notice))
_ = Notifier.deliver_operator_alert(notice)
{:ok, notice}
end
@ -277,6 +315,23 @@ defmodule WhoNeedHelp.ContentRemoval do
defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice}
defp notify_verified_notice(result), do: result
defp verify_legacy_confirmation(%Notice{id: id} = notice, token) do
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: contact_verification_max_age_seconds()
) do
{:ok, ^id} -> verify_contact(notice)
_error -> {:error, :not_found}
end
end
defp contact_verification_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
end
defp case_access_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
end
defp mark_acknowledgement_sent(notice) do
notice
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))

View File

@ -13,6 +13,7 @@ defmodule WhoNeedHelp.Support do
alias WhoNeedHelp.Trust.RateLimiter
@access_salt "support-request-access"
@confirmation_salt "support-request-confirmation"
@staff_topic "support:staff"
def subscribe_request(id),
@ -106,10 +107,14 @@ defmodule WhoNeedHelp.Support do
def get_by_access_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token, max_age: 31_536_000),
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: case_access_max_age_seconds()
),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
with {:ok, request} <- verify_contact(request) do
if request.contact_verified_at do
{:ok, preload_conversation(request)}
else
verify_legacy_confirmation(request, token)
end
else
_ -> {:error, :not_found}
@ -118,6 +123,21 @@ defmodule WhoNeedHelp.Support do
def get_by_access_token(_id, _token), do: {:error, :not_found}
def verify_by_confirmation_token(id, token) when is_binary(token) do
with {:ok, id} <- Ecto.UUID.cast(id),
{:ok, ^id} <-
Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @confirmation_salt, token,
max_age: contact_verification_max_age_seconds()
),
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
verify_contact(request)
else
_ -> {:error, :not_found}
end
end
def verify_by_confirmation_token(_id, _token), do: {:error, :not_found}
def get_for_viewer(scope, id, token \\ nil)
def get_for_viewer(%Scope{} = scope, id, token) do
@ -130,7 +150,15 @@ defmodule WhoNeedHelp.Support do
def get_for_viewer(nil, id, token), do: get_by_access_token(id, token)
def access_token(%SupportRequest{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id)
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @access_salt, id,
max_age: case_access_max_age_seconds()
)
end
def confirmation_token(%SupportRequest{id: id}) do
Phoenix.Token.sign(WhoNeedHelpWeb.Endpoint, @confirmation_salt, id,
max_age: contact_verification_max_age_seconds()
)
end
def status_url(%SupportRequest{} = request) do
@ -140,6 +168,13 @@ defmodule WhoNeedHelp.Support do
"/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}"
end
def confirmation_url(%SupportRequest{} = request) do
token = confirmation_token(request)
WhoNeedHelpWeb.Endpoint.url() <>
"/support/cases/#{request.id}/verify?token=#{URI.encode_www_form(token)}"
end
def paginate_for_staff(%Scope{user: user}, options \\ []) do
if Accounts.authorized?(user, :support_view) do
limit = Pagination.limit(options)
@ -373,7 +408,7 @@ defmodule WhoNeedHelp.Support do
end
defp notify_created({:ok, {request, :pending_verification}}) do
_ = Notifier.deliver_confirmation(request, status_url(request))
_ = Notifier.deliver_confirmation(request, confirmation_url(request))
{:ok, request}
end
@ -487,6 +522,7 @@ defmodule WhoNeedHelp.Support do
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
defp notify_verified_request({:ok, {request, :newly_verified}}) do
_ = Notifier.deliver_received(request, status_url(request))
_ = Notifier.deliver_operator_alert(request)
event = {:support_request_updated, request.id}
@ -499,6 +535,28 @@ defmodule WhoNeedHelp.Support do
defp notify_verified_request({:ok, {request, :already_verified}}), do: {:ok, request}
defp notify_verified_request(result), do: result
defp verify_legacy_confirmation(%SupportRequest{id: id} = request, token) do
case Phoenix.Token.verify(WhoNeedHelpWeb.Endpoint, @access_salt, token,
max_age: contact_verification_max_age_seconds()
) do
{:ok, ^id} ->
with {:ok, verified} <- verify_contact(request) do
{:ok, preload_conversation(verified)}
end
_error ->
{:error, :not_found}
end
end
defp contact_verification_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
end
defp case_access_max_age_seconds do
Application.fetch_env!(:who_need_help, :public_case_access_max_age_seconds)
end
defp preload_conversation(%SupportRequest{} = request) do
Repo.preload(
request,

View File

@ -3,17 +3,21 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
import Ecto.Query
alias WhoNeedHelp.Help
alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelp.Tracking
alias WhoNeedHelp.Trust.RateLimiter
alias WhoNeedHelp.Trust.{AuditEvent, RateLimiter}
@impl Oban.Worker
def perform(_job) do
now = DateTime.utc_now(:second)
with {:ok, expired} <- expire_available(now, 0),
{:ok, cleanup} <- Tracking.cleanup_finished_sessions() do
{:ok, cleanup} <- Tracking.cleanup_finished_sessions(),
{:ok, support_pruned} <- prune_unverified_support(verification_cutoff(now), 0),
{:ok, removal_pruned} <- prune_unverified_removal(verification_cutoff(now), 0) do
{pruned_buckets, _} = RateLimiter.prune_expired()
{pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now)
@ -22,6 +26,8 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
expired: expired,
tracking_positions_deleted: cleanup.positions_deleted,
tracking_sessions_ended: cleanup.sessions_ended,
unverified_support_pruned: support_pruned,
unverified_removal_pruned: removal_pruned,
rate_limit_buckets_pruned: pruned_buckets,
user_tokens_pruned: pruned_user_tokens
}}
@ -65,4 +71,77 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
{:error, reason}
end
end
defp prune_unverified_support(cutoff, pruned) do
result =
Repo.transact(fn ->
request =
SupportRequest
|> where(
[request],
is_nil(request.requester_id) and is_nil(request.contact_verified_at) and
request.inserted_at <= ^cutoff
)
|> order_by([request], asc: request.inserted_at, asc: request.id)
|> limit(1)
|> lock("FOR UPDATE SKIP LOCKED")
|> Repo.one()
case request do
nil ->
{:ok, :empty}
request ->
delete_target_audit("support_request", request.id)
Repo.delete(request)
end
end)
continue_pruning(result, pruned, &prune_unverified_support(cutoff, &1))
end
defp prune_unverified_removal(cutoff, pruned) do
result =
Repo.transact(fn ->
notice =
Notice
|> where(
[notice],
is_nil(notice.requester_id) and not is_nil(notice.contact_email) and
is_nil(notice.contact_verified_at) and notice.inserted_at <= ^cutoff
)
|> order_by([notice], asc: notice.inserted_at, asc: notice.id)
|> limit(1)
|> lock("FOR UPDATE SKIP LOCKED")
|> Repo.one()
case notice do
nil ->
{:ok, :empty}
notice ->
delete_target_audit("content_removal_notice", notice.id)
Repo.delete(notice)
end
end)
continue_pruning(result, pruned, &prune_unverified_removal(cutoff, &1))
end
defp continue_pruning({:ok, :empty}, pruned, _continue), do: {:ok, pruned}
defp continue_pruning({:ok, _record}, pruned, continue), do: continue.(pruned + 1)
defp continue_pruning({:error, reason}, _pruned, _continue), do: {:error, reason}
defp delete_target_audit(target_type, target_id) do
AuditEvent
|> where([event], event.target_type == ^target_type and event.target_id == ^target_id)
|> Repo.delete_all()
end
defp verification_cutoff(now) do
max_age =
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
DateTime.add(now, -max_age, :second)
end
end

View File

@ -62,6 +62,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
end
end
def verify(conn, %{"id" => id, "token" => token}) do
case ContentRemoval.verify_by_confirmation_token(id, token) do
{:ok, notice} ->
conn
|> put_flash(
:info,
gettext("Your email was confirmed and the notice was sent for review.")
)
|> redirect(
to: ~p"/legal/content-removal/#{notice.id}?token=#{ContentRemoval.access_token(notice)}"
)
{:error, :not_found} ->
send_resp(conn, :not_found, "Not found")
end
end
def verify(conn, _params), do: send_resp(conn, :not_found, "Not found")
defp create_notice(conn, regime, params) do
case ContentRemoval.create_notice(
conn.assigns.current_scope,

View File

@ -98,6 +98,23 @@ defmodule WhoNeedHelpWeb.SupportController do
end
end
def verify(conn, %{"id" => id, "token" => token}) do
case Support.verify_by_confirmation_token(id, token) do
{:ok, request} ->
conn
|> put_flash(
:info,
gettext("Your email was confirmed and the request was sent to support.")
)
|> redirect(to: case_path(request.id, Support.access_token(request)))
{:error, :not_found} ->
send_resp(conn, :not_found, "Not found")
end
end
def verify(conn, _params), do: send_resp(conn, :not_found, "Not found")
def add_message(conn, %{"id" => id, "message" => params} = outer_params)
when is_map(params) do
token = outer_params["token"]

View File

@ -88,6 +88,7 @@ defmodule WhoNeedHelpWeb.Router do
get "/support/new", SupportController, :new
post "/support", SupportController, :create
get "/support/received", SupportController, :received
get "/support/cases/:id/verify", SupportController, :verify
get "/support/cases/:id", SupportController, :show
post "/support/cases/:id/messages", SupportController, :add_message
post "/support/cases/:id/reopen", SupportController, :reopen
@ -157,6 +158,7 @@ defmodule WhoNeedHelpWeb.Router do
scope "/", WhoNeedHelpWeb do
pipe_through :browser
get "/legal/content-removal/:id/verify", ContentRemovalController, :verify
get "/legal/content-removal/:id", ContentRemovalController, :show
end

View File

@ -29,7 +29,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert_email_sent(fn email ->
matches_request =
email.subject =~ request.reference and
email.text_body =~ "/support/cases/#{request.id}?token="
email.text_body =~ "/support/cases/#{request.id}/verify?token="
if matches_request, do: send(test_pid, {:support_acknowledgement, email})
matches_request
@ -37,19 +37,29 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
assert_receive {:support_acknowledgement, email}
[status_url] =
[confirmation_url] =
Regex.run(
~r{https?://[^\s]+/support/cases/#{Regex.escape(request.id)}\?token=[^\s]+},
~r{https?://[^\s]+/support/cases/#{Regex.escape(request.id)}/verify\?token=[^\s]+},
email.text_body
)
emailed_token =
status_url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() |> Map.fetch!("token")
confirmation_token =
confirmation_url
|> URI.parse()
|> Map.fetch!(:query)
|> URI.decode_query()
|> Map.fetch!("token")
assert {:error, :not_found} = Support.get_by_access_token(request.id, "invalid")
assert {:error, :not_found} = Support.get_by_access_token(request.id, confirmation_token)
assert {:error, :not_found} =
Support.verify_by_confirmation_token(request.id, Support.access_token(request))
assert {:ok, verified} =
Support.get_by_access_token(request.id, emailed_token)
Support.verify_by_confirmation_token(request.id, confirmation_token)
verified = Repo.preload(verified, :status_events)
assert verified.contact_verified_at
assert verified.status == :open
@ -66,6 +76,12 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
event.action == "support_request.contact_verified" and
event.target_id == ^request.id
)
assert_email_sent(fn received ->
received.to == [{"", request.contact_email}] and
received.subject == "Who Need Help support request #{request.reference}" and
received.text_body =~ "/support/cases/#{request.id}?token="
end)
end
test "operator alert is sent only after public contact verification" do
@ -97,10 +113,16 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
refute_receive {:email, _operator_alert}, 50
assert {:ok, verified} =
Support.get_by_access_token(request.id, Support.access_token(request))
Support.verify_by_confirmation_token(request.id, Support.confirmation_token(request))
assert verified.status == :open
assert_email_sent(fn email ->
email.to == [{"", "appeal@example.com"}] and
email.subject == "Who Need Help support request #{request.reference}" and
email.text_body =~ "/support/cases/#{request.id}?token="
end)
assert_email_sent(fn email ->
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
email.text_body =~ "/support/operations" and
@ -191,7 +213,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
})
assert {:ok, verified} =
Support.get_by_access_token(pending.id, Support.access_token(pending))
Support.verify_by_confirmation_token(
pending.id,
Support.confirmation_token(pending)
)
assert Enum.map(Support.paginate_for_staff(moderator_scope).entries, & &1.id) == [verified.id]
end
@ -432,7 +457,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
})
assert {:ok, verified} =
ContentRemoval.get_by_access_token(notice.id, ContentRemoval.access_token(notice))
ContentRemoval.verify_by_confirmation_token(
notice.id,
ContentRemoval.confirmation_token(notice)
)
assert verified.contact_verified_at

View File

@ -3,8 +3,10 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
import WhoNeedHelp.AccountsFixtures
alias WhoNeedHelp.{Catalog, Help, Repo}
alias WhoNeedHelp.{Catalog, ContentRemoval, Help, Repo, Support}
alias WhoNeedHelp.ContentRemoval.Notice
alias WhoNeedHelp.Help.HelpRequest
alias WhoNeedHelp.Support.SupportRequest
alias WhoNeedHelp.Trust.AuditEvent
alias WhoNeedHelp.Workers.ExpireRequests
@ -72,4 +74,110 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
end
test "prunes only expired unverified public support and removal submissions" do
support_attrs = fn email, subject ->
%{
"kind" => "technical_issue",
"contact_email" => email,
"subject" => subject,
"details" => "This public support request exercises verification lifecycle cleanup."
}
end
removal_attrs = fn email, suffix ->
%{
"category" => "privacy_violation",
"submitter_name" => "Lifecycle reporter",
"contact_email" => email,
"relationship" => "self",
"content_locations" => "https://example.test/requests/lifecycle-#{suffix}",
"explanation" => "This public removal notice exercises verification lifecycle cleanup.",
"electronic_signature" => "Lifecycle reporter",
"good_faith" => "true",
"accurate_complete" => "true"
}
end
assert {:ok, expired_support} =
Support.create_request(
nil,
support_attrs.("expired-support@example.com", "Expired public support")
)
assert {:ok, fresh_support} =
Support.create_request(
nil,
support_attrs.("fresh-support@example.com", "Fresh public support")
)
assert {:ok, verified_support} =
Support.create_request(
nil,
support_attrs.("verified-support@example.com", "Verified public support")
)
assert {:ok, verified_support} =
Support.verify_by_confirmation_token(
verified_support.id,
Support.confirmation_token(verified_support)
)
assert {:ok, expired_removal} =
ContentRemoval.create_notice(
nil,
:general,
removal_attrs.("expired-removal@example.com", "expired")
)
assert {:ok, fresh_removal} =
ContentRemoval.create_notice(
nil,
:general,
removal_attrs.("fresh-removal@example.com", "fresh")
)
max_age =
Application.fetch_env!(:who_need_help, :public_contact_verification_max_age_seconds)
expired_at = DateTime.add(DateTime.utc_now(:second), -(max_age + 1), :second)
Repo.update_all(
from(request in SupportRequest, where: request.id == ^expired_support.id),
set: [inserted_at: expired_at]
)
Repo.update_all(
from(notice in Notice, where: notice.id == ^expired_removal.id),
set: [inserted_at: expired_at]
)
assert {:ok,
%{
unverified_support_pruned: 1,
unverified_removal_pruned: 1
}} = ExpireRequests.perform(%Oban.Job{})
refute Repo.get(SupportRequest, expired_support.id)
refute Repo.get(Notice, expired_removal.id)
assert Repo.get(SupportRequest, fresh_support.id)
assert Repo.get(SupportRequest, verified_support.id)
assert Repo.get(Notice, fresh_removal.id)
refute Repo.exists?(
from(event in AuditEvent,
where:
event.target_type == "support_request" and
event.target_id == ^expired_support.id
)
)
refute Repo.exists?(
from(event in AuditEvent,
where:
event.target_type == "content_removal_notice" and
event.target_id == ^expired_removal.id
)
)
end
end

View File

@ -120,6 +120,56 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
assert_email_sent()
end
test "confirmation endpoints exchange short-lived confirmation links for status links", %{
conn: conn
} do
assert {:ok, support_request} =
WhoNeedHelp.Support.create_request(nil, %{
"kind" => "technical_issue",
"contact_email" => "confirmation-support@example.com",
"subject" => "Confirm the public support contact",
"details" =>
"The confirmation endpoint must redirect to a separate private status link."
})
support_conn =
get(
conn,
~p"/support/cases/#{support_request.id}/verify?token=#{WhoNeedHelp.Support.confirmation_token(support_request)}"
)
support_location = redirected_to(support_conn)
assert support_location =~ "/support/cases/#{support_request.id}?token="
refute support_location =~ "/verify"
assert Repo.get!(SupportRequest, support_request.id).contact_verified_at
assert {:ok, removal_notice} =
WhoNeedHelp.ContentRemoval.create_notice(nil, :general, %{
"category" => "privacy_violation",
"submitter_name" => "Confirmation reporter",
"contact_email" => "confirmation-removal@example.com",
"relationship" => "self",
"content_locations" => "https://example.test/requests/confirmation-removal",
"explanation" =>
"The removal confirmation endpoint must issue a separate status link.",
"electronic_signature" => "Confirmation reporter",
"good_faith" => "true",
"accurate_complete" => "true"
})
removal_conn =
conn
|> recycle()
|> get(
~p"/legal/content-removal/#{removal_notice.id}/verify?token=#{WhoNeedHelp.ContentRemoval.confirmation_token(removal_notice)}"
)
removal_location = redirected_to(removal_conn)
assert removal_location =~ "/legal/content-removal/#{removal_notice.id}?token="
refute removal_location =~ "/verify"
assert Repo.get!(WhoNeedHelp.ContentRemoval.Notice, removal_notice.id).contact_verified_at
end
test "removal intake enforces independent contact and client IP limits", %{conn: conn} do
previous = Application.get_env(:who_need_help, :rate_limit_policies)
@ -381,15 +431,15 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
})
assert {:ok, _verified_removal_notice} =
WhoNeedHelp.ContentRemoval.get_by_access_token(
WhoNeedHelp.ContentRemoval.verify_by_confirmation_token(
removal_notice.id,
WhoNeedHelp.ContentRemoval.access_token(removal_notice)
WhoNeedHelp.ContentRemoval.confirmation_token(removal_notice)
)
assert {:ok, _verified_support_request} =
WhoNeedHelp.Support.get_by_access_token(
WhoNeedHelp.Support.verify_by_confirmation_token(
support_request.id,
WhoNeedHelp.Support.access_token(support_request)
WhoNeedHelp.Support.confirmation_token(support_request)
)
staff_conn = log_in_user(conn, moderator)