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,
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
else
{:error, :site_not_found} ->

View File

@ -31,8 +31,15 @@ defmodule Plausible.Plugins.API.SharedLinks do
{:ok, Plausible.Site.SharedLink.t()}
def get_or_create(site, name, password \\ nil) do
case get_by_name(site, name) do
nil -> Plausible.Sites.create_shared_link(site, name, password)
shared_link -> {:ok, shared_link}
nil ->
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

View File

@ -369,13 +369,4 @@ defmodule Plausible.Segments do
"#{field} #{formatted_message}"
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

View File

@ -14,15 +14,27 @@ defmodule Plausible.Site.SharedLink do
timestamps()
end
def changeset(link, attrs \\ %{}) do
def changeset(link, attrs \\ %{}, opts \\ []) do
link
|> cast(attrs, [:slug, :password, :name])
|> validate_required([:slug, :name])
|> validate_special_name(opts)
|> unique_constraint(:slug)
|> unique_constraint(:name, name: :shared_links_site_id_name_index)
|> hash_password()
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
case link.changes[:password] do
nil ->

View File

@ -6,15 +6,31 @@ defmodule Plausible.Sites do
import Ecto.Query
alias Plausible.Auth
alias Plausible.Billing
alias Plausible.Repo
alias Plausible.Site
alias Plausible.{Auth, Repo, Site, Teams, Billing}
alias Plausible.Billing.Feature.SharedLinks
alias Plausible.Site.SharedLink
alias Plausible.Teams
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
Repo.get_by(Site, domain: domain)
end
@ -338,17 +354,21 @@ defmodule Plausible.Sites do
!!stats_start_date(site)
end
def create_shared_link(site, name, password \\ nil) do
changes =
SharedLink.changeset(
%SharedLink{
site_id: site.id,
slug: Nanoid.generate()
},
%{name: name, password: password}
)
def create_shared_link(site, name, opts \\ []) do
password = Keyword.get(opts, :password)
site = Plausible.Repo.preload(site, :team)
skip_feature_check? = Keyword.get(opts, :skip_feature_check?, false)
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
def shared_link_url(site, link) do

View File

