Starter Tier: Shared Link Feature Gates (#5474)

* feature gate creating shared links

* feature gate GET shared_link

* stop granting shared links access in code + organize tests

* allow GET shared_link for WP

* prevent shared link creation with special name

unless created by the Plugins API, the name WordPress - Shared Dashboard
will be considered reserved.

* do not render special shared links in site settings > visibility

* remove hardcoded special name from test

* add function doc for special names

* prevent updates to special name as well

* warn about losing access to shared links

* make features_usage return empty list on ce

* Update lib/plausible/sites.ex

Co-authored-by: hq1 <hq@mtod.org>

* move special name check to changeset

* fix tests

---------

Co-authored-by: hq1 <hq@mtod.org>
This commit is contained in:
RobertJoonas 2025-06-10 07:43:40 +01:00 committed by GitHub
parent efc55e323d
commit 4e5093f86c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 584 additions and 246 deletions

View File

@ -300,6 +300,13 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
name: link.name, name: link.name,
url: Sites.shared_link_url(site, link) url: Sites.shared_link_url(site, link)
}) })
{:error, %Ecto.Changeset{} = changeset} ->
{msg, _} = changeset.errors[:name]
H.bad_request(conn, msg)
{:error, :upgrade_required} ->
H.payment_required(conn, "Your current subscription plan does not include Shared Links")
end end
else else
{:error, :site_not_found} -> {:error, :site_not_found} ->

View File

@ -31,8 +31,15 @@ defmodule Plausible.Plugins.API.SharedLinks do
{:ok, Plausible.Site.SharedLink.t()} {:ok, Plausible.Site.SharedLink.t()}
def get_or_create(site, name, password \\ nil) do def get_or_create(site, name, password \\ nil) do
case get_by_name(site, name) do case get_by_name(site, name) do
nil -> Plausible.Sites.create_shared_link(site, name, password) nil ->
shared_link -> {:ok, shared_link} Plausible.Sites.create_shared_link(site, name,
password: password,
skip_feature_check?: true,
skip_special_name_check?: true
)
shared_link ->
{:ok, shared_link}
end end
end end

View File

@ -369,13 +369,4 @@ defmodule Plausible.Segments do
"#{field} #{formatted_message}" "#{field} #{formatted_message}"
end end
@spec get_site_segments_usage_query(list(pos_integer())) :: Ecto.Query.t()
def get_site_segments_usage_query(site_ids) do
from(segment in Segment,
as: :segment,
where: segment.type == :site,
where: segment.site_id in ^site_ids
)
end
end end

View File

@ -14,15 +14,27 @@ defmodule Plausible.Site.SharedLink do
timestamps() timestamps()
end end
def changeset(link, attrs \\ %{}) do def changeset(link, attrs \\ %{}, opts \\ []) do
link link
|> cast(attrs, [:slug, :password, :name]) |> cast(attrs, [:slug, :password, :name])
|> validate_required([:slug, :name]) |> validate_required([:slug, :name])
|> validate_special_name(opts)
|> unique_constraint(:slug) |> unique_constraint(:slug)
|> unique_constraint(:name, name: :shared_links_site_id_name_index) |> unique_constraint(:name, name: :shared_links_site_id_name_index)
|> hash_password() |> hash_password()
end end
defp validate_special_name(changeset, opts) do
name = get_change(changeset, :name)
if name not in Plausible.Sites.shared_link_special_names() ||
Keyword.get(opts, :skip_special_name_check?, false) do
changeset
else
changeset |> add_error(:name, "This name is reserved. Please choose another one")
end
end
defp hash_password(link) do defp hash_password(link) do
case link.changes[:password] do case link.changes[:password] do
nil -> nil ->

View File

@ -6,15 +6,31 @@ defmodule Plausible.Sites do
import Ecto.Query import Ecto.Query
alias Plausible.Auth alias Plausible.{Auth, Repo, Site, Teams, Billing}
alias Plausible.Billing alias Plausible.Billing.Feature.SharedLinks
alias Plausible.Repo
alias Plausible.Site
alias Plausible.Site.SharedLink alias Plausible.Site.SharedLink
alias Plausible.Teams
require Plausible.Site.UserPreference require Plausible.Site.UserPreference
@shared_link_special_names ["WordPress - Shared Dashboard"]
@doc """
Special shared link names are used to distinguish between those
created by the Plugins API, and those created in any other way
(i.e. via Sites API or in the Dashboard Site Settings UI).
The intent is to give our WP plugin the ability to display an
embedded dashboard even when the user's subscription does not
support the shared links feature.
A shared link with a special name can only be created via the
plugins API, and it will not show up under the list of shared
links in Site Settings > Visibility.
Once created with the special name, the link will be accessible
even when the team does not have access to SharedLinks feature.
"""
def shared_link_special_names(), do: @shared_link_special_names
def get_by_domain(domain) do def get_by_domain(domain) do
Repo.get_by(Site, domain: domain) Repo.get_by(Site, domain: domain)
end end
@ -338,17 +354,21 @@ defmodule Plausible.Sites do
!!stats_start_date(site) !!stats_start_date(site)
end end
def create_shared_link(site, name, password \\ nil) do def create_shared_link(site, name, opts \\ []) do
changes = password = Keyword.get(opts, :password)
SharedLink.changeset( site = Plausible.Repo.preload(site, :team)
%SharedLink{ skip_feature_check? = Keyword.get(opts, :skip_feature_check?, false)
site_id: site.id,
slug: Nanoid.generate()
},
%{name: name, password: password}
)
Repo.insert(changes) if not skip_feature_check? and SharedLinks.check_availability(site.team) != :ok do
{:error, :upgrade_required}
else
%SharedLink{site_id: site.id, slug: Nanoid.generate()}
|> SharedLink.changeset(
%{name: name, password: password},
Keyword.take(opts, [:skip_special_name_check?])
)
|> Repo.insert()
end
end end
def shared_link_url(site, link) do def shared_link_url(site, link) do

