From 1c5c4a25aae0a713c2452810a35e9bacedecd332 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Fri, 12 Jul 2024 12:01:59 +0200 Subject: [PATCH] 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 --- config/.env.dev | 5 + config/.env.test | 4 + config/runtime.exs | 10 + config/test.exs | 5 + extra/lib/plausible/help_scout.ex | 260 +++++++++++ extra/lib/plausible/help_scout/vault.ex | 19 + .../controllers/help_scout_controller.ex | 20 + .../plausible_web/views/help_scout_view.ex | 62 +++ lib/plausible/application.ex | 16 + lib/plausible/billing/plans.ex | 2 +- lib/plausible/sites.ex | 7 + lib/plausible_release.ex | 2 +- lib/plausible_web/router.ex | 4 + test/plausible/help_scout_test.exs | 426 ++++++++++++++++++ .../help_scout_controller_test.exs | 57 +++ 15 files changed, 897 insertions(+), 2 deletions(-) create mode 100644 extra/lib/plausible/help_scout.ex create mode 100644 extra/lib/plausible/help_scout/vault.ex create mode 100644 extra/lib/plausible_web/controllers/help_scout_controller.ex create mode 100644 extra/lib/plausible_web/views/help_scout_view.ex create mode 100644 test/plausible/help_scout_test.exs create mode 100644 test/plausible_web/controllers/help_scout_controller_test.exs diff --git a/config/.env.dev b/config/.env.dev index 92b855456..cb957fe12 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -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 diff --git a/config/.env.test b/config/.env.test index 322bcdc60..f9098926b 100644 --- a/config/.env.test +++ b/config/.env.test @@ -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 diff --git a/config/runtime.exs b/config/runtime.exs index e54dc7fd1..02b9f11fc 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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) diff --git a/config/test.exs b/config/test.exs index 110c9965c..004264858 100644 --- a/config/test.exs +++ b/config/test.exs @@ -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} + ] diff --git a/extra/lib/plausible/help_scout.ex b/extra/lib/plausible/help_scout.ex new file mode 100644 index 000000000..3005246c7 --- /dev/null +++ b/extra/lib/plausible/help_scout.ex @@ -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 diff --git a/extra/lib/plausible/help_scout/vault.ex b/extra/lib/plausible/help_scout/vault.ex new file mode 100644 index 000000000..ef4017ff5 --- /dev/null +++ b/extra/lib/plausible/help_scout/vault.ex @@ -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 diff --git a/extra/lib/plausible_web/controllers/help_scout_controller.ex b/extra/lib/plausible_web/controllers/help_scout_controller.ex new file mode 100644 index 000000000..1015a5712 --- /dev/null +++ b/extra/lib/plausible_web/controllers/help_scout_controller.ex @@ -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 diff --git a/extra/lib/plausible_web/views/help_scout_view.ex b/extra/lib/plausible_web/views/help_scout_view.ex new file mode 100644 index 000000000..52794e2da --- /dev/null +++ b/extra/lib/plausible_web/views/help_scout_view.ex @@ -0,0 +1,62 @@ +defmodule PlausibleWeb.HelpScoutView do + use PlausibleWeb, :view + + def render("callback.html", assigns) do + ~H""" + + + + + + Helpscout Customer Details + + + + + <%= if @conn.assigns[:error] do %> +

+ Failed to get details: <%= @error %> +

+ <% else %> +
+

+ Status +

+

+ <%= @status_label %> +

+
+ +
+

+ Plan +

+

+ <%= @plan_label %> +

+
+ <% end %> + + + """ + end +end diff --git a/lib/plausible/application.ex b/lib/plausible/application.ex index 12ba22c31..01eec232d 100644 --- a/lib/plausible/application.ex +++ b/lib/plausible/application.ex @@ -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) diff --git a/lib/plausible/billing/plans.ex b/lib/plausible/billing/plans.ex index 055c9d55a..901ed2d96 100644 --- a/lib/plausible/billing/plans.ex +++ b/lib/plausible/billing/plans.ex @@ -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 diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 4fbf857ab..a23c72952 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -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() diff --git a/lib/plausible_release.ex b/lib/plausible_release.ex index e4c39caf4..9d23382e7 100644 --- a/lib/plausible_release.ex +++ b/lib/plausible_release.ex @@ -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) diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index ff30122fa..4cdea939c 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -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 diff --git a/test/plausible/help_scout_test.exs b/test/plausible/help_scout_test.exs new file mode 100644 index 000000000..fe1467552 --- /dev/null +++ b/test/plausible/help_scout_test.exs @@ -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 diff --git a/test/plausible_web/controllers/help_scout_controller_test.exs b/test/plausible_web/controllers/help_scout_controller_test.exs new file mode 100644 index 000000000..7824d3a3f --- /dev/null +++ b/test/plausible_web/controllers/help_scout_controller_test.exs @@ -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