From 7be5fcf478db86eb737d3c7b702e1403761ecc05 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Mon, 3 Aug 2026 02:01:09 +0300 Subject: [PATCH] Harden public contact verification lifecycle --- .env.example | 5 + compose.yaml | 2 + config/config.exs | 2 + config/runtime.exs | 6 + .../who-need-help/templates/deployments.yaml | 4 + deploy/helm/who-need-help/values.yaml | 2 + docs/support-and-content-removal.md | 10 ++ lib/who_need_help/content_removal.ex | 63 +++++++++- lib/who_need_help/support.ex | 66 ++++++++++- lib/who_need_help/workers/expire_requests.ex | 83 ++++++++++++- .../controllers/content_removal_controller.ex | 19 +++ .../controllers/support_controller.ex | 17 +++ lib/who_need_help_web/router.ex | 2 + .../support_and_content_removal_test.exs | 46 ++++++-- .../workers/expire_requests_test.exs | 110 +++++++++++++++++- .../controllers/support_controller_test.exs | 58 ++++++++- 16 files changed, 471 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index bfb5e1d..bc7cdc9 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/compose.yaml b/compose.yaml index 5ca5151..ee5f110 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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:-} diff --git a/config/config.exs b/config/config.exs index 457a182..fa56af3 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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, diff --git a/config/runtime.exs b/config/runtime.exs index 32330fd..f3a4b2c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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: [ diff --git a/deploy/helm/who-need-help/templates/deployments.yaml b/deploy/helm/who-need-help/templates/deployments.yaml index 5b42ac2..4f3bc21 100644 --- a/deploy/helm/who-need-help/templates/deployments.yaml +++ b/deploy/helm/who-need-help/templates/deployments.yaml @@ -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 diff --git a/deploy/helm/who-need-help/values.yaml b/deploy/helm/who-need-help/values.yaml index 5562e84..e856572 100644 --- a/deploy/helm/who-need-help/values.yaml +++ b/deploy/helm/who-need-help/values.yaml @@ -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 diff --git a/docs/support-and-content-removal.md b/docs/support-and-content-removal.md index 18bc380..1d5cae4 100644 --- a/docs/support-and-content-removal.md +++ b/docs/support-and-content-removal.md @@ -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 diff --git a/lib/who_need_help/content_removal.ex b/lib/who_need_help/content_removal.ex index 15129a2..f7b0301 100644 --- a/lib/who_need_help/content_removal.ex +++ b/lib/who_need_help/content_removal.ex @@ -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)) diff --git a/lib/who_need_help/support.ex b/lib/who_need_help/support.ex index 4c7245f..78f64d3 100644 --- a/lib/who_need_help/support.ex +++ b/lib/who_need_help/support.ex @@ -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, diff --git a/lib/who_need_help/workers/expire_requests.ex b/lib/who_need_help/workers/expire_requests.ex index 53430bd..dd00ee0 100644 --- a/lib/who_need_help/workers/expire_requests.ex +++ b/lib/who_need_help/workers/expire_requests.ex @@ -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 diff --git a/lib/who_need_help_web/controllers/content_removal_controller.ex b/lib/who_need_help_web/controllers/content_removal_controller.ex index 4570ce6..cb60273 100644 --- a/lib/who_need_help_web/controllers/content_removal_controller.ex +++ b/lib/who_need_help_web/controllers/content_removal_controller.ex @@ -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, diff --git a/lib/who_need_help_web/controllers/support_controller.ex b/lib/who_need_help_web/controllers/support_controller.ex index 9670e97..04e6904 100644 --- a/lib/who_need_help_web/controllers/support_controller.ex +++ b/lib/who_need_help_web/controllers/support_controller.ex @@ -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"] diff --git a/lib/who_need_help_web/router.ex b/lib/who_need_help_web/router.ex index 2e9ffd9..5175859 100644 --- a/lib/who_need_help_web/router.ex +++ b/lib/who_need_help_web/router.ex @@ -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 diff --git a/test/who_need_help/support_and_content_removal_test.exs b/test/who_need_help/support_and_content_removal_test.exs index 2f90a89..adc6485 100644 --- a/test/who_need_help/support_and_content_removal_test.exs +++ b/test/who_need_help/support_and_content_removal_test.exs @@ -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 diff --git a/test/who_need_help/workers/expire_requests_test.exs b/test/who_need_help/workers/expire_requests_test.exs index 22dd55f..370dcde 100644 --- a/test/who_need_help/workers/expire_requests_test.exs +++ b/test/who_need_help/workers/expire_requests_test.exs @@ -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 diff --git a/test/who_need_help_web/controllers/support_controller_test.exs b/test/who_need_help_web/controllers/support_controller_test.exs index ff7a82c..631dd7d 100644 --- a/test/who_need_help_web/controllers/support_controller_test.exs +++ b/test/who_need_help_web/controllers/support_controller_test.exs @@ -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)