View File

@ -478,84 +478,88 @@ defmodule Plausible.Teams.Billing do
""" """
def features_usage(team, site_ids \\ nil) def features_usage(team, site_ids \\ nil)
def features_usage(nil, nil), do: [] on_ee do
def features_usage(nil, nil), do: []
def features_usage(%Teams.Team{} = team, nil) do def features_usage(%Teams.Team{} = team, nil) do
owned_site_ids = Teams.owned_sites_ids(team) owned_site_ids = Teams.owned_sites_ids(team)
features_usage(team, owned_site_ids) features_usage(team, owned_site_ids)
end end
def features_usage(%Teams.Team{} = team, owned_site_ids) when is_list(owned_site_ids) do def features_usage(%Teams.Team{} = team, owned_site_ids) when is_list(owned_site_ids) do
site_scoped_feature_usage = features_usage(nil, owned_site_ids) site_scoped_feature_usage = features_usage(nil, owned_site_ids)
stats_api_used? = stats_api_used? =
Repo.exists?( Repo.exists?(
from tm in Plausible.Teams.Membership, from tm in Plausible.Teams.Membership,
as: :team_membership, as: :team_membership,
where: tm.team_id == ^team.id, where: tm.team_id == ^team.id,
where: where:
exists( exists(
from ak in Plausible.Auth.ApiKey, from ak in Plausible.Auth.ApiKey,
where: ak.user_id == parent_as(:team_membership).user_id where: ak.user_id == parent_as(:team_membership).user_id
) )
) )
site_scoped_feature_usage = site_scoped_feature_usage =
if stats_api_used? do if stats_api_used? do
site_scoped_feature_usage ++ [Feature.StatsAPI] site_scoped_feature_usage ++ [Feature.StatsAPI]
else
site_scoped_feature_usage
end
sites_api_used? =
Repo.exists?(
from tm in Plausible.Teams.Membership,
as: :team_membership,
where: tm.team_id == ^team.id,
where:
exists(
from ak in Plausible.Auth.ApiKey,
where: ak.user_id == parent_as(:team_membership).user_id,
where: "sites:provision:*" in ak.scopes
)
)
if sites_api_used? do
site_scoped_feature_usage ++ [SitesAPI]
else else
site_scoped_feature_usage site_scoped_feature_usage
end end
sites_api_used? =
Repo.exists?(
from tm in Plausible.Teams.Membership,
as: :team_membership,
where: tm.team_id == ^team.id,
where:
exists(
from ak in Plausible.Auth.ApiKey,
where: ak.user_id == parent_as(:team_membership).user_id,
where: "sites:provision:*" in ak.scopes
)
)
if sites_api_used? do
site_scoped_feature_usage ++ [SitesAPI]
else
site_scoped_feature_usage
end end
end
def features_usage(nil, site_ids) when is_list(site_ids) do def features_usage(nil, site_ids) when is_list(site_ids) do
props_usage_q = shared_links_usage_q =
from s in Plausible.Site, from l in Plausible.Site.SharedLink,
where: s.id in ^site_ids and fragment("cardinality(?) > 0", s.allowed_event_props) where:
l.site_id in ^site_ids and l.name not in ^Plausible.Sites.shared_link_special_names()
revenue_goals_usage_q = props_usage_q =
from g in Plausible.Goal, from s in Plausible.Site,
where: g.site_id in ^site_ids and not is_nil(g.currency) where: s.id in ^site_ids and fragment("cardinality(?) > 0", s.allowed_event_props)
queries = funnels_usage_q = from f in "funnels", where: f.site_id in ^site_ids
on_ee do
funnels_usage_q = from f in "funnels", where: f.site_id in ^site_ids
[ revenue_goals_usage_q =
{Feature.Props, props_usage_q}, from g in Plausible.Goal,
{Feature.Funnels, funnels_usage_q}, where: g.site_id in ^site_ids and not is_nil(g.currency)
{Feature.RevenueGoals, revenue_goals_usage_q},
{Feature.SiteSegments, Plausible.Segments.get_site_segments_usage_query(site_ids)}
]
else
[
{Feature.Props, props_usage_q},
{Feature.RevenueGoals, revenue_goals_usage_q}
]
end
Enum.reduce(queries, [], fn {feature, query}, acc -> site_segments_usage_q =
if Repo.exists?(query), do: acc ++ [feature], else: acc from s in Plausible.Segments.Segment, where: s.site_id in ^site_ids and s.type == :site
end)
[
{Feature.SharedLinks, shared_links_usage_q},
{Feature.Props, props_usage_q},
{Feature.Funnels, funnels_usage_q},
{Feature.RevenueGoals, revenue_goals_usage_q},
{Feature.SiteSegments, site_segments_usage_q}
]
|> Enum.reduce([], fn {feature, query}, acc ->
if Repo.exists?(query), do: acc ++ [feature], else: acc
end)
end
else
def features_usage(_team, _site_ids), do: []
end end
defp query_team_member_emails(team, pending_ownership_site_ids, exclude_emails) do defp query_team_member_emails(team, pending_ownership_site_ids, exclude_emails) do
@ -613,7 +617,7 @@ defmodule Plausible.Teams.Billing do
case Plans.get_subscription_plan(team.subscription) do case Plans.get_subscription_plan(team.subscription) do
%EnterprisePlan{features: features} -> %EnterprisePlan{features: features} ->
features ++ [SharedLinks] features
%Plan{features: features} -> %Plan{features: features} ->
features features
@ -625,7 +629,7 @@ defmodule Plausible.Teams.Billing do
if Teams.on_trial?(team) do if Teams.on_trial?(team) do
Feature.list() -- [SitesAPI] Feature.list() -- [SitesAPI]
else else
[Goals, SharedLinks] [Goals]
end end
end end
end end

