HelpScout integration (#4327)

* Implement basic HelpScout integration

* Set 127.0.0.1 as a default customer IP in `Plans.with_prices/2`

* Use `is_nil/1` instead of `... == nil` (h/t @aerosol)

* Use `Path.join/1,2` to build API URLs a bit more safely (h/t @aerosol)

* Check for locked sites entirely within query logic

* Move conditional start of HelpScout vault to compile-time

* Include customer_id in error sent to Sentry

* Use `Plug.Crypto.secure_compare/2` for constant-time signature comparison

* Refactor token request function

* Use `Path.join/1` in one more spot

* Use route helper to build CRM URL
This commit is contained in:
Adrian Gruntkowski 2024-07-12 12:01:59 +02:00 committed by GitHub
parent ff778436c4
commit 1c5c4a25aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 897 additions and 2 deletions

View File

@ -29,4 +29,9 @@ S3_ENDPOINT=http://localhost:10000
S3_EXPORTS_BUCKET=dev-exports
S3_IMPORTS_BUCKET=dev-imports
HELP_SCOUT_APP_ID=fake_app_id
HELP_SCOUT_APP_SECRET=fake_app_secret
HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
VERIFICATION_ENABLED=true

View File

@ -15,6 +15,10 @@ IP_GEOLOCATION_DB=test/priv/GeoLite2-City-Test.mmdb
SITE_DEFAULT_INGEST_THRESHOLD=1000000
GOOGLE_CLIENT_ID=fake_client_id
GOOGLE_CLIENT_SECRET=fake_client_secret
HELP_SCOUT_APP_ID=fake_app_id
HELP_SCOUT_APP_SECRET=fake_app_secret
HELP_SCOUT_SIGNATURE_KEY=fake_signature_key
HELP_SCOUT_VAULT_KEY=ym9ZQg0KPNGCH3C2eD5y6KpL0tFzUqAhwxQO6uEv/ZM=
S3_DISABLED=false
S3_ACCESS_KEY_ID=minioadmin

View File

@ -207,6 +207,10 @@ paddle_vendor_id = get_var_from_path_or_env(config_dir, "PADDLE_VENDOR_ID")
google_cid = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_ID")
google_secret = get_var_from_path_or_env(config_dir, "GOOGLE_CLIENT_SECRET")
postmark_api_key = get_var_from_path_or_env(config_dir, "POSTMARK_API_KEY")
help_scout_app_id = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_ID")
help_scout_app_secret = get_var_from_path_or_env(config_dir, "HELP_SCOUT_APP_SECRET")
help_scout_signature_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_SIGNATURE_KEY")
help_scout_vault_key = get_var_from_path_or_env(config_dir, "HELP_SCOUT_VAULT_KEY")
{otel_sampler_ratio, ""} =
config_dir
@ -372,6 +376,12 @@ config :plausible, :google,
api_url: "https://www.googleapis.com",
reporting_api_url: "https://analyticsreporting.googleapis.com"
config :plausible, Plausible.HelpScout,
app_id: help_scout_app_id,
app_secret: help_scout_app_secret,
signature_key: help_scout_signature_key,
vault_key: help_scout_vault_key
config :plausible, :imported,
max_buffer_size: get_int_from_path_or_env(config_dir, "IMPORTED_MAX_BUFFER_SIZE", 10_000)

View File

@ -41,3 +41,8 @@ config :plausible, Plausible.Verification.Checks.Installation,
req_opts: [
plug: {Req.Test, Plausible.Verification.Checks.Installation}
]
config :plausible, Plausible.HelpScout,
req_opts: [
plug: {Req.Test, Plausible.HelpScout}
]

View File

@ -0,0 +1,260 @@
defmodule Plausible.HelpScout do
@moduledoc """
HelpScout callback API logic.
"""
import Ecto.Query
alias Plausible.Billing
alias Plausible.Billing.Subscription
alias Plausible.HelpScout.Vault
alias Plausible.Repo
alias PlausibleWeb.Router.Helpers, as: Routes
require Plausible.Billing.Subscription.Status
@base_api_url "https://api.helpscout.net"
@signature_field "X-HelpScout-Signature"
@doc """
Validates signature against secret key configured for the
HelpScout application.
NOTE: HelpScout signature generation procedure at
https://developer.helpscout.com/apps/guides/signature-validation/
fails to mention that it's implicitly dependent on request params
order getting preserved. PHP arrays are ordered maps, so they provide
this guarantee. Here, on the other hand, we have to determine the original
order of the keys directly from the query string and serialize
params to JSON using wrapper struct, informing Jason to put the values
in the serialized object in this particular order matching query string.
"""
@spec validate_signature(Plug.Conn.t()) :: :ok | {:error, :missing_signature | :bad_signature}
def validate_signature(conn) do
params = conn.params
keys =
conn.query_string
|> String.split("&")
|> Enum.map(fn part ->
part |> String.split("=") |> List.first()
end)
|> Enum.reject(&(&1 == @signature_field))
signature = params[@signature_field]
if is_binary(signature) do
signature_key = Keyword.fetch!(config(), :signature_key)
ordered_data = Enum.map(keys, fn key -> {key, params[key]} end)
data = Jason.encode!(%Jason.OrderedObject{values: ordered_data})
calculated =
:hmac
|> :crypto.mac(:sha, signature_key, data)
|> Base.encode64()
if Plug.Crypto.secure_compare(signature, calculated) do
:ok
else
{:error, :bad_signature}
end
else
{:error, :missing_signature}
end
end
@spec get_customer_details(String.t()) :: {:ok, map()} | {:error, any()}
def get_customer_details(customer_id) do
with {:ok, emails} <- get_customer_emails(customer_id),
{:ok, user} <- get_user(emails) do
user = Plausible.Users.with_subscription(user.id)
plan = Billing.Plans.get_subscription_plan(user.subscription)
{:ok,
%{
status_label: status_label(user),
status_link:
Routes.kaffy_resource_url(PlausibleWeb.Endpoint, :show, :auth, :user, user.id),
plan_label: plan_label(user.subscription, plan),
plan_link: plan_link(user.subscription)
}}
end
end
defp plan_link(nil), do: "#"
defp plan_link(%{paddle_subscription_id: paddle_id}) do
Path.join([
Billing.PaddleApi.vendors_domain(),
"/subscriptions/customers/manage/",
paddle_id
])
end
defp status_label(user) do
subscription_active? = Billing.Subscriptions.active?(user.subscription)
trial? = Plausible.Users.on_trial?(user)
cond do
not subscription_active? and not trial? and is_nil(user.trial_expiry_date) ->
"None"
is_nil(user.subscription) and not trial? ->
"Expired trial"
trial? ->
"Trial"
user.subscription.status == Subscription.Status.deleted() ->
if subscription_active? do
"Pending cancellation"
else
"Canceled"
end
user.subscription.status == Subscription.Status.paused() ->
"Paused"
Plausible.Sites.owned_sites_locked?(user) ->
"Dashboard locked"
subscription_active? ->
"Paid"
end
end
defp plan_label(_, nil) do
"None"
end
defp plan_label(_, :free_10k) do
"Free 10k"
end
defp plan_label(subscription, %Billing.Plan{} = plan) do
[plan] = Billing.Plans.with_prices([plan])
interval = Billing.Plans.subscription_interval(subscription)
quota = PlausibleWeb.AuthView.subscription_quota(subscription, [])
price =
cond do
interval == "monthly" && plan.monthly_cost ->
Billing.format_price(plan.monthly_cost)
interval == "yearly" && plan.yearly_cost ->
Billing.format_price(plan.yearly_cost)
true ->
"N/A"
end
"#{quota} Plan (#{price} #{interval})"
end
defp plan_label(subscription, %Billing.EnterprisePlan{} = plan) do
quota = PlausibleWeb.AuthView.subscription_quota(subscription, [])
price_amount = Billing.Plans.get_price_for(plan, "127.0.0.1")
price =
if price_amount do
Billing.format_price(price_amount)
else
"N/A"
end
"#{quota} Enterprise Plan (#{price} #{plan.billing_interval})"
end
defp get_user(emails) do
user =
from(u in Plausible.Auth.User, where: u.email in ^emails, limit: 1)
|> Repo.one()
if user do
{:ok, user}
else
{:error, :not_found}
end
end
defp get_customer_emails(customer_id, opts \\ []) do
refresh? = Keyword.get(opts, :refresh?, true)
token = get_token!()
url = Path.join([@base_api_url, "/v2/customers/", customer_id])
extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts = Keyword.merge([auth: {:bearer, token}], extra_opts)
case Req.get(url, opts) do
{:ok, %{body: %{"_embedded" => %{"emails" => [_ | _] = emails}}}} ->
{:ok, Enum.map(emails, & &1["value"])}
{:ok, %{status: 200}} ->
{:error, :no_emails}
{:ok, %{status: 404}} ->
{:error, :not_found}
{:ok, %{status: 401}} ->
if refresh? do
refresh_token!()
get_customer_emails(customer_id, refresh?: false)
else
{:error, :auth_failed}
end
error ->
Sentry.capture_message("Failed to obtain customer data from HelpScout API",
extra: %{error: inspect(error), customer_id: customer_id}
)
{:error, :unknown}
end
end
defp get_token!() do
token =
"SELECT access_token FROM help_scout_credentials ORDER BY id DESC LIMIT 1"
|> Repo.query!()
|> Map.get(:rows)
|> List.first()
case token do
[token] when is_binary(token) ->
Vault.decrypt!(token)
_ ->
refresh_token!()
end
end
defp refresh_token!() do
url = Path.join(@base_api_url, "/v2/oauth2/token")
credentials = config()
params = [
grant_type: "client_credentials",
client_id: Keyword.fetch!(credentials, :app_id),
client_secret: Keyword.fetch!(credentials, :app_secret)
]
extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || []
opts = Keyword.merge([form: params], extra_opts)
%{status: 200, body: %{"access_token" => token}} = Req.post!(url, opts)
now = NaiveDateTime.utc_now(:second)
Repo.insert_all("help_scout_credentials", [
[access_token: Vault.encrypt!(token), inserted_at: now, updated_at: now]
])
token
end
defp config() do
Application.fetch_env!(:plausible, __MODULE__)
end
end

View File

@ -0,0 +1,19 @@
defmodule Plausible.HelpScout.Vault do
@moduledoc """
Provides a vault that will be used to encrypt/decrypt the stored HelpScout API access tokens.
"""
use Cloak.Vault, otp_app: :plausible
@impl GenServer
def init(config) do
{key, config} = Keyword.pop!(config, :key)
config =
Keyword.put(config, :ciphers,
default: {Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", iv_length: 12, key: key}
)
{:ok, config}
end
end

View File

@ -0,0 +1,20 @@
defmodule PlausibleWeb.HelpScoutController do
use PlausibleWeb, :controller
alias Plausible.HelpScout
def callback(conn, %{"customer-id" => customer_id}) do
conn =
conn
|> delete_resp_header("x-frame-options")
|> put_layout(false)
with :ok <- HelpScout.validate_signature(conn),
{:ok, details} <- HelpScout.get_customer_details(customer_id) do
render(conn, "callback.html", details)
else
{:error, error} ->
render(conn, "callback.html", error: inspect(error))
end
end
end

View File

@ -0,0 +1,62 @@
defmodule PlausibleWeb.HelpScoutView do
use PlausibleWeb, :view
def render("callback.html", assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Helpscout Customer Details</title>
<style type="text/css">
* {
margin: 0;
padding: 0;
}
body {
font-family: Helvetica, Arial, Sans-Serif;
font-size: 14px;
}
p {
margin-left: 1.25em;
}
.value {
margin-bottom: 1.25em;
font-weight: bold;
}
</style>
</head>
<body>
<%= if @conn.assigns[:error] do %>
<p>
Failed to get details: <%= @error %>
</p>
<% else %>
<div class="status">
<p class="label">
Status
</p>
<p class="value">
<a href={@status_link} target="_blank"><%= @status_label %></a>
</p>
</div>
<div class="plan">
<p class="label">
Plan
</p>
<p class="value">
<a href={@plan_link} target="_blank"><%= @plan_label %></a>
</p>
</div>
<% end %>
</body>
</html>
"""
end
end

View File

@ -93,6 +93,10 @@ defmodule Plausible.Application do
Plausible.PromEx
]
on_ee do
children = children ++ help_scout_vault()
end
opts = [strategy: :one_for_one, name: Plausible.Supervisor]
setup_request_logging()
@ -111,6 +115,18 @@ defmodule Plausible.Application do
:ok
end
on_ee do
defp help_scout_vault() do
help_scout_vault_key =
:plausible
|> Application.fetch_env!(Plausible.HelpScout)
|> Keyword.fetch!(:vault_key)
|> Base.decode64!()
[{Plausible.HelpScout.Vault, key: help_scout_vault_key}]
end
end
defp totp_vault_key() do
:plausible
|> Application.fetch_env!(Plausible.Auth.TOTP)

View File

@ -151,7 +151,7 @@ defmodule Plausible.Billing.Plans do
response, fills in the `monthly_cost` and `yearly_cost` fields for each
given plan and returns the new list of plans with completed information.
"""
def with_prices([_ | _] = plans, customer_ip) do
def with_prices([_ | _] = plans, customer_ip \\ "127.0.0.1") do
product_ids = Enum.flat_map(plans, &[&1.monthly_product_id, &1.yearly_product_id])
case Plausible.Billing.paddle_api().fetch_prices(product_ids, customer_ip) do

View File

@ -338,6 +338,13 @@ defmodule Plausible.Sites do
)
end
def owned_sites_locked?(user) do
user
|> owned_sites_query()
|> where([s], s.locked == true)
|> Repo.exists?()
end
def owned_sites_count(user) do
user
|> owned_sites_query()

View File

@ -85,7 +85,7 @@ defmodule Plausible.Release do
plans =
Plausible.Billing.Plans.all()
|> Plausible.Billing.Plans.with_prices("127.0.0.1")
|> Plausible.Billing.Plans.with_prices()
|> Enum.map(fn plan ->
plan = Map.from_struct(plan)

View File

@ -290,6 +290,10 @@ defmodule PlausibleWeb.Router do
get "/auth/google/callback", AuthController, :google_auth_callback
on_ee do
get "/helpscout/callback", HelpScoutController, :callback
end
get "/", PageController, :index
get "/billing/change-plan/preview/:plan_id", BillingController, :change_plan_preview

View File

@ -0,0 +1,426 @@
defmodule Plausible.HelpScoutTest do
use Plausible.DataCase, async: true
use Plausible
@moduletag :ee_only
on_ee do
alias Plausible.Billing.Subscription
alias Plausible.HelpScout
alias Plausible.Repo
require Plausible.Billing.Subscription.Status
@v4_business_monthly_plan_id "857105"
@v4_business_yearly_plan_id "857087"
describe "validate_signature/1" do
test "returns error on missing signature" do
conn =
:get
|> Plug.Test.conn("/?foo=one&bar=two&baz=three")
|> Plug.Conn.fetch_query_params()
assert {:error, :missing_signature} = HelpScout.validate_signature(conn)
end
test "returns error on invalid signature" do
conn =
:get
|> Plug.Test.conn("/?foo=one&bar=two&baz=three&X-HelpScout-Signature=invalid")
|> Plug.Conn.fetch_query_params()
assert {:error, :bad_signature} = HelpScout.validate_signature(conn)
end
test "passes for valid signature" do
signature_key = Application.fetch_env!(:plausible, HelpScout)[:signature_key]
data = ~s|{"foo":"one","bar":"two","baz":"three"}|
signature =
:hmac
|> :crypto.mac(:sha, signature_key, data)
|> Base.encode64()
|> URI.encode_www_form()
conn =
:get
|> Plug.Test.conn("/?foo=one&bar=two&baz=three&X-HelpScout-Signature=#{signature}")
|> Plug.Conn.fetch_query_params()
assert :ok = HelpScout.validate_signature(conn)
end
end
describe "get_customer_details/1" do
test "returns details for user on trial" do
%{id: user_id, email: email} = insert(:user)
stub_help_scout_requests(email)
crm_url = "#{PlausibleWeb.Endpoint.url()}/crm/auth/user/#{user_id}"
assert {:ok,
%{
status_link: ^crm_url,
status_label: "Trial",
plan_link: "#",
plan_label: "None"
}} = HelpScout.get_customer_details("500")
end
test "returns for user without trial or subscription" do
%{email: email} = insert(:user, trial_expiry_date: nil)
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "None",
plan_link: "#",
plan_label: "None"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with trial expired" do
%{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Expired trial",
plan_link: "#",
plan_label: "None"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with paid subscription on standard plan" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
%{paddle_subscription_id: paddle_subscription_id} =
insert(:subscription, user: user, paddle_plan_id: @v4_business_monthly_plan_id)
stub_help_scout_requests(email)
plan_link =
"https://vendors.paddle.com/subscriptions/customers/manage/#{paddle_subscription_id}"
assert {:ok,
%{
status_link: _,
status_label: "Paid",
plan_link: ^plan_link,
plan_label: "10k Plan (€10 monthly)"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with paid subscription on standard yearly plan" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
%{paddle_subscription_id: paddle_subscription_id} =
insert(:subscription, user: user, paddle_plan_id: @v4_business_yearly_plan_id)
stub_help_scout_requests(email)
plan_link =
"https://vendors.paddle.com/subscriptions/customers/manage/#{paddle_subscription_id}"
assert {:ok,
%{
status_link: _,
status_label: "Paid",
plan_link: ^plan_link,
plan_label: "10k Plan (€100 yearly)"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with paid subscription on free 10k plan" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
insert(:subscription, user: user, paddle_plan_id: "free_10k")
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Paid",
plan_link: _,
plan_label: "Free 10k"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with paid subscription on enterprise plan" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
ep =
insert(:enterprise_plan,
features: [Plausible.Billing.Feature.StatsAPI],
user_id: user.id
)
insert(:subscription, user: user, paddle_plan_id: ep.paddle_plan_id)
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Paid",
plan_link: _,
plan_label: "1M Enterprise Plan (€10 monthly)"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with paid subscription on yearly enterprise plan" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
ep =
insert(:enterprise_plan,
features: [Plausible.Billing.Feature.StatsAPI],
user_id: user.id,
billing_interval: :yearly
)
insert(:subscription, user: user, paddle_plan_id: ep.paddle_plan_id)
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Paid",
plan_link: _,
plan_label: "1M Enterprise Plan (€10 yearly)"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with subscription pending cancellation" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
insert(:subscription,
user: user,
status: Subscription.Status.deleted(),
paddle_plan_id: @v4_business_monthly_plan_id
)
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Pending cancellation",
plan_link: _,
plan_label: "10k Plan (€10 monthly)"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with canceled subscription" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
insert(:subscription,
user: user,
status: Subscription.Status.deleted(),
paddle_plan_id: @v4_business_monthly_plan_id,
next_bill_date: Date.add(Date.utc_today(), -1)
)
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Canceled",
plan_link: _,
plan_label: "10k Plan (€10 monthly)"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with paused subscription" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
insert(:subscription,
user: user,
status: Subscription.Status.paused(),
paddle_plan_id: @v4_business_monthly_plan_id
)
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Paused",
plan_link: _,
plan_label: "10k Plan (€10 monthly)"
}} = HelpScout.get_customer_details("500")
end
test "returns for user with locked site" do
user = %{email: email} = insert(:user, trial_expiry_date: Date.add(Date.utc_today(), -1))
insert(:site, members: [user], locked: true)
insert(:subscription, user: user, paddle_plan_id: @v4_business_monthly_plan_id)
stub_help_scout_requests(email)
assert {:ok,
%{
status_link: _,
status_label: "Dashboard locked",
plan_link: _,
plan_label: "10k Plan (€10 monthly)"
}} = HelpScout.get_customer_details("500")
end
test "returns error when no matching user found in database" do
insert(:user)
stub_help_scout_requests("another@example.com")
assert {:error, :not_found} = HelpScout.get_customer_details("500")
end
test "returns error when no customer found in Help Scout" do
Req.Test.stub(HelpScout, fn
%{request_path: "/v2/oauth2/token"} = conn ->
Req.Test.json(conn, %{
"token_type" => "bearer",
"access_token" => "369dbb08be58430086d2f8bd832bc1eb",
"expires_in" => 172_800
})
%{request_path: "/v2/customers/500"} = conn ->
conn
|> Plug.Conn.put_status(404)
|> Req.Test.text("Not found")
end)
assert {:error, :not_found} = HelpScout.get_customer_details("500")
end
test "returns error when found customer has no emails" do
Req.Test.stub(HelpScout, fn
%{request_path: "/v2/oauth2/token"} = conn ->
Req.Test.json(conn, %{
"token_type" => "bearer",
"access_token" => "369dbb08be58430086d2f8bd832bc1eb",
"expires_in" => 172_800
})
%{request_path: "/v2/customers/500"} = conn ->
Req.Test.json(conn, %{
"id" => 500,
"_embedded" => %{
"emails" => []
}
})
end)
assert {:error, :no_emails} = HelpScout.get_customer_details("500")
end
test "uses existing access token when available" do
%{email: email} = insert(:user)
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
Repo.insert_all("help_scout_credentials", [
[
access_token: HelpScout.Vault.encrypt!("VerySecret"),
inserted_at: now,
updated_at: now
]
])
Req.Test.stub(HelpScout, fn %{request_path: "/v2/customers/500"} = conn ->
Req.Test.json(conn, %{
"id" => 500,
"_embedded" => %{
"emails" => [
%{
"id" => 1,
"value" => email,
"type" => "home"
}
]
}
})
end)
assert {:ok, _} = HelpScout.get_customer_details("500")
end
test "refreshes token on expiry" do
%{email: email} = insert(:user)
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
Repo.insert_all("help_scout_credentials", [
[
access_token: HelpScout.Vault.encrypt!("VerySecretExpired"),
inserted_at: now,
updated_at: now
]
])
Req.Test.stub(HelpScout, fn
%{request_path: "/v2/oauth2/token"} = conn ->
Req.Test.json(conn, %{
"token_type" => "bearer",
"access_token" => "VerySecretNew",
"expires_in" => 172_800
})
%{request_path: "/v2/customers/500"} = conn ->
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer VerySecretExpired"] ->
conn
|> Plug.Conn.put_status(401)
|> Req.Test.text("Token expired")
["Bearer VerySecretNew"] ->
Req.Test.json(conn, %{
"id" => 500,
"_embedded" => %{
"emails" => [
%{
"id" => 1,
"value" => email,
"type" => "home"
}
]
}
})
end
end)
assert {:ok, _} = HelpScout.get_customer_details("500")
end
end
defp stub_help_scout_requests(email) do
Req.Test.stub(HelpScout, fn
%{request_path: "/v2/oauth2/token"} = conn ->
Req.Test.json(conn, %{
"token_type" => "bearer",
"access_token" => "369dbb08be58430086d2f8bd832bc1eb",
"expires_in" => 172_800
})
%{request_path: "/v2/customers/500"} = conn ->
Req.Test.json(conn, %{
"id" => 500,
"_embedded" => %{
"emails" => [
%{
"id" => 1,
"value" => email,
"type" => "home"
}
]
}
})
end)
end
end
end

View File

@ -0,0 +1,57 @@
defmodule PlausibleWeb.HelpScoutControllerTest do
use PlausibleWeb.ConnCase, async: true
use Plausible
@moduletag :ee_only
on_ee do
alias Plausible.HelpScout
describe "callback/2" do
test "returns details on success", %{conn: conn} do
user = insert(:user)
signature_key = Application.fetch_env!(:plausible, HelpScout)[:signature_key]
data = ~s|{"customer-id":"500"}|
signature =
:hmac
|> :crypto.mac(:sha, signature_key, data)
|> Base.encode64()
|> URI.encode_www_form()
Req.Test.stub(HelpScout, fn
%{request_path: "/v2/oauth2/token"} = conn ->
Req.Test.json(conn, %{
"token_type" => "bearer",
"access_token" => "369dbb08be58430086d2f8bd832bc1eb",
"expires_in" => 172_800
})
%{request_path: "/v2/customers/500"} = conn ->
Req.Test.json(conn, %{
"id" => 500,
"_embedded" => %{
"emails" => [
%{
"id" => 1,
"value" => user.email,
"type" => "home"
}
]
}
})
end)
conn = get(conn, "/helpscout/callback?customer-id=500&X-HelpScout-Signature=#{signature}")
assert html_response(conn, 200) =~ "/crm/auth/user/#{user.id}"
end
test "returns error on failure", %{conn: conn} do
conn = get(conn, "/helpscout/callback?customer-id=500&X-HelpScout-Signature=invalid")
assert html_response(conn, 200) =~ "bad_signature"
end
end
end
end