Implement Stats API feature gate (#3411)

* Include ApiKey functions in Auth context

* Make feature notice work without %Site{}

Previously the extra feature notice required a %Site{} in order to check
the owner plan. However, not every feature is scoped by site, for
example the Stats API. For features like this, a %User{} is required,
and not a %Site{}.

This commit replaces the `:site` param with `:billable_user`, which is
common to both site and user-scoped features.

* Add stats_api to the list of extra features

* Limit API Key creation based on user plan
This commit is contained in:
Vini Brasil 2023-10-11 17:24:16 -03:00 committed by GitHub
parent 192aefc493
commit c0fe2a3996
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 298 additions and 170 deletions

View File

@ -2,6 +2,8 @@ defmodule Plausible.Auth.ApiKey do
use Ecto.Schema
import Ecto.Changeset
@type t() :: %__MODULE__{}
@required [:user_id, :name]
@optional [:key, :scopes, :hourly_request_limit]
schema "api_keys" do
@ -22,7 +24,7 @@ defmodule Plausible.Auth.ApiKey do
schema
|> cast(attrs, @required ++ @optional)
|> validate_required(@required)
|> generate_key()
|> maybe_put_key()
|> process_key()
|> unique_constraint(:key_hash, error_key: :key)
end
@ -50,12 +52,12 @@ defmodule Plausible.Auth.ApiKey do
def process_key(changeset), do: changeset
defp generate_key(changeset) do
if !changeset.changes[:key] do
key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
change(changeset, key: key)
else
defp maybe_put_key(changeset) do
if get_change(changeset, :key) do
changeset
else
key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
put_change(changeset, :key, key)
end
end

View File

@ -125,4 +125,42 @@ defmodule Plausible.Auth do
|> Ecto.assoc(:enterprise_plan)
|> Repo.exists?()
end
@spec create_api_key(Auth.User.t(), String.t(), String.t()) ::
{:ok, Auth.ApiKey.t()} | {:error, Ecto.Changeset.t()}
def create_api_key(user, name, key) do
params = %{name: name, user_id: user.id, key: key}
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, params)
with :ok <- Plausible.Billing.Feature.StatsAPI.check_availability(user),
do: Repo.insert(changeset)
end
@spec delete_api_key(Auth.User.t(), integer()) :: :ok | {:error, :not_found}
def delete_api_key(user, id) do
query = from(api_key in Auth.ApiKey, where: api_key.id == ^id and api_key.user_id == ^user.id)
case Repo.delete_all(query) do
{1, _} -> :ok
{0, _} -> {:error, :not_found}
end
end
@spec find_api_key(String.t()) :: {:ok, Auth.ApiKey.t()} | {:error, :invalid_api_key}
def find_api_key(raw_key) do
hashed_key = Auth.ApiKey.do_hash(raw_key)
query =
from(api_key in Auth.ApiKey,
join: user in assoc(api_key, :user),
where: api_key.key_hash == ^hashed_key,
preload: [user: user]
)
if found = Repo.one(query) do
{:ok, found}
else
{:error, :invalid_api_key}
end
end
end

View File