View File

@ -162,7 +162,14 @@ defmodule PlausibleWeb.SiteController do
def settings_visibility(conn, _params) do def settings_visibility(conn, _params) do
site = conn.assigns[:site] site = conn.assigns[:site]
shared_links = Repo.all(from(l in Plausible.Site.SharedLink, where: l.site_id == ^site.id))
shared_links =
Repo.all(
from(l in Plausible.Site.SharedLink,
where:
l.site_id == ^site.id and l.name not in ^Plausible.Sites.shared_link_special_names()
)
)
conn conn
|> render("settings_visibility.html", |> render("settings_visibility.html",
@ -576,10 +583,15 @@ defmodule PlausibleWeb.SiteController do
def create_shared_link(conn, %{"shared_link" => link}) do def create_shared_link(conn, %{"shared_link" => link}) do
site = conn.assigns[:site] site = conn.assigns[:site]
case Sites.create_shared_link(site, link["name"], link["password"]) do case Sites.create_shared_link(site, link["name"], password: link["password"]) do
{:ok, _created} -> {:ok, _created} ->
redirect(conn, to: Routes.site_path(conn, :settings_visibility, site.domain)) redirect(conn, to: Routes.site_path(conn, :settings_visibility, site.domain))
{:error, :upgrade_required} ->
conn
|> put_flash(:error, "Your current subscription plan does not include Shared Links")
|> redirect(to: Routes.site_path(conn, :settings_visibility, site.domain))
{:error, changeset} -> {:error, changeset} ->
conn conn
|> assign(:skip_plausible_tracking, true) |> assign(:skip_plausible_tracking, true)
@ -609,7 +621,7 @@ defmodule PlausibleWeb.SiteController do
changeset = Plausible.Site.SharedLink.changeset(shared_link, params) changeset = Plausible.Site.SharedLink.changeset(shared_link, params)
case Repo.update(changeset) do case Repo.update(changeset) do
{:ok, _created} -> {:ok, _updated} ->
redirect(conn, to: Routes.site_path(conn, :settings_visibility, site.domain)) redirect(conn, to: Routes.site_path(conn, :settings_visibility, site.domain))
{:error, changeset} -> {:error, changeset} ->

View File

@ -48,6 +48,7 @@ defmodule PlausibleWeb.StatsController do
alias Plausible.Stats.{Filters, Query} alias Plausible.Stats.{Filters, Query}
alias Plausible.Teams alias Plausible.Teams
alias PlausibleWeb.Api alias PlausibleWeb.Api
alias Plausible.Billing.Feature.SharedLinks
plug(PlausibleWeb.Plugs.AuthorizeSiteAccess when action in [:stats, :csv_export]) plug(PlausibleWeb.Plugs.AuthorizeSiteAccess when action in [:stats, :csv_export])
@ -341,7 +342,30 @@ defmodule PlausibleWeb.StatsController do
end end
defp render_shared_link(conn, shared_link) do defp render_shared_link(conn, shared_link) do
shared_links_feature_access? =
SharedLinks.check_availability(shared_link.site.team) == :ok or
shared_link.name in Plausible.Sites.shared_link_special_names()
cond do cond do
Teams.locked?(shared_link.site.team) ->
owners = Plausible.Repo.preload(shared_link.site, :owners)
render(conn, "site_locked.html",
owners: owners,
site: shared_link.site,
dogfood_page_path: "/share/:dashboard"
)
not shared_links_feature_access? ->
owners = Plausible.Repo.preload(shared_link.site, :owners)
render(conn, "site_locked.html",
only_shared_link_access_missing?: true,
owners: owners,
site: shared_link.site,
dogfood_page_path: "/share/:dashboard"
)
not Teams.locked?(shared_link.site.team) -> not Teams.locked?(shared_link.site.team) ->
current_user = conn.assigns[:current_user] current_user = conn.assigns[:current_user]
site_role = get_fallback_site_role(conn) site_role = get_fallback_site_role(conn)
@ -379,15 +403,6 @@ defmodule PlausibleWeb.StatsController do
load_dashboard_js: true, load_dashboard_js: true,
hide_footer?: if(ce?(), do: embedded?, else: embedded? || site_role != :public) hide_footer?: if(ce?(), do: embedded?, else: embedded? || site_role != :public)
) )
Teams.locked?(shared_link.site.team) ->
owners = Plausible.Repo.preload(shared_link.site, :owners)
render(conn, "site_locked.html",
owners: owners,
site: shared_link.site,
dogfood_page_path: "/share/:dashboard"
)
end end
end end

View File

