Harden public contact verification lifecycle
This commit is contained in:
parent
accc0845bd
commit
7be5fcf478
|
|
@ -227,6 +227,11 @@ SUPPORT_INBOX_ADDRESS=
|
||||||
# staff workspace is the canonical queue. Set immediate only when a monitored
|
# staff workspace is the canonical queue. Set immediate only when a monitored
|
||||||
# mailbox should receive one metadata-only alert for each verified case/update.
|
# mailbox should receive one metadata-only alert for each verified case/update.
|
||||||
SUPPORT_OPERATOR_EMAIL_MODE=disabled
|
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
|
CODEX_SESSION_ID=copy-the-main-local-codex-session-id
|
||||||
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
|
# Optional shared PostgreSQL-backed policies. Keep {} until product thresholds are approved.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ x-app-environment: &app-environment
|
||||||
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
|
SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-}
|
||||||
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
|
CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured}
|
||||||
RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-{}}
|
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}
|
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_ID: ${GITHUB_OAUTH_CLIENT_ID:-}
|
||||||
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
|
GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ config :who_need_help,
|
||||||
e2e_routes: false,
|
e2e_routes: false,
|
||||||
secure_cookies: false,
|
secure_cookies: false,
|
||||||
rate_limit_policies: %{},
|
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,
|
tracking_presence_cleanup_grace_ms: 5_000,
|
||||||
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
map_tile_url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
android_app_links: nil,
|
android_app_links: nil,
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,12 @@ positive_integer_with_default = fn name, default ->
|
||||||
optional_positive_integer.(name) || default
|
optional_positive_integer.(name) || default
|
||||||
end
|
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
|
if config_env() == :prod do
|
||||||
config :who_need_help, Oban,
|
config :who_need_help, Oban,
|
||||||
queues: [
|
queues: [
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@ spec:
|
||||||
value: {{ $root.Values.app.codexSessionId | quote }}
|
value: {{ $root.Values.app.codexSessionId | quote }}
|
||||||
- name: RATE_LIMIT_POLICIES_JSON
|
- name: RATE_LIMIT_POLICIES_JSON
|
||||||
value: {{ $root.Values.app.rateLimitPoliciesJson | quote }}
|
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
|
- name: MAP_TILE_URL
|
||||||
value: {{ $root.Values.app.mapTileUrl | quote }}
|
value: {{ $root.Values.app.mapTileUrl | quote }}
|
||||||
- name: DNS_CLUSTER_QUERY
|
- name: DNS_CLUSTER_QUERY
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ app:
|
||||||
codexSessionId: not-configured
|
codexSessionId: not-configured
|
||||||
# Shared limits are opt-in; set only after product policy thresholds are approved.
|
# Shared limits are opt-in; set only after product policy thresholds are approved.
|
||||||
rateLimitPoliciesJson: "{}"
|
rateLimitPoliciesJson: "{}"
|
||||||
|
publicContactVerificationMaxAgeSeconds: "86400"
|
||||||
|
publicCaseAccessMaxAgeSeconds: "31536000"
|
||||||
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
mapTileUrl: https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
emailDeliveryProvider: smtp
|
emailDeliveryProvider: smtp
|
||||||
smtpRelay: mailpit
|
smtpRelay: mailpit
|
||||||
|
|
|
||||||
|
|
@ -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
|
sent. The configured inbox must be monitored operationally; the application
|
||||||
cannot prove staffing or response availability.
|
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
|
## Account deletion and data export
|
||||||
|
|
||||||
The web application and Android WebView expose the account-deletion request from
|
The web application and Android WebView expose the account-deletion request from
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
alias WhoNeedHelp.Trust.RateLimiter
|
alias WhoNeedHelp.Trust.RateLimiter
|
||||||
|
|
||||||
@access_salt "content-removal-access"
|
@access_salt "content-removal-access"
|
||||||
|
@confirmation_salt "content-removal-confirmation"
|
||||||
@urgent_categories [
|
@urgent_categories [
|
||||||
:non_consensual_intimate_media,
|
:non_consensual_intimate_media,
|
||||||
:child_sexual_abuse_material,
|
:child_sexual_abuse_material,
|
||||||
|
|
@ -88,9 +89,15 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
def get_by_access_token(id, token) when is_binary(token) do
|
def get_by_access_token(id, token) when is_binary(token) do
|
||||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
{:ok, ^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
|
%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
|
else
|
||||||
_ -> {:error, :not_found}
|
_ -> {:error, :not_found}
|
||||||
end
|
end
|
||||||
|
|
@ -98,8 +105,31 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
|
|
||||||
def get_by_access_token(_id, _token), do: {:error, :not_found}
|
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
|
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
|
end
|
||||||
|
|
||||||
def status_url(%Notice{} = notice) do
|
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)}"
|
"/legal/content-removal/#{notice.id}?token=#{URI.encode_www_form(token)}"
|
||||||
end
|
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
|
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
||||||
if Accounts.authorized?(user, :legal_view) do
|
if Accounts.authorized?(user, :legal_view) do
|
||||||
limit = Pagination.limit(options)
|
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})
|
defp notify_received({:ok, %Notice{contact_email: email, contact_verified_at: nil} = notice})
|
||||||
when is_binary(email) and email != "" do
|
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)
|
{:ok, _metadata} -> mark_acknowledgement_sent(notice)
|
||||||
_error -> {:ok, notice}
|
_error -> {:ok, notice}
|
||||||
end
|
end
|
||||||
|
|
@ -270,6 +307,7 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
defp verify_contact(%Notice{} = notice), do: {:ok, notice}
|
defp verify_contact(%Notice{} = notice), do: {:ok, notice}
|
||||||
|
|
||||||
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
|
defp notify_verified_notice({:ok, {notice, :newly_verified}}) do
|
||||||
|
_ = Notifier.deliver_received(notice, status_url(notice))
|
||||||
_ = Notifier.deliver_operator_alert(notice)
|
_ = Notifier.deliver_operator_alert(notice)
|
||||||
{:ok, notice}
|
{:ok, notice}
|
||||||
end
|
end
|
||||||
|
|
@ -277,6 +315,23 @@ defmodule WhoNeedHelp.ContentRemoval do
|
||||||
defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice}
|
defp notify_verified_notice({:ok, {notice, :already_verified}}), do: {:ok, notice}
|
||||||
defp notify_verified_notice(result), do: result
|
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
|
defp mark_acknowledgement_sent(notice) do
|
||||||
notice
|
notice
|
||||||
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|
|> Ecto.Changeset.change(acknowledgement_sent_at: DateTime.utc_now(:second))
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
alias WhoNeedHelp.Trust.RateLimiter
|
alias WhoNeedHelp.Trust.RateLimiter
|
||||||
|
|
||||||
@access_salt "support-request-access"
|
@access_salt "support-request-access"
|
||||||
|
@confirmation_salt "support-request-confirmation"
|
||||||
@staff_topic "support:staff"
|
@staff_topic "support:staff"
|
||||||
|
|
||||||
def subscribe_request(id),
|
def subscribe_request(id),
|
||||||
|
|
@ -106,10 +107,14 @@ defmodule WhoNeedHelp.Support do
|
||||||
def get_by_access_token(id, token) when is_binary(token) do
|
def get_by_access_token(id, token) when is_binary(token) do
|
||||||
with {:ok, id} <- Ecto.UUID.cast(id),
|
with {:ok, id} <- Ecto.UUID.cast(id),
|
||||||
{:ok, ^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
|
%SupportRequest{} = request <- Repo.get(SupportRequest, id) do
|
||||||
with {:ok, request} <- verify_contact(request) do
|
if request.contact_verified_at do
|
||||||
{:ok, preload_conversation(request)}
|
{:ok, preload_conversation(request)}
|
||||||
|
else
|
||||||
|
verify_legacy_confirmation(request, token)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
_ -> {:error, :not_found}
|
_ -> {:error, :not_found}
|
||||||
|
|
@ -118,6 +123,21 @@ defmodule WhoNeedHelp.Support do
|
||||||
|
|
||||||
def get_by_access_token(_id, _token), do: {:error, :not_found}
|
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, id, token \\ nil)
|
||||||
|
|
||||||
def get_for_viewer(%Scope{} = scope, id, token) do
|
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 get_for_viewer(nil, id, token), do: get_by_access_token(id, token)
|
||||||
|
|
||||||
def access_token(%SupportRequest{id: id}) do
|
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
|
end
|
||||||
|
|
||||||
def status_url(%SupportRequest{} = request) do
|
def status_url(%SupportRequest{} = request) do
|
||||||
|
|
@ -140,6 +168,13 @@ defmodule WhoNeedHelp.Support do
|
||||||
"/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}"
|
"/support/cases/#{request.id}?token=#{URI.encode_www_form(token)}"
|
||||||
end
|
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
|
def paginate_for_staff(%Scope{user: user}, options \\ []) do
|
||||||
if Accounts.authorized?(user, :support_view) do
|
if Accounts.authorized?(user, :support_view) do
|
||||||
limit = Pagination.limit(options)
|
limit = Pagination.limit(options)
|
||||||
|
|
@ -373,7 +408,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp notify_created({:ok, {request, :pending_verification}}) do
|
defp notify_created({:ok, {request, :pending_verification}}) do
|
||||||
_ = Notifier.deliver_confirmation(request, status_url(request))
|
_ = Notifier.deliver_confirmation(request, confirmation_url(request))
|
||||||
{:ok, request}
|
{:ok, request}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -487,6 +522,7 @@ defmodule WhoNeedHelp.Support do
|
||||||
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
|
defp verify_contact(%SupportRequest{} = request), do: {:ok, request}
|
||||||
|
|
||||||
defp notify_verified_request({:ok, {request, :newly_verified}}) do
|
defp notify_verified_request({:ok, {request, :newly_verified}}) do
|
||||||
|
_ = Notifier.deliver_received(request, status_url(request))
|
||||||
_ = Notifier.deliver_operator_alert(request)
|
_ = Notifier.deliver_operator_alert(request)
|
||||||
|
|
||||||
event = {:support_request_updated, request.id}
|
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({:ok, {request, :already_verified}}), do: {:ok, request}
|
||||||
defp notify_verified_request(result), do: result
|
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
|
defp preload_conversation(%SupportRequest{} = request) do
|
||||||
Repo.preload(
|
Repo.preload(
|
||||||
request,
|
request,
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,21 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
alias WhoNeedHelp.Help
|
alias WhoNeedHelp.Help
|
||||||
|
alias WhoNeedHelp.ContentRemoval.Notice
|
||||||
alias WhoNeedHelp.Help.HelpRequest
|
alias WhoNeedHelp.Help.HelpRequest
|
||||||
alias WhoNeedHelp.Repo
|
alias WhoNeedHelp.Repo
|
||||||
|
alias WhoNeedHelp.Support.SupportRequest
|
||||||
alias WhoNeedHelp.Tracking
|
alias WhoNeedHelp.Tracking
|
||||||
alias WhoNeedHelp.Trust.RateLimiter
|
alias WhoNeedHelp.Trust.{AuditEvent, RateLimiter}
|
||||||
|
|
||||||
@impl Oban.Worker
|
@impl Oban.Worker
|
||||||
def perform(_job) do
|
def perform(_job) do
|
||||||
now = DateTime.utc_now(:second)
|
now = DateTime.utc_now(:second)
|
||||||
|
|
||||||
with {:ok, expired} <- expire_available(now, 0),
|
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_buckets, _} = RateLimiter.prune_expired()
|
||||||
{pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now)
|
{pruned_user_tokens, _} = WhoNeedHelp.Accounts.delete_expired_user_tokens(now)
|
||||||
|
|
||||||
|
|
@ -22,6 +26,8 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
||||||
expired: expired,
|
expired: expired,
|
||||||
tracking_positions_deleted: cleanup.positions_deleted,
|
tracking_positions_deleted: cleanup.positions_deleted,
|
||||||
tracking_sessions_ended: cleanup.sessions_ended,
|
tracking_sessions_ended: cleanup.sessions_ended,
|
||||||
|
unverified_support_pruned: support_pruned,
|
||||||
|
unverified_removal_pruned: removal_pruned,
|
||||||
rate_limit_buckets_pruned: pruned_buckets,
|
rate_limit_buckets_pruned: pruned_buckets,
|
||||||
user_tokens_pruned: pruned_user_tokens
|
user_tokens_pruned: pruned_user_tokens
|
||||||
}}
|
}}
|
||||||
|
|
@ -65,4 +71,77 @@ defmodule WhoNeedHelp.Workers.ExpireRequests do
|
||||||
{:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,25 @@ defmodule WhoNeedHelpWeb.ContentRemovalController do
|
||||||
end
|
end
|
||||||
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
|
defp create_notice(conn, regime, params) do
|
||||||
case ContentRemoval.create_notice(
|
case ContentRemoval.create_notice(
|
||||||
conn.assigns.current_scope,
|
conn.assigns.current_scope,
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,23 @@ defmodule WhoNeedHelpWeb.SupportController do
|
||||||
end
|
end
|
||||||
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)
|
def add_message(conn, %{"id" => id, "message" => params} = outer_params)
|
||||||
when is_map(params) do
|
when is_map(params) do
|
||||||
token = outer_params["token"]
|
token = outer_params["token"]
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,7 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
get "/support/new", SupportController, :new
|
get "/support/new", SupportController, :new
|
||||||
post "/support", SupportController, :create
|
post "/support", SupportController, :create
|
||||||
get "/support/received", SupportController, :received
|
get "/support/received", SupportController, :received
|
||||||
|
get "/support/cases/:id/verify", SupportController, :verify
|
||||||
get "/support/cases/:id", SupportController, :show
|
get "/support/cases/:id", SupportController, :show
|
||||||
post "/support/cases/:id/messages", SupportController, :add_message
|
post "/support/cases/:id/messages", SupportController, :add_message
|
||||||
post "/support/cases/:id/reopen", SupportController, :reopen
|
post "/support/cases/:id/reopen", SupportController, :reopen
|
||||||
|
|
@ -157,6 +158,7 @@ defmodule WhoNeedHelpWeb.Router do
|
||||||
scope "/", WhoNeedHelpWeb do
|
scope "/", WhoNeedHelpWeb do
|
||||||
pipe_through :browser
|
pipe_through :browser
|
||||||
|
|
||||||
|
get "/legal/content-removal/:id/verify", ContentRemovalController, :verify
|
||||||
get "/legal/content-removal/:id", ContentRemovalController, :show
|
get "/legal/content-removal/:id", ContentRemovalController, :show
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
assert_email_sent(fn email ->
|
assert_email_sent(fn email ->
|
||||||
matches_request =
|
matches_request =
|
||||||
email.subject =~ request.reference and
|
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})
|
if matches_request, do: send(test_pid, {:support_acknowledgement, email})
|
||||||
matches_request
|
matches_request
|
||||||
|
|
@ -37,19 +37,29 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
|
|
||||||
assert_receive {:support_acknowledgement, email}
|
assert_receive {:support_acknowledgement, email}
|
||||||
|
|
||||||
[status_url] =
|
[confirmation_url] =
|
||||||
Regex.run(
|
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
|
email.text_body
|
||||||
)
|
)
|
||||||
|
|
||||||
emailed_token =
|
confirmation_token =
|
||||||
status_url |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() |> Map.fetch!("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, "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} =
|
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.contact_verified_at
|
||||||
assert verified.status == :open
|
assert verified.status == :open
|
||||||
|
|
@ -66,6 +76,12 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
event.action == "support_request.contact_verified" and
|
event.action == "support_request.contact_verified" and
|
||||||
event.target_id == ^request.id
|
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
|
end
|
||||||
|
|
||||||
test "operator alert is sent only after public contact verification" do
|
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
|
refute_receive {:email, _operator_alert}, 50
|
||||||
|
|
||||||
assert {:ok, verified} =
|
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 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 ->
|
assert_email_sent(fn email ->
|
||||||
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
|
email.to == [{"", "support@example.com"}] and email.subject =~ request.reference and
|
||||||
email.text_body =~ "/support/operations" and
|
email.text_body =~ "/support/operations" and
|
||||||
|
|
@ -191,7 +213,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
assert {:ok, verified} =
|
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]
|
assert Enum.map(Support.paginate_for_staff(moderator_scope).entries, & &1.id) == [verified.id]
|
||||||
end
|
end
|
||||||
|
|
@ -432,7 +457,10 @@ defmodule WhoNeedHelp.SupportAndContentRemovalTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
assert {:ok, verified} =
|
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
|
assert verified.contact_verified_at
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
||||||
|
|
||||||
import WhoNeedHelp.AccountsFixtures
|
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.Help.HelpRequest
|
||||||
|
alias WhoNeedHelp.Support.SupportRequest
|
||||||
alias WhoNeedHelp.Trust.AuditEvent
|
alias WhoNeedHelp.Trust.AuditEvent
|
||||||
alias WhoNeedHelp.Workers.ExpireRequests
|
alias WhoNeedHelp.Workers.ExpireRequests
|
||||||
|
|
||||||
|
|
@ -72,4 +74,110 @@ defmodule WhoNeedHelp.Workers.ExpireRequestsTest do
|
||||||
|
|
||||||
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
|
assert {:ok, %{expired: 0}} = ExpireRequests.perform(%Oban.Job{})
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,56 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
assert_email_sent()
|
assert_email_sent()
|
||||||
end
|
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
|
test "removal intake enforces independent contact and client IP limits", %{conn: conn} do
|
||||||
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
previous = Application.get_env(:who_need_help, :rate_limit_policies)
|
||||||
|
|
||||||
|
|
@ -381,15 +431,15 @@ defmodule WhoNeedHelpWeb.SupportControllerTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
assert {:ok, _verified_removal_notice} =
|
assert {:ok, _verified_removal_notice} =
|
||||||
WhoNeedHelp.ContentRemoval.get_by_access_token(
|
WhoNeedHelp.ContentRemoval.verify_by_confirmation_token(
|
||||||
removal_notice.id,
|
removal_notice.id,
|
||||||
WhoNeedHelp.ContentRemoval.access_token(removal_notice)
|
WhoNeedHelp.ContentRemoval.confirmation_token(removal_notice)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert {:ok, _verified_support_request} =
|
assert {:ok, _verified_support_request} =
|
||||||
WhoNeedHelp.Support.get_by_access_token(
|
WhoNeedHelp.Support.verify_by_confirmation_token(
|
||||||
support_request.id,
|
support_request.id,
|
||||||
WhoNeedHelp.Support.access_token(support_request)
|
WhoNeedHelp.Support.confirmation_token(support_request)
|
||||||
)
|
)
|
||||||
|
|
||||||
staff_conn = log_in_user(conn, moderator)
|
staff_conn = log_in_user(conn, moderator)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user