@ -478,6 +478,7 @@ defmodule Plausible.Teams.Billing do
"""
def features_usage(team, site_ids \\ nil)
on_ee do
def features_usage(nil, nil), do: []
def features_usage(%Teams.Team{} = team, nil) do
@ -528,35 +529,38 @@ defmodule Plausible.Teams.Billing do
end
def features_usage(nil, site_ids) when is_list(site_ids) do
shared_links_usage_q =
from l in Plausible.Site.SharedLink,
where:
l.site_id in ^site_ids and l.name not in ^Plausible.Sites.shared_link_special_names()
props_usage_q =
from s in Plausible.Site,
where: s.id in ^site_ids and fragment("cardinality(?) > 0", s.allowed_event_props)
funnels_usage_q = from f in "funnels", where: f.site_id in ^site_ids
revenue_goals_usage_q =
from g in Plausible.Goal,
where: g.site_id in ^site_ids and not is_nil(g.currency)
queries =
on_ee do
funnels_usage_q = from f in "funnels", where: f.site_id in ^site_ids
site_segments_usage_q =
from s in Plausible.Segments.Segment, where: s.site_id in ^site_ids and s.type == :site
[
{Feature.SharedLinks, shared_links_usage_q},
{Feature.Props, props_usage_q},
{Feature.Funnels, funnels_usage_q},
{Feature.RevenueGoals, revenue_goals_usage_q},
{Feature.SiteSegments, Plausible.Segments.get_site_segments_usage_query(site_ids)}
{Feature.SiteSegments, site_segments_usage_q}
]
else
[
{Feature.Props, props_usage_q},
{Feature.RevenueGoals, revenue_goals_usage_q}
]
end
Enum.reduce(queries, [], fn {feature, query}, acc ->
|> 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
defp query_team_member_emails(team, pending_ownership_site_ids, exclude_emails) do
pending_owner_memberships_q =
@ -613,7 +617,7 @@ defmodule Plausible.Teams.Billing do
case Plans.get_subscription_plan(team.subscription) do
%EnterprisePlan{features: features} ->
features ++ [SharedLinks]
features
%Plan{features: features} ->
features
@ -625,7 +629,7 @@ defmodule Plausible.Teams.Billing do
if Teams.on_trial?(team) do
Feature.list() -- [SitesAPI]
else
[Goals, SharedLinks]
[Goals]
end
end
end

View File

@ -162,7 +162,14 @@ defmodule PlausibleWeb.SiteController do
def settings_visibility(conn, _params) do
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
|> render("settings_visibility.html",
@ -576,10 +583,15 @@ defmodule PlausibleWeb.SiteController do
def create_shared_link(conn, %{"shared_link" => link}) do
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} ->
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} ->
conn
|> assign(:skip_plausible_tracking, true)
@ -609,7 +621,7 @@ defmodule PlausibleWeb.SiteController do
changeset = Plausible.Site.SharedLink.changeset(shared_link, params)
case Repo.update(changeset) do
{:ok, _created} ->
{:ok, _updated} ->
redirect(conn, to: Routes.site_path(conn, :settings_visibility, site.domain))
{:error, changeset} ->

View File

@ -48,6 +48,7 @@ defmodule PlausibleWeb.StatsController do
alias Plausible.Stats.{Filters, Query}
alias Plausible.Teams
alias PlausibleWeb.Api
alias Plausible.Billing.Feature.SharedLinks
plug(PlausibleWeb.Plugs.AuthorizeSiteAccess when action in [:stats, :csv_export])
@ -341,7 +342,30 @@ defmodule PlausibleWeb.StatsController do
end
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
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) ->
current_user = conn.assigns[:current_user]
site_role = get_fallback_site_role(conn)
@ -379,15 +403,6 @@ defmodule PlausibleWeb.StatsController do
load_dashboard_js: true,
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

View File

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

View File

@ -17,11 +17,19 @@
</svg>
</div>
<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>
<%= case @conn.assigns[:site_role] do %>
<% role when role in [:owner, :billing] -> %>
<%= case @conn.assigns do %>
<% %{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">
<p>
This dashboard is locked because you don't have a valid subscription.
@ -40,7 +48,7 @@
Manage my subscription
</.button_link>
</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">
<p>
Owner of this site must upgrade their subscription plan in order to unlock the stats.

View File

@ -1,11 +1,24 @@
defmodule Plausible.Billing.FeatureTest do
alias Plausible.Billing.Feature.SiteSegments
use Plausible.DataCase
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
test "#{mod}.check_availability/1 returns :ok when site owner is on a enterprise plan" do
@v1_growth_plan_id "558018"
@v5_growth_plan_id "910429"
describe "business features (for everyone)" do
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)])
@ -14,6 +27,15 @@ defmodule Plausible.Billing.FeatureTest do
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)
@ -24,80 +46,117 @@ defmodule Plausible.Billing.FeatureTest do
assert {:error, :upgrade_required} == unquote(mod).check_availability(team)
end
test "#{mod}.check_availability/1 returns error when site owner is on an old plan" do
team = new_user() |> subscribe_to_plan(@v1_plan_id) |> team_of()
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
end
test "Plausible.Billing.Feature.SharedLinks.check_availability/2 returns :ok when user is on an enterprise plan" do
team = new_user() |> subscribe_to_enterprise_plan() |> team_of()
assert :ok == Plausible.Billing.Feature.SharedLinks.check_availability(team)
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on a business plan" do
team = new_user() |> subscribe_to_business_plan() |> 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 old plan" do
team = new_user() |> subscribe_to_plan(@v1_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
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 == Plausible.Billing.Feature.StatsAPI.check_availability(team)
assert :ok == unquote(mod).check_availability(team)
end
end
end
test "Plausible.Billing.Feature.StatsAPI.check_availability/2 returns :ok when user is on an enterprise plan" do
describe "business features (grandfathered Growth access before v4)" do
for mod <- [Props, StatsAPI] 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: [Plausible.Billing.Feature.StatsAPI]
)
|> subscribe_to_enterprise_plan(paddle_plan_id: "123321", features: [unquote(mod)])
|> team_of()
assert :ok == Plausible.Billing.Feature.StatsAPI.check_availability(team)
assert :ok == unquote(mod).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
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} ==
Plausible.Billing.Feature.StatsAPI.check_availability(team)
assert {:error, :upgrade_required} == unquote(mod).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)
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 "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)
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
test "Plausible.Billing.Feature.Goals.check_availability/2 always returns :ok" do
t1 = new_user() |> subscribe_to_plan(@v1_plan_id) |> team_of()
describe "growth features" do
for mod <- [SharedLinks, SiteSegments] 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()
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
test "Goals.check_availability/2 always returns :ok" do
t1 = new_user() |> subscribe_to_plan(@v1_growth_plan_id) |> team_of()
t2 = new_user() |> subscribe_to_growth_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()
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t1)
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t2)
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t3)
assert :ok == Plausible.Billing.Feature.Goals.check_availability(t4)
assert :ok == Goals.check_availability(t1)
assert :ok == Goals.check_availability(t2)
assert :ok == Goals.check_availability(t3)
assert :ok == Goals.check_availability(t4)
end
for {mod, property} <- [
{Plausible.Billing.Feature.Funnels, :funnels_enabled},
{Plausible.Billing.Feature.Props, :props_enabled}
{Funnels, :funnels_enabled},
{Props, :props_enabled}
] do
test "#{mod}.toggle/2 toggles #{property} on and off" do
user = new_user()

View File

@ -3,13 +3,12 @@ defmodule Plausible.Billing.QuotaTest do
use Plausible.DataCase, async: true
use Plausible
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
on_ee do
alias Plausible.Billing.Feature.Funnels
alias Plausible.Billing.Feature.RevenueGoals
alias Plausible.Billing.Feature.{Funnels, RevenueGoals, SitesAPI}
end
@legacy_plan_id "558746"
@ -471,6 +470,7 @@ defmodule Plausible.Billing.QuotaTest do
end
end
on_ee do
describe "features_usage/2" do
test "returns an empty list for a user/site who does not use any feature" do
assert [] == Plausible.Teams.Billing.features_usage(team_of(new_user()))
@ -486,7 +486,6 @@ defmodule Plausible.Billing.QuotaTest do
assert [Props] == Plausible.Teams.Billing.features_usage(team)
end
on_ee do
test "returns [Funnels] when user/site uses funnels" do
user = new_user()
site = new_site(owner: user)
@ -509,7 +508,6 @@ defmodule Plausible.Billing.QuotaTest do
assert [RevenueGoals] == Plausible.Teams.Billing.features_usage(nil, [site.id])
assert [RevenueGoals] == Plausible.Teams.Billing.features_usage(team)
end
end
test "returns [StatsAPI] when user has a stats api key" do
user = new_user(trial_expiry_date: Date.utc_today())
@ -541,7 +539,6 @@ defmodule Plausible.Billing.QuotaTest do
assert [Props, StatsAPI] == Plausible.Teams.Billing.features_usage(team, site_ids)
end
on_ee do
test "returns multiple features used by the user" do
user = new_user()
insert(:api_key, user: user)
@ -563,20 +560,19 @@ defmodule Plausible.Billing.QuotaTest do
assert [Props, Funnels, RevenueGoals, StatsAPI] ==
Plausible.Teams.Billing.features_usage(team)
end
end
test "accounts only for sites the user owns" do
assert [] == Plausible.Teams.Billing.features_usage(nil)
end
end
end
describe "allowed_features_for/1" do
on_ee 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()
assert [Goals, Plausible.Billing.Feature.SharedLinks] ==
Plausible.Teams.Billing.allowed_features_for(team)
assert [Goals] == Plausible.Teams.Billing.allowed_features_for(team)
end
end
@ -605,7 +601,11 @@ defmodule Plausible.Billing.QuotaTest do
subscribe_to_enterprise_plan(user,
monthly_pageview_limit: 100_000,
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)
@ -660,10 +660,7 @@ defmodule Plausible.Billing.QuotaTest do
team = team_of(user)
assert [
Plausible.Billing.Feature.StatsAPI,
Plausible.Billing.Feature.SharedLinks
] ==
assert [Plausible.Billing.Feature.StatsAPI] ==
Plausible.Teams.Billing.allowed_features_for(team)
end
@ -678,8 +675,7 @@ defmodule Plausible.Billing.QuotaTest do
assert [
Plausible.Billing.Feature.StatsAPI,
Plausible.Billing.Feature.SitesAPI,
Plausible.Billing.Feature.SharedLinks
Plausible.Billing.Feature.SitesAPI
] ==
Plausible.Teams.Billing.allowed_features_for(team)
end
@ -1059,4 +1055,31 @@ defmodule Plausible.Billing.QuotaTest do
assert suggested_tier == :business
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

View File

@ -436,6 +436,39 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
res = json_response(conn, 404)
assert res["error"] == "Site could not be found"
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
describe "PUT /api/v1/sites/goals" do

View File

@ -691,6 +691,23 @@ defmodule PlausibleWeb.SiteControllerTest do
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
setup [:create_user, :log_in, :create_site]
@ -1614,6 +1631,39 @@ defmodule PlausibleWeb.SiteControllerTest do
refute is_nil(link.password_hash)
assert link.name == "New name"
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
describe "GET /sites/:domain/shared-links/edit" do
@ -1641,6 +1691,19 @@ defmodule PlausibleWeb.SiteControllerTest do
assert link.name == "Updated link name"
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
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!()
conn = get(conn, "/" <> locked_site.domain)
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"
end
@ -185,7 +185,7 @@ defmodule PlausibleWeb.StatsControllerTest do
conn = get(conn, "/" <> locked_site.domain)
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"
end
@ -197,7 +197,7 @@ defmodule PlausibleWeb.StatsControllerTest do
conn = get(conn, "/" <> locked_site.domain)
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"
assert resp =~ "Owner of this site must upgrade their subscription plan"
end
@ -207,7 +207,7 @@ defmodule PlausibleWeb.StatsControllerTest do
locked_site.team |> Ecto.Changeset.change(locked: true) |> Repo.update!()
conn = get(build_conn(), "/" <> locked_site.domain)
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"
end
@ -1331,10 +1331,42 @@ defmodule PlausibleWeb.StatsControllerTest do
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")
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
conn = get(conn, "/share/example.com")
assert response(conn, 404) =~ "nothing here"

View File

@ -153,7 +153,7 @@ defmodule PlausibleWeb.Plugins.API.Controllers.CapabilitiesTest do
"StatsAPI" => true,
"SitesAPI" => true,
"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
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
describe "get /shared_links" do