@ -52,7 +52,7 @@
No Shared Links configured for this site. No Shared Links configured for this site.
</p> </p>
<.table rows={@shared_links}> <.table rows={@shared_links} id="shared-links-table">
<:thead> <:thead>
<.th hide_on_mobile>Name</.th> <.th hide_on_mobile>Name</.th>
<.th>Link</.th> <.th>Link</.th>

View File

@ -17,11 +17,19 @@
</svg> </svg>
</div> </div>
<h3 class="mt-6 text-center text-2xl leading-6 font-medium text-gray-900 dark:text-gray-200"> <h3 class="mt-6 text-center text-2xl leading-6 font-medium text-gray-900 dark:text-gray-200">
Dashboard locked <%= if @conn.assigns[:only_shared_link_access_missing?] do %>
Shared Link Unavailable
<% else %>
Dashboard Locked
<% end %>
</h3> </h3>
<%= case @conn.assigns[:site_role] do %> <%= case @conn.assigns do %>
<% role when role in [:owner, :billing] -> %> <% %{only_shared_link_access_missing?: true} -> %>
<p class="mt-6 text-gray-600 dark:text-gray-300 text-center">
This shared link is locked because the owner of the site does not have access to the Shared Links feature. To restore it, the owner must upgrade to a suitable plan.
</p>
<% %{site_role: role} when role in [:owner, :billing] -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center"> <div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p> <p>
This dashboard is locked because you don't have a valid subscription. This dashboard is locked because you don't have a valid subscription.
@ -40,7 +48,7 @@
Manage my subscription Manage my subscription
</.button_link> </.button_link>
</div> </div>
<% role when role in [:admin, :viewer, :editor] -> %> <% %{site_role: role} when role in [:admin, :viewer, :editor] -> %>
<div class="mt-3 text-gray-600 dark:text-gray-300 text-center"> <div class="mt-3 text-gray-600 dark:text-gray-300 text-center">
<p> <p>
Owner of this site must upgrade their subscription plan in order to unlock the stats. Owner of this site must upgrade their subscription plan in order to unlock the stats.

View File

@ -1,103 +1,162 @@
defmodule Plausible.Billing.FeatureTest do defmodule Plausible.Billing.FeatureTest do
alias Plausible.Billing.Feature.SiteSegments
use Plausible.DataCase use Plausible.DataCase
use Plausible.Teams.Test use Plausible.Teams.Test
@v1_plan_id "558018" alias Plausible.Billing.Feature.{
Goals,
SiteSegments,
SharedLinks,
Funnels,
RevenueGoals,
StatsAPI,
Props
}
for mod <- [Plausible.Billing.Feature.Funnels, Plausible.Billing.Feature.RevenueGoals] do @v1_growth_plan_id "558018"
test "#{mod}.check_availability/1 returns :ok when site owner is on a enterprise plan" do @v5_growth_plan_id "910429"
team =
new_user()
|> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [unquote(mod)])
|> team_of()
assert :ok == unquote(mod).check_availability(team) describe "business features (for everyone)" do
end for mod <- [Funnels, RevenueGoals] do
test "#{mod}.check_availability/1 returns :ok when site owner is on a enterprise plan that supports #{mod}" do
team =
new_user()
|> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [unquote(mod)])
|> team_of()
test "#{mod}.check_availability/1 returns :ok when site owner is on a business plan" do assert :ok == unquote(mod).check_availability(team)
team = new_user() |> subscribe_to_business_plan() |> team_of() end
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on a growth plan" do test "#{mod}.check_availability/1 returns error when site owner is on a enterprise plan does not support #{mod}" do
team = new_user() |> subscribe_to_growth_plan() |> team_of() team =
assert {:error, :upgrade_required} == unquote(mod).check_availability(team) new_user()
end |> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [Goals])
|> team_of()
test "#{mod}.check_availability/1 returns error when site owner is on an old plan" do assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
team = new_user() |> subscribe_to_plan(@v1_plan_id) |> team_of() end
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
test "#{mod}.check_availability/1 returns :ok when site owner is on a business plan" do
team = new_user() |> subscribe_to_business_plan() |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on a growth plan" do
team = new_user() |> subscribe_to_growth_plan() |> team_of()
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on an old v1 plan" do
team = new_user() |> subscribe_to_plan(@v1_growth_plan_id) |> team_of()
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns :ok when user is on trial" do
team = new_user(trial_expiry_date: Date.utc_today()) |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
end end
end end
test "Plausible.Billing.Feature.SharedLinks.check_availability/2 returns :ok when user is on an enterprise plan" do describe "business features (grandfathered Growth access before v4)" do
team = new_user() |> subscribe_to_enterprise_plan() |> team_of() for mod <- [Props, StatsAPI] do
assert :ok == Plausible.Billing.Feature.SharedLinks.check_availability(team) test "#{mod}.check_availability/1 returns :ok when site owner is on a enterprise plan that supports #{mod}" do
team =
new_user()
|> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [unquote(mod)])
|> team_of()
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on a enterprise plan does not support #{mod}" do
team =
new_user()
|> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [Goals])
|> team_of()
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns :ok when site owner is on a business plan" do
team = new_user() |> subscribe_to_business_plan() |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on a new growth plan" do
team = new_user() |> subscribe_to_growth_plan() |> team_of()
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns :ok when site owner is on an old plan" do
team = new_user() |> subscribe_to_plan(@v1_growth_plan_id) |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns :ok when user is on trial" do
team = new_user(trial_expiry_date: Date.utc_today()) |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
end
end end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on a business plan" do describe "growth features" do
team = new_user() |> subscribe_to_business_plan() |> team_of() for mod <- [SharedLinks, SiteSegments] do
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(team) test "#{mod}.check_availability/1 returns :ok when site owner is on a enterprise plan that supports #{mod}" do
team =
new_user()
|> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [unquote(mod)])
|> team_of()
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on a enterprise plan does not support #{mod}" do
team =
new_user()
|> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [Goals])
|> team_of()
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns :ok when site owner is on a business plan" do
team = new_user() |> subscribe_to_business_plan() |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns :ok when site owner is on a growth plan" do
team = new_user() |> subscribe_to_plan(@v5_growth_plan_id) |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on a starter plan" do
team = new_user() |> subscribe_to_starter_plan() |> team_of()
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns :ok when user is on trial" do
team = new_user(trial_expiry_date: Date.utc_today()) |> team_of()
assert :ok == unquote(mod).check_availability(team)
end
end
end end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on an old plan" do test "Goals.check_availability/2 always returns :ok" do
team = new_user() |> subscribe_to_plan(@v1_plan_id) |> team_of() t1 = new_user() |> subscribe_to_plan(@v1_growth_plan_id) |> team_of()
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(team)
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on trial" do
team = new_user(trial_expiry_date: Date.utc_today()) |> team_of()
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(team)
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on an enterprise plan" do
team =
new_user()
|> subscribe_to_enterprise_plan(
paddle_plan_id: "123321",
features: [Plausible.Billing.Feature.StatsAPI]
)
|> team_of()
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(team)
end
@tag :ee_only
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns error when user is on a growth plan" do
team = new_user() |> subscribe_to_growth_plan() |> team_of()
assert {:error, :upgrade_required} ==
Plausible.Billing.Feature.StatsAPI.check_availability(team)
end
@tag :ee_only
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns error when user trial hasn't started and was created after the business tier launch" do
_user = new_user(trial_expiry_date: nil)
assert {:error, :upgrade_required} ==
Plausible.Billing.Feature.StatsAPI.check_availability(nil)
end
test "Plausible.Billing.Feature.Props.check_availability/1 applies grandfathering to old plans" do
team = new_user() |> subscribe_to_plan(@v1_plan_id) |> team_of()
assert :ok == Plausible.Billing.Feature.Props.check_availability(team)
end
test "Plausible.Billing.Feature.Goals.check_availability/2 always returns :ok" do
t1 = new_user() |> subscribe_to_plan(@v1_plan_id) |> team_of()
t2 = new_user() |> subscribe_to_growth_plan() |> team_of() t2 = new_user() |> subscribe_to_growth_plan() |> team_of()
t3 = new_user() |> subscribe_to_business_plan() |> team_of() t3 = new_user() |> subscribe_to_business_plan() |> team_of()
t4 = new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321") |> team_of() t4 = new_user() |> subscribe_to_enterprise_plan(paddle_plan_id: "123321") |> team_of()
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t1) assert :ok == Goals.check_availability(t1)
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t2) assert :ok == Goals.check_availability(t2)
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t3) assert :ok == Goals.check_availability(t3)
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t4) assert :ok == Goals.check_availability(t4)
end end
for {mod, property} <- [ for {mod, property} <- [
{Plausible.Billing.Feature.Funnels, :funnels_enabled}, {Funnels, :funnels_enabled},
{Plausible.Billing.Feature.Props, :props_enabled} {Props, :props_enabled}
] do ] do
test "#{mod}.toggle/2 toggles #{property} on and off" do test "#{mod}.toggle/2 toggles #{property} on and off" do
user = new_user() user = new_user()

