mirror of
https://github.com/plausible/analytics.git
synced 2024-11-29 14:04:12 +03:00
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:
parent
ff778436c4
commit
1c5c4a25aa
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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}
|
||||
]
|
||||
|
260
extra/lib/plausible/help_scout.ex
Normal file
260
extra/lib/plausible/help_scout.ex
Normal 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
|
19
extra/lib/plausible/help_scout/vault.ex
Normal file
19
extra/lib/plausible/help_scout/vault.ex
Normal 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
|
20
extra/lib/plausible_web/controllers/help_scout_controller.ex
Normal file
20
extra/lib/plausible_web/controllers/help_scout_controller.ex
Normal 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
|
62
extra/lib/plausible_web/views/help_scout_view.ex
Normal file
62
extra/lib/plausible_web/views/help_scout_view.ex
Normal 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
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
426
test/plausible/help_scout_test.exs
Normal file
426
test/plausible/help_scout_test.exs
Normal 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
|
@ -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
|
Loading…
Reference in New Issue
Block a user