@ -47,8 +47,8 @@ defmodule Plausible.Billing.Feature do
@doc """
Checks whether the site owner or the user plan includes the given feature.
"""
@callback check_availability(Plausible.Site.t() | Plausible.Auth.User.t()) ::
:ok | {:error, :upgrade_required}
@callback check_availability(Plausible.Auth.User.t()) ::
:ok | {:error, :upgrade_required} | {:error, :not_implemented}
@features [
Plausible.Billing.Feature.Funnels,
@ -77,22 +77,17 @@ defmodule Plausible.Billing.Feature do
def toggle_field, do: Keyword.get(unquote(opts), :toggle_field)
@impl true
def enabled?(site) do
def enabled?(%Plausible.Site{} = site) do
site = Plausible.Repo.preload(site, :owner)
cond do
check_availability(site) !== :ok -> false
check_availability(site.owner) !== :ok -> false
is_nil(toggle_field()) -> true
true -> Map.get(site, toggle_field())
true -> Map.fetch!(site, toggle_field())
end
end
@impl true
def check_availability(site_or_user)
def check_availability(%Plausible.Site{} = site) do
site = Plausible.Repo.preload(site, :owner)
check_availability(site.owner)
end
def check_availability(%Plausible.Auth.User{} = user) do
extra_feature = Keyword.get(unquote(opts), :extra_feature)
@ -105,9 +100,10 @@ defmodule Plausible.Billing.Feature do
end
@impl true
def toggle(site, opts \\ []) do
def toggle(%Plausible.Site{} = site, opts \\ []) do
with key when not is_nil(key) <- toggle_field(),
:ok <- check_availability(site) do
site <- Plausible.Repo.preload(site, :owner),
:ok <- check_availability(site.owner) do
override = Keyword.get(opts, :override)
toggle = if is_boolean(override), do: override, else: !Map.fetch!(site, toggle_field())
@ -119,8 +115,6 @@ defmodule Plausible.Billing.Feature do
{:error, :upgrade_required} -> {:error, :upgrade_required}
end
end
defoverridable check_availability: 1, toggle: 1, toggle: 2
end
end
end
@ -154,3 +148,10 @@ defmodule Plausible.Billing.Feature.Props do
toggle_field: :props_enabled,
extra_feature: :props
end
defmodule Plausible.Billing.Feature.StatsAPI do
@moduledoc false
use Plausible.Billing.Feature,
display_name: "Stats API",
extra_feature: :stats_api
end

View File

@ -31,7 +31,7 @@ defmodule Plausible.Billing.Plans do
alias Plausible.Billing.{Subscription, Plan, EnterprisePlan}
alias Plausible.Auth.User
@available_features ["props", "revenue_goals", "funnels"]
@available_features ["props", "revenue_goals", "funnels", "stats_api"]
for f <- [
:plans_v1,

View File

@ -162,7 +162,7 @@ defmodule Plausible.Billing.Quota do
end)
end
@all_features [:props, :revenue_goals, :funnels]
@all_features [:props, :revenue_goals, :funnels, :stats_api]
@doc """
Returns a list of extra features the user can use. Trial users have the
ability to use all features during their trial.

View File

@ -214,7 +214,8 @@ defmodule Plausible.Goals do
defp maybe_check_feature_access(site, changeset) do
if Ecto.Changeset.get_field(changeset, :currency) do
Plausible.Billing.Feature.RevenueGoals.check_availability(site)
site = Plausible.Repo.preload(site, :owner)
Plausible.Billing.Feature.RevenueGoals.check_availability(site.owner)
else
:ok
end

View File

@ -24,7 +24,8 @@ defmodule Plausible.Props do
data to be dropped or lost.
"""
def allow(site, prop_or_props) do
with :ok <- Plausible.Billing.Feature.Props.check_availability(site) do
with site <- Plausible.Repo.preload(site, :owner),
:ok <- Plausible.Billing.Feature.Props.check_availability(site.owner) do
site
|> allow_changeset(prop_or_props)
|> Plausible.Repo.update()

View File

@ -7,7 +7,7 @@ defmodule PlausibleWeb.Components.Billing do
alias PlausibleWeb.Router.Helpers, as: Routes
alias Plausible.Billing.Subscription
attr(:site, Plausible.Site, required: true)
attr(:billable_user, Plausible.Auth.User, required: true)
attr(:current_user, Plausible.Auth.User, required: true)
attr(:feature_mod, :atom, required: true, values: Plausible.Billing.Feature.list())
attr(:grandfathered?, :boolean, default: false)
@ -17,8 +17,8 @@ defmodule PlausibleWeb.Components.Billing do
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def extra_feature_notice(assigns) do
private_preview? = not FunWithFlags.enabled?(:business_tier, for: assigns.current_user)
owner? = assigns.current_user.id == assigns.site.owner.id
has_access? = assigns.feature_mod.check_availability(assigns.site.owner) == :ok
display_upgrade_link? = assigns.current_user.id == assigns.billable_user.id
has_access? = assigns.feature_mod.check_availability(assigns.billable_user) == :ok
message =
cond do
@ -28,10 +28,10 @@ defmodule PlausibleWeb.Components.Billing do
private_preview? ->
"#{assigns.feature_mod.display_name()} is an upcoming premium functionality that's free-to-use during the private preview. Pricing will be announced soon."
Plausible.Billing.on_trial?(assigns.site.owner) ->
Plausible.Billing.on_trial?(assigns.billable_user) ->
"#{assigns.feature_mod.display_name()} is part of the Plausible Business plan. You can access it during your trial, but you'll need to subscribe to the Business plan to retain access after the trial ends."
not has_access? && owner? ->
not has_access? && display_upgrade_link? ->
~H"""
<%= @feature_mod.display_name() %> is part of the Plausible Business plan. To get access to it, please
<.link class="underline" href={Routes.billing_path(PlausibleWeb.Endpoint, :upgrade)}>
@ -39,7 +39,7 @@ defmodule PlausibleWeb.Components.Billing do
</.link> to the Business plan.
"""
not has_access? && not owner? ->
not has_access? && not display_upgrade_link? ->
"#{assigns.feature_mod.display_name()} is part of the Plausible Business plan. To get access to it, please reach out to the site owner to upgrade your subscription to the Business plan."
true ->

View File

@ -14,7 +14,7 @@ defmodule PlausibleWeb.Components.Site.Feature do
assigns =
assigns
|> assign(:current_setting, assigns.feature_mod.enabled?(assigns.site))
|> assign(:disabled?, assigns.feature_mod.check_availability(assigns.site) !== :ok)
|> assign(:disabled?, assigns.feature_mod.check_availability(assigns.site.owner) !== :ok)
~H"""
<div>

View File

@ -414,8 +414,7 @@ defmodule PlausibleWeb.AuthController do
end
def new_api_key(conn, _params) do
key = :crypto.strong_rand_bytes(64) |> Base.url_encode64() |> binary_part(0, 64)
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{}, %{key: key})
changeset = Auth.ApiKey.changeset(%Auth.ApiKey{})
render(conn, "new_api_key.html",
changeset: changeset,
@ -423,12 +422,8 @@ defmodule PlausibleWeb.AuthController do
)
end
def create_api_key(conn, %{"api_key" => key_params}) do
api_key = %Auth.ApiKey{user_id: conn.assigns[:current_user].id}
key_params = Map.delete(key_params, "user_id")
changeset = Auth.ApiKey.changeset(api_key, key_params)
case Repo.insert(changeset) do
def create_api_key(conn, %{"api_key" => %{"name" => name, "key" => key}}) do
case Auth.create_api_key(conn.assigns.current_user, name, key) do
{:ok, _api_key} ->
conn
|> put_flash(:success, "API key created successfully")
@ -443,18 +438,17 @@ defmodule PlausibleWeb.AuthController do
end
def delete_api_key(conn, %{"id" => id}) do
query =
from(k in Auth.ApiKey,
where: k.id == ^id and k.user_id == ^conn.assigns[:current_user].id
)
case Auth.delete_api_key(conn.assigns.current_user, id) do
:ok ->
conn
|> put_flash(:success, "API key revoked successfully")
|> redirect(to: "/settings#api-keys")
query
|> Repo.one!()
|> Repo.delete!()
conn
|> put_flash(:success, "API key revoked successfully")
|> redirect(to: "/settings#api-keys")
{:error, :not_found} ->
conn
|> put_flash(:error, "Could not find API Key to delete")
|> redirect(to: "/settings#api-keys")
end
end
def delete_me(conn, params) do

View File

@ -210,7 +210,9 @@ defmodule PlausibleWeb.SiteController do
end
def settings_goals(conn, _params) do
site = conn.assigns[:site] |> Repo.preload(:custom_domain)
site = Repo.preload(conn.assigns[:site], [:custom_domain, :owner])
owner = Plausible.Users.with_subscription(site.owner)
site = Map.put(site, :owner, owner)
conn
|> render("settings_goals.html",

View File

@ -29,7 +29,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
site = Map.put(site, :owner, owner)
has_access_to_revenue_goals? =
Plausible.Billing.Feature.RevenueGoals.check_availability(site) == :ok
Plausible.Billing.Feature.RevenueGoals.check_availability(owner) == :ok
{:ok,
assign(socket,
@ -193,7 +193,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
<span x-show="active">
<PlausibleWeb.Components.Billing.extra_feature_notice
site={@site}
billable_user={@site.owner}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.RevenueGoals}
size={:xs}

View File

@ -1,7 +1,7 @@
defmodule PlausibleWeb.AuthorizeStatsApiPlug do
import Plug.Conn
use Plausible.Repo
alias Plausible.Auth.ApiKey
alias Plausible.Auth
alias Plausible.Sites
alias PlausibleWeb.Api.Helpers, as: H
@ -11,7 +11,7 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
def call(conn, _opts) do
with {:ok, token} <- get_bearer_token(conn),
{:ok, api_key} <- find_api_key(token),
{:ok, api_key} <- Auth.find_api_key(token),
:ok <- check_api_key_rate_limit(api_key),
{:ok, site} <- verify_access(api_key, conn.params["site_id"]) do
Plausible.OpenTelemetry.add_site_attributes(site)
@ -41,6 +41,12 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
"Invalid API key or site ID. Please make sure you're using a valid API key with access to the site you've requested."
)
{:error, :upgrade_required} ->
H.payment_required(
conn,
"#{Plausible.Billing.Feature.StatsAPI.display_name()} is part of the Plausible Business plan. To get access to this feature, please upgrade your account."
)
{:error, :site_locked} ->
H.payment_required(
conn,
@ -61,10 +67,20 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
is_super_admin? = Plausible.Auth.is_super_admin?(api_key.user_id)
cond do
is_super_admin? -> {:ok, site}
Sites.locked?(site) -> {:error, :site_locked}
is_member? -> {:ok, site}
true -> {:error, :invalid_api_key}
is_super_admin? ->
{:ok, site}
Sites.locked?(site) ->
{:error, :site_locked}
Plausible.Billing.Feature.StatsAPI.check_availability(api_key.user) !== :ok ->
{:error, :upgrade_required}
is_member? ->
{:ok, site}
true ->
{:error, :invalid_api_key}
end
nil ->
@ -83,12 +99,6 @@ defmodule PlausibleWeb.AuthorizeStatsApiPlug do
end
end
defp find_api_key(token) do
hashed_key = ApiKey.do_hash(token)
found_key = Repo.get_by(ApiKey, key_hash: hashed_key)
if found_key, do: {:ok, found_key}, else: {:error, :invalid_api_key}
end
@one_hour 60 * 60 * 1000
defp check_api_key_rate_limit(api_key) do
case Hammer.check_rate("api_request:#{api_key.id}", @one_hour, api_key.hourly_request_limit) do

View File

@ -316,10 +316,15 @@
id="api-keys"
class="max-w-2xl px-8 pt-6 pb-8 mx-auto mt-16 bg-white border-t-2 border-indigo-100 rounded rounded-t-none shadow-md dark:bg-gray-800 dark:border-indigo-500"
>
<h2 class="text-xl font-black dark:text-gray-100">API keys</h2>
<h2 class="text-xl font-black dark:text-gray-100">API Keys</h2>
<div class="my-4 border-b border-gray-300 dark:border-gray-500"></div>
<PlausibleWeb.Components.Billing.extra_feature_notice
billable_user={@current_user}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.StatsAPI}
/>
<div class="flex flex-col mt-6">
<div class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
@ -370,7 +375,13 @@
</div>
<% end %>
<%= link("+ New API key", to: "/settings/api-keys/new", class: "button mt-4") %>
<.link
:if={Plausible.Billing.Feature.StatsAPI.check_availability(@current_user) == :ok}
href={Routes.auth_path(@conn, :new_api_key)}
class="button mt-4"
>
+ New API Key
</.link>
</div>
</div>
</div>
@ -388,7 +399,7 @@
</p>
<%= if @subscription && @subscription.status == Plausible.Billing.Subscription.Status.active() do %>
<span class="mt-6 bg-gray-300 button dark:bg-gray-600 hover:shadow-none hover:bg-gray-300">
<span class="mt-6 bg-gray-300 button dark:bg-gray-600 hover:shadow-none hover:bg-gray-300 cursor-not-allowed">
Delete my account
</span>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">

View File

@ -1,6 +1,6 @@
<section class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden">
<PlausibleWeb.Components.Billing.extra_feature_notice
site={@site}
billable_user={@site.owner}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.Funnels}
/>

View File

@ -1,6 +1,6 @@
<section class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden">
<PlausibleWeb.Components.Billing.extra_feature_notice
site={@site}
billable_user={@site.owner}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.Props}
grandfathered?

View File

@ -6,7 +6,7 @@
"yearly_product_id":"572810",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -15,7 +15,7 @@
"yearly_product_id":"590752",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -24,7 +24,7 @@
"yearly_product_id":"597486",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -33,7 +33,7 @@
"yearly_product_id":"597488",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -42,7 +42,7 @@
"yearly_product_id":"597643",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -51,7 +51,7 @@
"yearly_product_id":"597310",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -60,7 +60,7 @@
"yearly_product_id":"597312",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -69,7 +69,7 @@
"yearly_product_id":"642354",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -78,7 +78,7 @@
"yearly_product_id":"642356",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -87,6 +87,6 @@
"yearly_product_id":"650653",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
}
]

View File

@ -6,7 +6,7 @@
"yearly_product_id":"653232",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -15,7 +15,7 @@
"yearly_product_id":"653234",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -24,7 +24,7 @@
"yearly_product_id":"653236",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -33,7 +33,7 @@
"yearly_product_id":"653239",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -42,7 +42,7 @@
"yearly_product_id":"653242",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -51,7 +51,7 @@
"yearly_product_id":"653254",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -60,7 +60,7 @@
"yearly_product_id":"653256",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -69,7 +69,7 @@
"yearly_product_id":"653257",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -78,7 +78,7 @@
"yearly_product_id":"653258",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -87,6 +87,6 @@
"yearly_product_id":"653259",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
}
]

View File

@ -6,7 +6,7 @@
"yearly_product_id":"749343",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -15,7 +15,7 @@
"yearly_product_id":"749345",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -24,7 +24,7 @@
"yearly_product_id":"749347",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -33,7 +33,7 @@
"yearly_product_id":"749349",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -42,7 +42,7 @@
"yearly_product_id":"749352",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -51,7 +51,7 @@
"yearly_product_id":"749355",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -60,7 +60,7 @@
"yearly_product_id":"749357",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -69,6 +69,6 @@
"yearly_product_id":"749359",
"site_limit":50,
"team_member_limit":"unlimited",
"extra_features":["props"]
"extra_features":["props","stats_api"]
}
]

View File

@ -78,7 +78,7 @@
"yearly_product_id":"change-me-b749343",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
},
{
"kind":"business",
@ -87,7 +87,7 @@
"yearly_product_id":"change-me-b749345",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
},
{
"kind":"business",
@ -96,7 +96,7 @@
"yearly_product_id":"change-me-b749347",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
},
{
"kind":"business",
@ -105,7 +105,7 @@
"yearly_product_id":"change-me-b749349",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
},
{
"kind":"business",
@ -114,7 +114,7 @@
"yearly_product_id":"change-me-b749352",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
},
{
"kind":"business",
@ -123,7 +123,7 @@
"yearly_product_id":"change-me-b749355",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
},
{
"kind":"business",
@ -132,7 +132,7 @@
"yearly_product_id":"change-me-b749357",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
},
{
"kind":"business",
@ -141,6 +141,6 @@
"yearly_product_id":"change-me-b749359",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props","revenue_goals","funnels"]
"extra_features":["props","revenue_goals","funnels","stats_api"]
}
]

View File

@ -6,7 +6,7 @@
"yearly_product_id":"63859",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -15,7 +15,7 @@
"yearly_product_id":"63860",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -24,7 +24,7 @@
"yearly_product_id":"63861",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -33,7 +33,7 @@
"yearly_product_id":"63862",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -42,7 +42,7 @@
"yearly_product_id":"63863",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -51,7 +51,7 @@
"yearly_product_id":"63864",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -60,7 +60,7 @@
"yearly_product_id":"63865",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"growth",
@ -69,7 +69,7 @@
"yearly_product_id":"63866",
"site_limit":10,
"team_member_limit":5,
"extra_features":["props"]
"extra_features":["props","stats_api"]
},
{
"kind":"business",
@ -78,7 +78,7 @@
"yearly_product_id":"63867",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
},
{
"kind":"business",
@ -87,7 +87,7 @@
"yearly_product_id":"63868",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
},
{
"kind":"business",
@ -96,7 +96,7 @@
"yearly_product_id":"63869",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
},
{
"kind":"business",
@ -105,7 +105,7 @@
"yearly_product_id":"63870",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
},
{
"kind":"business",
@ -114,7 +114,7 @@
"yearly_product_id":"63871",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
},
{
"kind":"business",
@ -123,7 +123,7 @@
"yearly_product_id":"63872",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
},
{
"kind":"business",
@ -132,7 +132,7 @@
"yearly_product_id":"63873",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
},
{
"kind":"business",
@ -141,6 +141,6 @@
"yearly_product_id":"63874",
"site_limit":50,
"team_member_limit":50,
"extra_features":["props", "revenue_goals", "funnels"]
"extra_features":["props", "revenue_goals", "funnels","stats_api"]
}
]

View File

@ -2,6 +2,8 @@ defmodule Plausible.AuthTest do
use Plausible.DataCase, async: true
alias Plausible.Auth
@v4_growth_plan_id "change-me-749342"
describe "user_completed_setup?" do
test "is false if user does not have any sites" do
user = insert(:user)
@ -49,4 +51,50 @@ defmodule Plausible.AuthTest do
refute Auth.enterprise_configured?(user_without_plan)
refute Auth.enterprise_configured?(nil)
end
describe "create_api_key/3" do
test "creates a new api key" do
user = insert(:user)
key = Ecto.UUID.generate()
assert {:ok, %Auth.ApiKey{}} = Auth.create_api_key(user, "my new key", key)
end
test "errors when key already exists" do
u1 = insert(:user)
u2 = insert(:user)
key = Ecto.UUID.generate()
assert {:ok, %Auth.ApiKey{}} = Auth.create_api_key(u1, "my new key", key)
assert {:error, changeset} = Auth.create_api_key(u2, "my other key", key)
assert changeset.errors[:key] ==
{"has already been taken",
[constraint: :unique, constraint_name: "api_keys_key_hash_index"]}
end
test "returns error when user is on a growth plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_growth_plan_id))
assert {:error, :upgrade_required} =
Auth.create_api_key(user, "my new key", Ecto.UUID.generate())
end
end
describe "delete_api_key/2" do
test "deletes the record" do
user = insert(:user)
assert {:ok, api_key} = Auth.create_api_key(user, "my new key", Ecto.UUID.generate())
assert :ok = Auth.delete_api_key(user, api_key.id)
refute Plausible.Repo.reload(api_key)
end
test "returns error when api key does not exist or does not belong to user" do
me = insert(:user)
other_user = insert(:user)
{:ok, other_api_key} = Auth.create_api_key(other_user, "my new key", Ecto.UUID.generate())
assert {:error, :not_found} = Auth.delete_api_key(me, other_api_key.id)
assert {:error, :not_found} = Auth.delete_api_key(me, -1)
end
end
end

View File

@ -13,45 +13,62 @@ defmodule Plausible.Billing.FeatureTest do
subscription: build(:subscription, paddle_plan_id: "123321")
)
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
assert :ok == unquote(mod).check_availability(user)
assert :ok == unquote(mod).check_availability(site)
end
test "#{mod}.check_availability/1 returns :ok when site owner is on a business plan" do
user =
insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_business_plan_id))
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
assert :ok == unquote(mod).check_availability(user)
assert :ok == unquote(mod).check_availability(site)
end
test "#{mod}.check_availability/1 returns error when site owner is on a growth plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_growth_plan_id))
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
assert {:error, :upgrade_required} == unquote(mod).check_availability(user)
assert {:error, :upgrade_required} == unquote(mod).check_availability(site)
end
test "#{mod}.check_availability/1 returns error when site owner is on an old plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
assert {:error, :upgrade_required} == unquote(mod).check_availability(user)
assert {:error, :upgrade_required} == unquote(mod).check_availability(site)
end
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on a business plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_business_plan_id))
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(user)
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on an old plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(user)
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on trial" do
user = insert(:user)
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(user)
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on an enterprise plan" do
user =
insert(:user,
enterprise_plan: build(:enterprise_plan, paddle_plan_id: "123321"),
subscription: build(:subscription, paddle_plan_id: "123321")
)
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(user)
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns error when user is on a growth plan" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_growth_plan_id))
assert {:error, :upgrade_required} ==
Plausible.Billing.Feature.StatsAPI.check_availability(user)
end
test "Plausible.Billing.Feature.Props.check_availability/1 applies grandfathering to old plans" do
user = insert(:user, subscription: build(:subscription, paddle_plan_id: @v1_plan_id))
site = insert(:site, memberships: [build(:site_membership, user: user, role: :owner)])
assert :ok == Plausible.Billing.Feature.Props.check_availability(user)
assert :ok == Plausible.Billing.Feature.Props.check_availability(site)
end
test "Plausible.Billing.Feature.Goals.check_availability/2 always returns :ok" do

View File

@ -436,9 +436,9 @@ defmodule Plausible.Billing.QuotaTest do
user_on_v2 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v2_plan_id))
user_on_v3 = insert(:user, subscription: build(:subscription, paddle_plan_id: @v3_plan_id))
assert [:props] == Quota.extra_features_limit(user_on_v1)
assert [:props] == Quota.extra_features_limit(user_on_v2)
assert [:props] == Quota.extra_features_limit(user_on_v3)
assert [:props, :stats_api] == Quota.extra_features_limit(user_on_v1)
assert [:props, :stats_api] == Quota.extra_features_limit(user_on_v2)
assert [:props, :stats_api] == Quota.extra_features_limit(user_on_v3)
end
test "returns an empty list when user is on free_10k plan" do
@ -459,12 +459,12 @@ defmodule Plausible.Billing.QuotaTest do
_subscription =
insert(:subscription, user_id: user.id, paddle_plan_id: enterprise_plan.paddle_plan_id)
assert [:props, :revenue_goals, :funnels] == Quota.extra_features_limit(user)
assert [:props, :revenue_goals, :funnels, :stats_api] == Quota.extra_features_limit(user)
end
test "returns all extra features when user in on trial" do
user = insert(:user, trial_expiry_date: Timex.shift(Timex.now(), days: 7))
assert [:props, :revenue_goals, :funnels] == Quota.extra_features_limit(user)
assert [:props, :revenue_goals, :funnels, :stats_api] == Quota.extra_features_limit(user)
end
test "returns previous plan limits for enterprise users who have not paid yet" do
@ -474,7 +474,7 @@ defmodule Plausible.Billing.QuotaTest do
subscription: build(:subscription, paddle_plan_id: @v1_plan_id)
)
assert [:props] == Quota.extra_features_limit(user)
assert [:props, :stats_api] == Quota.extra_features_limit(user)
end
test "returns trial limits for enterprise users who have not upgraded yet and are on trial" do
@ -484,7 +484,7 @@ defmodule Plausible.Billing.QuotaTest do
subscription: nil
)
assert [:props, :revenue_goals, :funnels] == Quota.extra_features_limit(user)
assert [:props, :revenue_goals, :funnels, :stats_api] == Quota.extra_features_limit(user)
end
test "returns all extra features for enterprise customers" do
@ -494,7 +494,7 @@ defmodule Plausible.Billing.QuotaTest do
subscription: build(:subscription, paddle_plan_id: "123321")
)
assert [:props, :revenue_goals, :funnels] == Quota.extra_features_limit(user)
assert [:props, :revenue_goals, :funnels, :stats_api] == Quota.extra_features_limit(user)
end
test "returns all extra features for enterprise customers who are due to change a plan" do
@ -505,7 +505,7 @@ defmodule Plausible.Billing.QuotaTest do
)
insert(:enterprise_plan, user_id: user.id, paddle_plan_id: "new-paddle-plan-id")
assert [:props, :revenue_goals, :funnels] == Quota.extra_features_limit(user)
assert [:props, :revenue_goals, :funnels, :stats_api] == Quota.extra_features_limit(user)
end
end
end

View File

@ -8,10 +8,9 @@ defmodule PlausibleWeb.Components.BillingTest do
test "extra_feature_notice/1 renders a message when user is on trial" do
me = insert(:user)
site = :site |> insert(members: [me]) |> Plausible.Repo.preload(:owner)
assert render_component(&Billing.extra_feature_notice/1,
site: site,
billable_user: me,
current_user: me,
feature_mod: Plausible.Billing.Feature.Props
) =~
@ -20,11 +19,10 @@ defmodule PlausibleWeb.Components.BillingTest do
test "extra_feature_notice/1 renders an upgrade link when user is the site owner and does not have access to the feature" do
me = insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_growth_plan_id))
site = :site |> insert(members: [me]) |> Plausible.Repo.preload(:owner)
rendered =
render_component(&Billing.extra_feature_notice/1,
site: site,
billable_user: me,
current_user: me,
feature_mod: Plausible.Billing.Feature.Props
)
@ -38,19 +36,9 @@ defmodule PlausibleWeb.Components.BillingTest do
me = insert(:user)
owner = insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_growth_plan_id))
site =
:site
|> insert(
memberships: [
build(:site_membership, user: owner, role: :owner),
build(:site_membership, user: me, role: :admin)
]
)
|> Plausible.Repo.preload(:owner)
rendered =
render_component(&Billing.extra_feature_notice/1,
site: site,
billable_user: owner,
current_user: me,
feature_mod: Plausible.Billing.Feature.Funnels
)
@ -63,11 +51,10 @@ defmodule PlausibleWeb.Components.BillingTest do
test "extra_feature_notice/1 does not render a notice when the user has access to the feature" do
me = insert(:user, subscription: build(:subscription, paddle_plan_id: @v4_business_plan_id))
site = :site |> insert(members: [me]) |> Plausible.Repo.preload(:owner)
rendered =
render_component(&Billing.extra_feature_notice/1,
site: site,
billable_user: me,
current_user: me,
feature_mod: Plausible.Billing.Feature.Funnels
)

View File

@ -1,5 +1,6 @@
defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do
use PlausibleWeb.ConnCase
@v4_growth_plan_id "change-me-749342"
setup [:create_user, :create_api_key]
@ -149,6 +150,23 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AuthTest do
})
end
test "returns HTTP 402 when user is on a growth plan", %{
conn: conn,
user: user,
api_key: api_key
} do
insert(:subscription, user: user, paddle_plan_id: @v4_growth_plan_id)
site = insert(:site, members: [user])
conn
|> with_api_key(api_key)
|> get("/api/v1/stats/aggregate", %{"site_id" => site.domain, "metrics" => "pageviews"})
|> assert_error(
402,
"Stats API is part of the Plausible Business plan. To get access to this feature, please upgrade your account."
)
end
defp with_api_key(conn, api_key) do
Plug.Conn.put_req_header(conn, "authorization", "Bearer #{api_key}")
end

View File

@ -978,10 +978,8 @@ defmodule PlausibleWeb.AuthControllerTest do
|> ApiKey.changeset(%{"name" => "other user's key"})
|> Repo.insert()
assert_raise Ecto.NoResultsError, fn ->
delete(conn, "/settings/api-keys/#{api_key.id}")
end
conn = delete(conn, "/settings/api-keys/#{api_key.id}")
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Could not find API Key to delete"
assert Repo.get(ApiKey, api_key.id)
end
end