View File

@ -3,13 +3,12 @@ defmodule Plausible.Billing.QuotaTest do
use Plausible.DataCase, async: true use Plausible.DataCase, async: true
use Plausible use Plausible
alias Plausible.Billing.{Quota, Plans} alias Plausible.Billing.{Quota, Plans}
alias Plausible.Billing.Feature.{Goals, Props, SitesAPI, StatsAPI, SharedLinks} alias Plausible.Billing.Feature.{Goals, Props, StatsAPI, SharedLinks}
use Plausible.Teams.Test use Plausible.Teams.Test
on_ee do on_ee do
alias Plausible.Billing.Feature.Funnels alias Plausible.Billing.Feature.{Funnels, RevenueGoals, SitesAPI}
alias Plausible.Billing.Feature.RevenueGoals
end end
@legacy_plan_id "558746" @legacy_plan_id "558746"
@ -471,22 +470,22 @@ defmodule Plausible.Billing.QuotaTest do
end end
end end
describe "features_usage/2" do on_ee do
test "returns an empty list for a user/site who does not use any feature" do describe "features_usage/2" do
assert [] == Plausible.Teams.Billing.features_usage(team_of(new_user())) test "returns an empty list for a user/site who does not use any feature" do
assert [] == Plausible.Teams.Billing.features_usage(nil, [new_site().id]) assert [] == Plausible.Teams.Billing.features_usage(team_of(new_user()))
end assert [] == Plausible.Teams.Billing.features_usage(nil, [new_site().id])
end
test "returns [Props] when user/site uses custom props" do test "returns [Props] when user/site uses custom props" do
user = new_user() user = new_user()
site = new_site(owner: user, allowed_event_props: ["dummy"]) site = new_site(owner: user, allowed_event_props: ["dummy"])
team = team_of(user) team = team_of(user)
assert [Props] == Plausible.Teams.Billing.features_usage(nil, [site.id]) assert [Props] == Plausible.Teams.Billing.features_usage(nil, [site.id])
assert [Props] == Plausible.Teams.Billing.features_usage(team) assert [Props] == Plausible.Teams.Billing.features_usage(team)
end end
on_ee do
test "returns [Funnels] when user/site uses funnels" do test "returns [Funnels] when user/site uses funnels" do
user = new_user() user = new_user()
site = new_site(owner: user) site = new_site(owner: user)
@ -509,39 +508,37 @@ defmodule Plausible.Billing.QuotaTest do
assert [RevenueGoals] == Plausible.Teams.Billing.features_usage(nil, [site.id]) assert [RevenueGoals] == Plausible.Teams.Billing.features_usage(nil, [site.id])
assert [RevenueGoals] == Plausible.Teams.Billing.features_usage(team) assert [RevenueGoals] == Plausible.Teams.Billing.features_usage(team)
end end
end
test "returns [StatsAPI] when user has a stats api key" do test "returns [StatsAPI] when user has a stats api key" do
user = new_user(trial_expiry_date: Date.utc_today()) user = new_user(trial_expiry_date: Date.utc_today())
team = team_of(user) team = team_of(user)
insert(:api_key, user: user) insert(:api_key, user: user)
assert [StatsAPI] == Plausible.Teams.Billing.features_usage(team) assert [StatsAPI] == Plausible.Teams.Billing.features_usage(team)
end end
test "returns [SitesAPI] when user has a Sites API enabled api key" do test "returns [SitesAPI] when user has a Sites API enabled api key" do
user = user =
new_user() new_user()
|> subscribe_to_enterprise_plan(features: [StatsAPI, SitesAPI]) |> subscribe_to_enterprise_plan(features: [StatsAPI, SitesAPI])
team = team_of(user) team = team_of(user)
insert(:api_key, user: user, scopes: ["sites:provision:*"]) insert(:api_key, user: user, scopes: ["sites:provision:*"])
assert [StatsAPI, SitesAPI] == Plausible.Teams.Billing.features_usage(team) assert [StatsAPI, SitesAPI] == Plausible.Teams.Billing.features_usage(team)
end end
test "returns feature usage based on a user and a custom list of site_ids" do test "returns feature usage based on a user and a custom list of site_ids" do
user = new_user(trial_expiry_date: Date.utc_today()) user = new_user(trial_expiry_date: Date.utc_today())
team = team_of(user) team = team_of(user)
insert(:api_key, user: user) insert(:api_key, user: user)
site_using_props = new_site(allowed_event_props: ["dummy"]) site_using_props = new_site(allowed_event_props: ["dummy"])
site_ids = [site_using_props.id] site_ids = [site_using_props.id]
assert [Props, StatsAPI] == Plausible.Teams.Billing.features_usage(team, site_ids) assert [Props, StatsAPI] == Plausible.Teams.Billing.features_usage(team, site_ids)
end end
on_ee do
test "returns multiple features used by the user" do test "returns multiple features used by the user" do
user = new_user() user = new_user()
insert(:api_key, user: user) insert(:api_key, user: user)
@ -563,10 +560,10 @@ defmodule Plausible.Billing.QuotaTest do
assert [Props, Funnels, RevenueGoals, StatsAPI] == assert [Props, Funnels, RevenueGoals, StatsAPI] ==
Plausible.Teams.Billing.features_usage(team) Plausible.Teams.Billing.features_usage(team)
end end
end
test "accounts only for sites the user owns" do test "accounts only for sites the user owns" do
assert [] == Plausible.Teams.Billing.features_usage(nil) assert [] == Plausible.Teams.Billing.features_usage(nil)
end
end end
end end
@ -575,8 +572,7 @@ defmodule Plausible.Billing.QuotaTest do
test "users with expired trials have no access to subscription features" do test "users with expired trials have no access to subscription features" do
team = new_user(trial_expiry_date: ~D[2023-01-01]) |> team_of() team = new_user(trial_expiry_date: ~D[2023-01-01]) |> team_of()
assert [Goals, Plausible.Billing.Feature.SharedLinks] == assert [Goals] == Plausible.Teams.Billing.allowed_features_for(team)
Plausible.Teams.Billing.allowed_features_for(team)
end end
end end
@ -605,7 +601,11 @@ defmodule Plausible.Billing.QuotaTest do
subscribe_to_enterprise_plan(user, subscribe_to_enterprise_plan(user,
monthly_pageview_limit: 100_000, monthly_pageview_limit: 100_000,
site_limit: 500, site_limit: 500,
features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.Funnels] features: [
Plausible.Billing.Feature.StatsAPI,
Plausible.Billing.Feature.Funnels,
Plausible.Billing.Feature.SharedLinks
]
) )
team = team_of(user) team = team_of(user)
@ -660,10 +660,7 @@ defmodule Plausible.Billing.QuotaTest do
team = team_of(user) team = team_of(user)
assert [ assert [Plausible.Billing.Feature.StatsAPI] ==
Plausible.Billing.Feature.StatsAPI,
Plausible.Billing.Feature.SharedLinks
] ==
Plausible.Teams.Billing.allowed_features_for(team) Plausible.Teams.Billing.allowed_features_for(team)
end end
@ -678,8 +675,7 @@ defmodule Plausible.Billing.QuotaTest do
assert [ assert [
Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.StatsAPI,
Plausible.Billing.Feature.SitesAPI, Plausible.Billing.Feature.SitesAPI
Plausible.Billing.Feature.SharedLinks
] == ] ==
Plausible.Teams.Billing.allowed_features_for(team) Plausible.Teams.Billing.allowed_features_for(team)
end end
@ -1059,4 +1055,31 @@ defmodule Plausible.Billing.QuotaTest do
assert suggested_tier == :business assert suggested_tier == :business
end end
end end
describe "feature usage and ensuring access" do
@describetag :ee_only
setup [:create_user, :create_site]
test "subscribing to Starter plan when using shared links", %{user: user, site: site} do
insert(:shared_link, site: site)
usage = team_of(user) |> Plausible.Teams.Billing.quota_usage(with_features: true)
plan = Plausible.Billing.Plans.find(@v5_10m_starter_plan_id)
assert {:error, {:unavailable_features, [SharedLinks]}} =
Quota.ensure_feature_access(usage, plan)
end
for special_name <- Plausible.Sites.shared_link_special_names() do
test "having a shared link with the name '#{special_name}' does not count as using shared links",
%{user: user, site: site} do
insert(:shared_link, site: site, name: unquote(special_name))
usage = team_of(user) |> Plausible.Teams.Billing.quota_usage(with_features: true)
plan = Plausible.Billing.Plans.find(@v5_10m_starter_plan_id)
assert :ok = Quota.ensure_feature_access(usage, plan)
end
end
end
end end

View File

@ -436,6 +436,39 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
res = json_response(conn, 404) res = json_response(conn, 404)
assert res["error"] == "Site could not be found" assert res["error"] == "Site could not be found"
end end
test "fails to create without access to SharedLinks feature", %{
conn: conn,
site: site,
user: user
} do
subscribe_to_enterprise_plan(user, features: [Plausible.Billing.Feature.SitesAPI])
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: "My Link"
})
res = json_response(conn, 402)
assert res["error"] == "Your current subscription plan does not include Shared Links"
end
for special_name <- Plausible.Sites.shared_link_special_names() do
test "fails to create with the special '#{special_name}' name intended for Plugins API",
%{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/shared-links", %{
site_id: site.domain,
name: unquote(special_name)
})
res = json_response(conn, 400)
assert res["error"] == "This name is reserved. Please choose another one"
end
end
end end
describe "PUT /api/v1/sites/goals" do describe "PUT /api/v1/sites/goals" do

View File

@ -691,6 +691,23 @@ defmodule PlausibleWeb.SiteControllerTest do
end end
end end
describe "GET /:domain/settings/visibility" do
setup [:create_user, :log_in, :create_site]
test "does not render shared links with special names", %{conn: conn, site: site} do
for special_name <- Plausible.Sites.shared_link_special_names() do
insert(:shared_link, name: special_name, site: site)
html =
conn
|> get("/#{site.domain}/settings/visibility")
|> html_response(200)
refute text_of_element(html, "#shared-links-table") =~ special_name
end
end
end
describe "GET /:domain/settings/goals" do describe "GET /:domain/settings/goals" do
setup [:create_user, :log_in, :create_site] setup [:create_user, :log_in, :create_site]
@ -1614,6 +1631,39 @@ defmodule PlausibleWeb.SiteControllerTest do
refute is_nil(link.password_hash) refute is_nil(link.password_hash)
assert link.name == "New name" assert link.name == "New name"
end end
test "fails to create when subscription plan doesn't support the shared links feature", %{
conn: conn,
user: user,
site: site
} do
subscribe_to_starter_plan(user)
conn =
post(conn, "/sites/#{site.domain}/shared-links", %{
"shared_link" => %{"name" => "Something"}
})
assert redirected_to(conn, 302) == Routes.site_path(conn, :settings_visibility, site.domain)
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
"Your current subscription plan does not include Shared Links"
refute Repo.exists?(Plausible.Site.SharedLink)
end
for special_name <- Plausible.Sites.shared_link_special_names() do
test "fails to create with the special '#{special_name}' name intended for Plugins API",
%{conn: conn, site: site} do
conn =
post(conn, "/sites/#{site.domain}/shared-links", %{
"shared_link" => %{"name" => unquote(special_name)}
})
assert html_response(conn, 200) =~ "This name is reserved. Please choose another one"
refute Repo.exists?(Plausible.Site.SharedLink)
end
end
end end
describe "GET /sites/:domain/shared-links/edit" do describe "GET /sites/:domain/shared-links/edit" do
@ -1641,6 +1691,19 @@ defmodule PlausibleWeb.SiteControllerTest do
assert link.name == "Updated link name" assert link.name == "Updated link name"
end end
for special_name <- Plausible.Sites.shared_link_special_names() do
test "fails to change link name to #{special_name}", %{conn: conn, site: site} do
link = insert(:shared_link, site: site)
conn =
put(conn, "/sites/#{site.domain}/shared-links/#{link.slug}", %{
"shared_link" => %{"name" => unquote(special_name)}
})
assert html_response(conn, 200) =~ "This name is reserved. Please choose another one"
end
end
end end
describe "DELETE /sites/:domain/shared-links/:slug" do describe "DELETE /sites/:domain/shared-links/:slug" do

View File

@ -173,7 +173,7 @@ defmodule PlausibleWeb.StatsControllerTest do
locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!()
conn = get(conn, "/" <> locked_site.domain) conn = get(conn, "/" <> locked_site.domain)
resp = html_response(conn, 200) resp = html_response(conn, 200)
assert resp =~ "Dashboard locked" assert resp =~ "Dashboard Locked"
assert resp =~ "Please subscribe to the appropriate tier with the link below" assert resp =~ "Please subscribe to the appropriate tier with the link below"
end end
@ -185,7 +185,7 @@ defmodule PlausibleWeb.StatsControllerTest do
conn = get(conn, "/" <> locked_site.domain) conn = get(conn, "/" <> locked_site.domain)
resp = html_response(conn, 200) resp = html_response(conn, 200)
assert resp =~ "Dashboard locked" assert resp =~ "Dashboard Locked"
assert resp =~ "Please subscribe to the appropriate tier with the link below" assert resp =~ "Please subscribe to the appropriate tier with the link below"
end end
@ -197,7 +197,7 @@ defmodule PlausibleWeb.StatsControllerTest do
conn = get(conn, "/" <> locked_site.domain) conn = get(conn, "/" <> locked_site.domain)
resp = html_response(conn, 200) resp = html_response(conn, 200)
assert resp =~ "Dashboard locked" assert resp =~ "Dashboard Locked"
refute resp =~ "Please subscribe to the appropriate tier with the link below" refute resp =~ "Please subscribe to the appropriate tier with the link below"
assert resp =~ "Owner of this site must upgrade their subscription plan" assert resp =~ "Owner of this site must upgrade their subscription plan"
end end
@ -207,7 +207,7 @@ defmodule PlausibleWeb.StatsControllerTest do
locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!() locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!()
conn = get(build_conn(), "/" <> locked_site.domain) conn = get(build_conn(), "/" <> locked_site.domain)
resp = html_response(conn, 200) resp = html_response(conn, 200)
assert resp =~ "Dashboard locked" assert resp =~ "Dashboard Locked"
assert resp =~ "You can check back later or contact the site owner" assert resp =~ "You can check back later or contact the site owner"
end end
@ -1331,10 +1331,42 @@ defmodule PlausibleWeb.StatsControllerTest do
conn = get(conn, "/share/test-site.com/?auth=#{link.slug}") conn = get(conn, "/share/test-site.com/?auth=#{link.slug}")
assert html_response(conn, 200) =~ "Dashboard locked" assert html_response(conn, 200) =~ "Dashboard Locked"
refute String.contains?(html_response(conn, 200), "Back to my sites") refute String.contains?(html_response(conn, 200), "Back to my sites")
end end
test "shows locked page if shared link is locked due to insufficient team subscription", %{
conn: conn
} do
site = new_site(domain: "test-site.com")
link = insert(:shared_link, site: site)
insert(:starter_subscription, team: site.team)
conn = get(conn, "/share/test-site.com/?auth=#{link.slug}")
assert html_response(conn, 200) =~ "Shared Link Unavailable"
refute String.contains?(html_response(conn, 200), "Back to my sites")
end
for special_name <- Plausible.Sites.shared_link_special_names() do
test "shows dashboard if team subscription insufficient but shared link name is '#{special_name}'",
%{conn: conn} do
site = new_site(domain: "test-site.com")
link = insert(:shared_link, site: site, name: unquote(special_name))
insert(:starter_subscription, team: site.team)
html =
conn
|> get("/share/test-site.com/?auth=#{link.slug}")
|> html_response(200)
assert element_exists?(html, @react_container)
refute html =~ "Shared Link Unavailable"
end
end
test "renders 404 not found when no auth parameter supplied", %{conn: conn} do test "renders 404 not found when no auth parameter supplied", %{conn: conn} do
conn = get(conn, "/share/example.com") conn = get(conn, "/share/example.com")
assert response(conn, 404) =~ "nothing here" assert response(conn, 404) =~ "nothing here"

View File

@ -153,7 +153,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.CapabilitiesTest do
"StatsAPI" => true, "StatsAPI" => true,
"SitesAPI" => true, "SitesAPI" => true,
"SiteSegments" => false, "SiteSegments" => false,
"SharedLinks" => true "SharedLinks" => false
} }
} }

View File

@ -177,6 +177,58 @@ defmodule PlausibleWeb.Plugins.API.Controllers.SharedLinksTest do
assert %{errors: [%{detail: "Missing field: shared_link"}]} = resp assert %{errors: [%{detail: "Missing field: shared_link"}]} = resp
end end
test "skips shared links feature access check", %{
conn: conn,
site: site,
token: token
} do
insert(:starter_subscription, team: site.team)
url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create)
resp =
authenticate(conn, site.domain, token)
|> put_req_header("content-type", "application/json")
|> put(url, %{
shared_link: %{
name: "My Shared Link"
}
})
|> json_response(201)
|> assert_schema("SharedLink", spec())
assert resp.shared_link.name == "My Shared Link"
assert resp.shared_link.href =~
"http://localhost:8000/share/#{URI.encode_www_form(site.domain)}?auth="
end
for special_name <- Plausible.Sites.shared_link_special_names() do
test "can create shared link with the reserved '#{special_name}' name", %{
conn: conn,
site: site,
token: token
} do
url = Routes.plugins_api_shared_links_url(PlausibleWeb.Endpoint, :create)
resp =
authenticate(conn, site.domain, token)
|> put_req_header("content-type", "application/json")
|> put(url, %{
shared_link: %{
name: unquote(special_name)
}
})
|> json_response(201)
|> assert_schema("SharedLink", spec())
assert resp.shared_link.name == unquote(special_name)
assert resp.shared_link.href =~
"http://localhost:8000/share/#{URI.encode_www_form(site.domain)}?auth="
end
end
end end
describe "get /shared_links" do describe "get /shared_links" do