Check for limits and features used by site before transferring ownership (#3445)

* Simplify team_member_usage query

* Check limits before transferring ownership

* Extract invite creation to dedicated service module

* Simplify team member usage query

* Remove unnecessary distinct clause

* Delegate CreateInvitation via Memberships
This commit is contained in:
Vini Brasil 2023-10-25 09:00:31 -03:00 committed by GitHub
parent a226773da1
commit ad05af08a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 608 additions and 476 deletions

View File

@ -108,27 +108,34 @@ defmodule Plausible.Billing.Quota do
with the user's sites. with the user's sites.
""" """
def team_member_usage(user) do def team_member_usage(user) do
Plausible.Repo.aggregate(team_member_usage_query(user), :count)
end
@doc false
def team_member_usage_query(user, site \\ nil) do
owned_sites_query = owned_sites_query(user)
owned_sites_query =
if site do
where(owned_sites_query, [os], os.site_id == ^site.id)
else
owned_sites_query
end
team_members_query = team_members_query =
from os in subquery(owned_sites_query(user)), from os in subquery(owned_sites_query),
inner_join: sm in Plausible.Site.Membership, inner_join: sm in Plausible.Site.Membership,
on: sm.site_id == os.site_id, on: sm.site_id == os.site_id,
inner_join: u in assoc(sm, :user), inner_join: u in assoc(sm, :user),
select: %{email: u.email} where: sm.role != :owner,
select: u.email
invitations_and_team_members_query =
from i in Plausible.Auth.Invitation, from i in Plausible.Auth.Invitation,
inner_join: os in subquery(owned_sites_query(user)), inner_join: os in subquery(owned_sites_query),
on: i.site_id == os.site_id, on: i.site_id == os.site_id,
where: i.role != :owner, where: i.role != :owner,
select: %{email: i.email}, select: i.email,
union: ^team_members_query union: ^team_members_query
query =
from itm in subquery(invitations_and_team_members_query),
where: itm.email != ^user.email,
select: count(itm.email, :distinct)
Plausible.Repo.one(query)
end end
@spec features_usage(Plausible.Auth.User.t()) :: [atom()] @spec features_usage(Plausible.Auth.User.t()) :: [atom()]

View File

@ -82,7 +82,11 @@ defmodule Plausible.SiteAdmin do
if new_owner do if new_owner do
{:ok, _} = {:ok, _} =
Plausible.Sites.bulk_transfer_ownership(sites, inviter, new_owner.email, Plausible.Site.Memberships.bulk_create_invitation(
sites,
inviter,
new_owner.email,
:owner,
check_permissions: false check_permissions: false
) )
@ -100,7 +104,7 @@ defmodule Plausible.SiteAdmin do
new_owner = Plausible.Auth.find_user_by(email: email) new_owner = Plausible.Auth.find_user_by(email: email)
if new_owner do if new_owner do
case Plausible.Sites.bulk_transfer_ownership_direct(sites, new_owner) do case Plausible.Site.Memberships.bulk_transfer_ownership_direct(sites, new_owner) do
{:ok, _} -> :ok {:ok, _} -> :ok
{:error, :transfer_to_self} -> {:error, "User is already an owner of one of the sites"} {:error, :transfer_to_self} -> {:error, "User is already an owner of one of the sites"}
end end

View File

@ -14,6 +14,14 @@ defmodule Plausible.Site.Memberships do
defdelegate reject_invitation(invitation_id, user), to: Memberships.RejectInvitation defdelegate reject_invitation(invitation_id, user), to: Memberships.RejectInvitation
defdelegate remove_invitation(invitation_id, site), to: Memberships.RemoveInvitation defdelegate remove_invitation(invitation_id, site), to: Memberships.RemoveInvitation
defdelegate create_invitation(site, inviter, invitee_email, role),
to: Memberships.CreateInvitation
defdelegate bulk_create_invitation(sites, inviter, invitee_email, role, opts),
to: Memberships.CreateInvitation
defdelegate bulk_transfer_ownership_direct(sites, new_owner), to: Memberships.CreateInvitation
@spec any?(Auth.User.t()) :: boolean() @spec any?(Auth.User.t()) :: boolean()
def any?(user) do def any?(user) do
user user

View File

@ -0,0 +1,181 @@
defmodule Plausible.Site.Memberships.CreateInvitation do
@moduledoc """
Service for inviting new or existing users to a sites, including ownershhip
transfers.
"""
alias Plausible.Auth.{User, Invitation}
alias Plausible.{Site, Sites, Site.Membership}
alias Plausible.Billing.Quota
import Ecto.Query
@type invite_error() ::
Ecto.Changeset.t()
| :already_a_member
| :transfer_to_self
| {:over_limit, non_neg_integer()}
| :forbidden
| :upgrade_required
@spec create_invitation(Site.t(), User.t(), String.t(), atom()) ::
{:ok, Invitation.t()} | {:error, invite_error()}
@doc """
Invites a new team member to the given site. Returns a
%Plausible.Auth.Invitation{} struct and sends the invitee an email to accept
this invitation.
The inviter must have enough permissions to invite the new team member,
otherwise this function returns `{:error, :forbidden}`.
If the new team member role is `:owner`, this function handles the invitation
as an ownership transfer and requires the inviter to be the owner of the site.
"""
def create_invitation(site, inviter, invitee_email, role) do
Plausible.Repo.transaction(fn ->
do_invite(site, inviter, invitee_email, role)
end)
end
@spec bulk_transfer_ownership_direct([Site.t()], User.t()) ::
{:ok, [Membership.t()]} | {:error, invite_error()}
def bulk_transfer_ownership_direct(sites, new_owner) do
Plausible.Repo.transaction(fn ->
for site <- sites do
with site <- Plausible.Repo.preload(site, :owner),
:ok <- ensure_transfer_valid(site, new_owner, :owner),
{:ok, membership} <- Site.Memberships.transfer_ownership(site, new_owner) do
membership
else
{:error, error} -> Plausible.Repo.rollback(error)
end
end
end)
end
@spec bulk_create_invitation([Site.t()], User.t(), String.t(), atom(), Keyword.t()) ::
{:ok, [Invitation.t()]} | {:error, invite_error()}
def bulk_create_invitation(sites, inviter, invitee_email, role, opts \\ []) do
Plausible.Repo.transaction(fn ->
for site <- sites do
do_invite(site, inviter, invitee_email, role, opts)
end
end)
end
defp do_invite(site, inviter, invitee_email, role, opts \\ []) do
attrs = %{email: invitee_email, role: role, site_id: site.id, inviter_id: inviter.id}
with site <- Plausible.Repo.preload(site, :owner),
:ok <- check_invitation_permissions(site, inviter, role, opts),
:ok <- check_team_member_limit(site, role),
invitee <- Plausible.Auth.find_user_by(email: invitee_email),
:ok <- ensure_transfer_valid(site, invitee, role),
:ok <- ensure_new_membership(site, invitee, role),
%Ecto.Changeset{} = changeset <- Invitation.new(attrs),
{:ok, invitation} <- Plausible.Repo.insert(changeset) do
send_invitation_email(invitation, invitee)
invitation
else
{:error, cause} -> Plausible.Repo.rollback(cause)
end
end
defp check_invitation_permissions(site, inviter, requested_role, opts) do
check_permissions? = Keyword.get(opts, :check_permissions, true)
if check_permissions? do
required_roles = if requested_role == :owner, do: [:owner], else: [:admin, :owner]
membership_query =
from(m in Membership,
where: m.user_id == ^inviter.id and m.site_id == ^site.id and m.role in ^required_roles
)
if Plausible.Repo.exists?(membership_query), do: :ok, else: {:error, :forbidden}
else
:ok
end
end
defp send_invitation_email(invitation, invitee) do
invitation = Plausible.Repo.preload(invitation, [:site, :inviter])
email =
case {invitee, invitation.role} do
{invitee, :owner} -> PlausibleWeb.Email.ownership_transfer_request(invitation, invitee)
{nil, _role} -> PlausibleWeb.Email.new_user_invitation(invitation)
{%User{}, _role} -> PlausibleWeb.Email.existing_user_invitation(invitation)
end
Plausible.Mailer.send(email)
end
defp within_team_member_limit_after_transfer?(site, new_owner) do
limit = Quota.team_member_limit(new_owner)
current_usage = Quota.team_member_usage(new_owner)
site_usage = Plausible.Repo.aggregate(Quota.team_member_usage_query(site.owner, site), :count)
usage_after_transfer = current_usage + site_usage
Quota.within_limit?(usage_after_transfer, limit)
end
defp within_site_limit_after_transfer?(new_owner) do
limit = Quota.site_limit(new_owner)
usage_after_transfer = Quota.site_usage(new_owner) + 1
Quota.within_limit?(usage_after_transfer, limit)
end
defp has_access_to_site_features?(site, new_owner) do
features_to_check = [
Plausible.Billing.Feature.Props,
Plausible.Billing.Feature.RevenueGoals,
Plausible.Billing.Feature.Funnels
]
Enum.all?(features_to_check, fn feature ->
if feature.enabled?(site), do: feature.check_availability(new_owner) == :ok, else: true
end)
end
defp ensure_transfer_valid(%Site{} = site, %User{} = new_owner, :owner) do
cond do
Sites.role(new_owner.id, site) == :owner -> {:error, :transfer_to_self}
not within_team_member_limit_after_transfer?(site, new_owner) -> {:error, :upgrade_required}
not within_site_limit_after_transfer?(new_owner) -> {:error, :upgrade_required}
not has_access_to_site_features?(site, new_owner) -> {:error, :upgrade_required}
true -> :ok
end
end
defp ensure_transfer_valid(_site, _invitee, _role) do
:ok
end
defp ensure_new_membership(_site, _invitee, :owner) do
:ok
end
defp ensure_new_membership(site, invitee, _role) do
if invitee && Sites.is_member?(invitee.id, site) do
{:error, :already_a_member}
else
:ok
end
end
defp check_team_member_limit(_site, :owner) do
:ok
end
defp check_team_member_limit(site, _role) do
site = Plausible.Repo.preload(site, :owner)
limit = Quota.team_member_limit(site.owner)
usage = Quota.team_member_usage(site.owner)
if Quota.within_limit?(usage, limit),
do: :ok,
else: {:error, {:over_limit, limit}}
end
end

View File

@ -1,15 +1,7 @@
defmodule Plausible.Sites do defmodule Plausible.Sites do
alias Plausible.{Repo, Site, Site.SharedLink, Auth.User, Billing.Quota} alias Plausible.{Repo, Site, Site.SharedLink, Billing.Quota}
alias PlausibleWeb.Email
import Ecto.Query import Ecto.Query
@type invite_error() ::
Ecto.Changeset.t()
| :already_a_member
| :transfer_to_self
| {:over_limit, non_neg_integer()}
| :forbidden
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
@ -47,139 +39,6 @@ defmodule Plausible.Sites do
end end
end end
@spec bulk_transfer_ownership_direct([Site.t()], Plausible.Auth.User.t()) ::
{:ok, [Plausible.Site.Membership.t()]} | {:error, invite_error()}
def bulk_transfer_ownership_direct(sites, new_owner) do
Repo.transaction(fn ->
for site <- sites do
with :ok <- ensure_transfer_valid(site, new_owner, :owner),
{:ok, membership} <- Site.Memberships.transfer_ownership(site, new_owner) do
membership
else
{:error, error} -> Repo.rollback(error)
end
end
end)
end
@spec bulk_transfer_ownership(
[Site.t()],
Plausible.Auth.User.t(),
String.t(),
Keyword.t()
) :: {:ok, [Plausible.Auth.Invitation.t()]} | {:error, invite_error()}
def bulk_transfer_ownership(sites, inviter, invitee_email, opts \\ []) do
Repo.transaction(fn ->
for site <- sites do
do_invite(site, inviter, invitee_email, :owner, opts)
end
end)
end
@spec invite(Site.t(), Plausible.Auth.User.t(), String.t(), atom()) ::
{:ok, Plausible.Auth.Invitation.t()} | {:error, invite_error()}
@doc """
Invites a new team member to the given site. Returns a
%Plausible.Auth.Invitation{} struct and sends the invitee an email to accept
this invitation.
The inviter must have enough permissions to invite the new team member,
otherwise this function returns `{:error, :forbidden}`.
If the new team member role is `:owner`, this function handles the invitation
as an ownership transfer and requires the inviter to be the owner of the site.
"""
def invite(site, inviter, invitee_email, role) do
Repo.transaction(fn ->
do_invite(site, inviter, invitee_email, role)
end)
end
defp do_invite(site, inviter, invitee_email, role, opts \\ []) do
attrs = %{email: invitee_email, role: role, site_id: site.id, inviter_id: inviter.id}
with :ok <- check_invitation_permissions(site, inviter, role, opts),
:ok <- check_team_member_limit(site, role),
invitee <- Plausible.Auth.find_user_by(email: invitee_email),
:ok <- ensure_transfer_valid(site, invitee, role),
:ok <- ensure_new_membership(site, invitee, role),
%Ecto.Changeset{} = changeset <- Plausible.Auth.Invitation.new(attrs),
{:ok, invitation} <- Repo.insert(changeset) do
send_invitation_email(invitation, invitee)
invitation
else
{:error, cause} -> Repo.rollback(cause)
end
end
defp check_invitation_permissions(site, inviter, requested_role, opts) do
check_permissions? = Keyword.get(opts, :check_permissions, true)
if check_permissions? do
required_roles = if requested_role == :owner, do: [:owner], else: [:admin, :owner]
membership_query =
from(m in Plausible.Site.Membership,
where: m.user_id == ^inviter.id and m.site_id == ^site.id and m.role in ^required_roles
)
if Repo.exists?(membership_query), do: :ok, else: {:error, :forbidden}
else
:ok
end
end
defp send_invitation_email(invitation, invitee) do
invitation = Repo.preload(invitation, [:site, :inviter])
email =
case {invitee, invitation.role} do
{invitee, :owner} -> Email.ownership_transfer_request(invitation, invitee)
{nil, _role} -> Email.new_user_invitation(invitation)
{%User{}, _role} -> Email.existing_user_invitation(invitation)
end
Plausible.Mailer.send(email)
end
defp ensure_transfer_valid(site, invitee, :owner) do
if invitee && role(invitee.id, site) == :owner do
{:error, :transfer_to_self}
else
:ok
end
end
defp ensure_transfer_valid(_site, _invitee, _role) do
:ok
end
defp ensure_new_membership(_site, _invitee, :owner) do
:ok
end
defp ensure_new_membership(site, invitee, _role) do
if invitee && is_member?(invitee.id, site) do
{:error, :already_a_member}
else
:ok
end
end
defp check_team_member_limit(_site, :owner) do
:ok
end
defp check_team_member_limit(site, _role) do
site = Plausible.Repo.preload(site, :owner)
limit = Quota.team_member_limit(site.owner)
usage = Quota.team_member_usage(site.owner)
if Quota.within_limit?(usage, limit),
do: :ok,
else: {:error, {:over_limit, limit}}
end
@spec stats_start_date(Plausible.Site.t()) :: Date.t() | nil @spec stats_start_date(Plausible.Site.t()) :: Date.t() | nil
@doc """ @doc """
Returns the date of the first event of the given site, or `nil` if the site Returns the date of the first event of the given site, or `nil` if the site

View File

@ -13,7 +13,7 @@ defmodule PlausibleWeb.Site.MembershipController do
use PlausibleWeb, :controller use PlausibleWeb, :controller
use Plausible.Repo use Plausible.Repo
alias Plausible.Sites alias Plausible.Sites
alias Plausible.Site.Membership alias Plausible.Site.{Membership, Memberships}
@only_owner_is_allowed_to [:transfer_ownership_form, :transfer_ownership] @only_owner_is_allowed_to [:transfer_ownership_form, :transfer_ownership]
@ -39,7 +39,7 @@ defmodule PlausibleWeb.Site.MembershipController do
site_domain = conn.assigns[:site].domain site_domain = conn.assigns[:site].domain
site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain) site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
case Sites.invite(site, conn.assigns.current_user, email, role) do case Memberships.create_invitation(site, conn.assigns.current_user, email, role) do
{:ok, invitation} -> {:ok, invitation} ->
conn conn
|> put_flash( |> put_flash(
@ -98,7 +98,7 @@ defmodule PlausibleWeb.Site.MembershipController do
site_domain = conn.assigns[:site].domain site_domain = conn.assigns[:site].domain
site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain) site = Sites.get_for_user!(conn.assigns[:current_user].id, site_domain)
case Sites.invite(site, conn.assigns.current_user, email, :owner) do case Memberships.create_invitation(site, conn.assigns.current_user, email, :owner) do
{:ok, _invitation} -> {:ok, _invitation} ->
conn conn
|> put_flash(:success, "Site transfer request has been sent to #{email}") |> put_flash(:success, "Site transfer request has been sent to #{email}")
@ -111,6 +111,16 @@ defmodule PlausibleWeb.Site.MembershipController do
|> put_flash(:error, "Can't transfer ownership to existing owner") |> put_flash(:error, "Can't transfer ownership to existing owner")
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain)) |> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
{:error, :upgrade_required} ->
conn
|> put_flash(:ttl, :timer.seconds(5))
|> put_flash(:error_title, "Transfer error")
|> put_flash(
:error,
"The site you're trying to transfer exceeds the invitee's subscription plan. To proceed, please contact us at hello@plausible.io for further assistance."
)
|> redirect(to: Routes.site_path(conn, :settings_people, site.domain))
{:error, changeset} -> {:error, changeset} ->
errors = Plausible.ChangesetHelpers.traverse_errors(changeset) errors = Plausible.ChangesetHelpers.traverse_errors(changeset)

View File

@ -0,0 +1,376 @@
defmodule Plausible.Site.Memberships.CreateInvitationTest do
alias Plausible.Site.Memberships.CreateInvitation
use Plausible.DataCase
use Bamboo.Test
describe "create_invitation/4" do
test "creates an invitation" do
inviter = insert(:user)
invitee = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :viewer)
end
test "returns validation errors" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:error, changeset} = CreateInvitation.create_invitation(site, inviter, "", :viewer)
assert {"can't be blank", _} = changeset.errors[:email]
end
test "returns error when user is already a member" do
inviter = insert(:user)
invitee = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner),
build(:site_membership, user: invitee, role: :viewer)
]
)
assert {:error, :already_a_member} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :viewer)
assert {:error, :already_a_member} =
CreateInvitation.create_invitation(site, inviter, inviter.email, :viewer)
end
test "sends invitation email for existing users" do
[inviter, invitee] = insert_list(2, :user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :viewer)
assert_email_delivered_with(
to: [nil: invitee.email],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "sends invitation email for new users" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :viewer)
assert_email_delivered_with(
to: [nil: "vini@plausible.test"],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "returns error when owner is over their team member limit" do
[owner, inviter, invitee] = insert_list(3, :user)
memberships =
[
build(:site_membership, user: owner, role: :owner),
build(:site_membership, user: inviter, role: :admin)
] ++ build_list(4, :site_membership)
site = insert(:site, memberships: memberships)
assert {:error, {:over_limit, 5}} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :viewer)
end
test "sends ownership transfer email when inviter role is owner" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :owner)
assert_email_delivered_with(
to: [nil: "vini@plausible.test"],
subject: "[Plausible Analytics] Request to transfer ownership of #{site.domain}"
)
end
test "only allows owners to transfer ownership" do
inviter = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: build(:user), role: :owner),
build(:site_membership, user: inviter, role: :admin)
]
)
assert {:error, :forbidden} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :owner)
end
test "allows ownership transfer to existing site members" do
inviter = insert(:user)
invitee = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner),
build(:site_membership, user: invitee, role: :viewer)
]
)
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, invitee.email, :owner)
end
test "does not allow transferring ownership to existing owner" do
inviter = insert(:user, email: "vini@plausible.test")
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner)
]
)
assert {:error, :transfer_to_self} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :owner)
end
test "does not check for limits when transferring ownership" do
inviter = insert(:user)
memberships =
[build(:site_membership, user: inviter, role: :owner)] ++ build_list(5, :site_membership)
site = insert(:site, memberships: memberships)
assert {:ok, _invitation} =
CreateInvitation.create_invitation(
site,
inviter,
"newowner@plausible.test",
:owner
)
end
test "does not allow viewers to invite users" do
inviter = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: build(:user), role: :owner),
build(:site_membership, user: inviter, role: :viewer)
]
)
assert {:error, :forbidden} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :viewer)
end
test "allows admins to invite other admins" do
inviter = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: build(:user), role: :owner),
build(:site_membership, user: inviter, role: :admin)
]
)
assert {:ok, %Plausible.Auth.Invitation{}} =
CreateInvitation.create_invitation(site, inviter, "vini@plausible.test", :admin)
end
test "does not allow transferring ownership when site does not fit the new owner subscription" do
old_owner = insert(:user, subscription: build(:business_subscription))
new_owner = insert(:user, subscription: build(:growth_subscription))
site_with_too_many_team_members =
insert(:site,
memberships:
[build(:site_membership, user: old_owner, role: :owner)] ++
build_list(6, :site_membership, role: :admin)
)
site_using_premium_features =
insert(:site,
memberships: [build(:site_membership, user: old_owner, role: :owner)],
props_enabled: true,
funnels_enabled: true
)
assert {:error, :upgrade_required} =
CreateInvitation.create_invitation(
site_with_too_many_team_members,
old_owner,
new_owner.email,
:owner
)
assert {:error, :upgrade_required} =
CreateInvitation.create_invitation(
site_using_premium_features,
old_owner,
new_owner.email,
:owner
)
end
end
describe "bulk_create_invitation/5" do
test "initiates ownership transfer for multiple sites in one action" do
admin_user = insert(:user)
new_owner = insert(:user)
site1 =
insert(:site, memberships: [build(:site_membership, user: admin_user, role: :owner)])
site2 =
insert(:site, memberships: [build(:site_membership, user: admin_user, role: :owner)])
assert {:ok, _} =
CreateInvitation.bulk_create_invitation(
[site1, site2],
admin_user,
new_owner.email,
:owner
)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site1.domain}"
)
assert Repo.exists?(
from(i in Plausible.Auth.Invitation,
where:
i.site_id == ^site1.id and i.email == ^new_owner.email and i.role == :owner
)
)
assert_invitation_exists(site1, new_owner.email, :owner)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site2.domain}"
)
assert_invitation_exists(site2, new_owner.email, :owner)
end
test "initiates ownership transfer for multiple sites in one action skipping permission checks" do
superadmin_user = insert(:user)
new_owner = insert(:user)
site1 = insert(:site)
site2 = insert(:site)
assert {:ok, _} =
CreateInvitation.bulk_create_invitation(
[site1, site2],
superadmin_user,
new_owner.email,
:owner,
check_permissions: false
)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site1.domain}"
)
assert Repo.exists?(
from(i in Plausible.Auth.Invitation,
where:
i.site_id == ^site1.id and i.email == ^new_owner.email and i.role == :owner
)
)
assert_invitation_exists(site1, new_owner.email, :owner)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site2.domain}"
)
assert_invitation_exists(site2, new_owner.email, :owner)
end
end
describe "bulk_transfer_ownership_direct/2" do
test "transfers ownership for multiple sites in one action" do
current_owner = insert(:user)
new_owner = insert(:user)
site1 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
site2 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
assert {:ok, _} = CreateInvitation.bulk_transfer_ownership_direct([site1, site2], new_owner)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site1.id,
user_id: new_owner.id,
role: :owner
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site2.id,
user_id: new_owner.id,
role: :owner
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site1.id,
user_id: current_owner.id,
role: :admin
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site2.id,
user_id: current_owner.id,
role: :admin
)
end
test "returns error when user is already an owner for one of the sites" do
current_owner = insert(:user)
new_owner = insert(:user)
site1 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
site2 = insert(:site, memberships: [build(:site_membership, user: new_owner, role: :owner)])
assert {:error, :transfer_to_self} =
CreateInvitation.bulk_transfer_ownership_direct([site1, site2], new_owner)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site1.id,
user_id: current_owner.id,
role: :owner
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site2.id,
user_id: new_owner.id,
role: :owner
)
end
end
defp assert_invitation_exists(site, email, role) do
assert Repo.exists?(
from(i in Plausible.Auth.Invitation,
where: i.site_id == ^site.id and i.email == ^email and i.role == ^role
)
)
end
end

View File

@ -1,6 +1,5 @@
defmodule Plausible.SitesTest do defmodule Plausible.SitesTest do
use Plausible.DataCase use Plausible.DataCase
use Bamboo.Test
alias Plausible.Sites alias Plausible.Sites
describe "is_member?" do describe "is_member?" do
@ -68,310 +67,6 @@ defmodule Plausible.SitesTest do
end end
end end
describe "invite/4" do
test "creates an invitation" do
inviter = insert(:user)
invitee = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, invitee.email, :viewer)
end
test "returns validation errors" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:error, changeset} = Sites.invite(site, inviter, "", :viewer)
assert {"can't be blank", _} = changeset.errors[:email]
end
test "returns error when user is already a member" do
inviter = insert(:user)
invitee = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner),
build(:site_membership, user: invitee, role: :viewer)
]
)
assert {:error, :already_a_member} = Sites.invite(site, inviter, invitee.email, :viewer)
assert {:error, :already_a_member} = Sites.invite(site, inviter, inviter.email, :viewer)
end
test "sends invitation email for existing users" do
[inviter, invitee] = insert_list(2, :user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, invitee.email, :viewer)
assert_email_delivered_with(
to: [nil: invitee.email],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "sends invitation email for new users" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, "vini@plausible.test", :viewer)
assert_email_delivered_with(
to: [nil: "vini@plausible.test"],
subject: "[Plausible Analytics] You've been invited to #{site.domain}"
)
end
test "returns error when owner is over their team member limit" do
[owner, inviter, invitee] = insert_list(3, :user)
memberships =
[
build(:site_membership, user: owner, role: :owner),
build(:site_membership, user: inviter, role: :admin)
] ++ build_list(4, :site_membership)
site = insert(:site, memberships: memberships)
assert {:error, {:over_limit, 5}} = Sites.invite(site, inviter, invitee.email, :viewer)
end
test "sends ownership transfer email when inviter role is owner" do
inviter = insert(:user)
site = insert(:site, memberships: [build(:site_membership, user: inviter, role: :owner)])
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, "vini@plausible.test", :owner)
assert_email_delivered_with(
to: [nil: "vini@plausible.test"],
subject: "[Plausible Analytics] Request to transfer ownership of #{site.domain}"
)
end
test "only allows owners to transfer ownership" do
inviter = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: build(:user), role: :owner),
build(:site_membership, user: inviter, role: :admin)
]
)
assert {:error, :forbidden} = Sites.invite(site, inviter, "vini@plausible.test", :owner)
end
test "allows ownership transfer to existing site members" do
inviter = insert(:user)
invitee = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner),
build(:site_membership, user: invitee, role: :viewer)
]
)
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, invitee.email, :owner)
end
test "does not allow transferring ownership to existing owner" do
inviter = insert(:user, email: "vini@plausible.test")
site =
insert(:site,
memberships: [
build(:site_membership, user: inviter, role: :owner)
]
)
assert {:error, :transfer_to_self} =
Sites.invite(site, inviter, "vini@plausible.test", :owner)
end
test "does not check for limits when transferring ownership" do
inviter = insert(:user)
memberships =
[build(:site_membership, user: inviter, role: :owner)] ++ build_list(5, :site_membership)
site = insert(:site, memberships: memberships)
assert {:ok, _invitation} = Sites.invite(site, inviter, "newowner@plausible.test", :owner)
end
test "does not allow viewers to invite users" do
inviter = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: build(:user), role: :owner),
build(:site_membership, user: inviter, role: :viewer)
]
)
assert {:error, :forbidden} = Sites.invite(site, inviter, "vini@plausible.test", :viewer)
end
test "allows admins to invite other admins" do
inviter = insert(:user)
site =
insert(:site,
memberships: [
build(:site_membership, user: build(:user), role: :owner),
build(:site_membership, user: inviter, role: :admin)
]
)
assert {:ok, %Plausible.Auth.Invitation{}} =
Sites.invite(site, inviter, "vini@plausible.test", :admin)
end
end
describe "bulk_transfer_ownership/4" do
test "initiates ownership transfer for multiple sites in one action" do
admin_user = insert(:user)
new_owner = insert(:user)
site1 =
insert(:site, memberships: [build(:site_membership, user: admin_user, role: :owner)])
site2 =
insert(:site, memberships: [build(:site_membership, user: admin_user, role: :owner)])
assert {:ok, _} = Sites.bulk_transfer_ownership([site1, site2], admin_user, new_owner.email)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site1.domain}"
)
assert Repo.exists?(
from(i in Plausible.Auth.Invitation,
where:
i.site_id == ^site1.id and i.email == ^new_owner.email and i.role == :owner
)
)
assert_invitation_exists(site1, new_owner.email, :owner)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site2.domain}"
)
assert_invitation_exists(site2, new_owner.email, :owner)
end
test "initiates ownership transfer for multiple sites in one action skipping permission checks" do
superadmin_user = insert(:user)
new_owner = insert(:user)
site1 = insert(:site)
site2 = insert(:site)
assert {:ok, _} =
Sites.bulk_transfer_ownership([site1, site2], superadmin_user, new_owner.email,
check_permissions: false
)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site1.domain}"
)
assert Repo.exists?(
from(i in Plausible.Auth.Invitation,
where:
i.site_id == ^site1.id and i.email == ^new_owner.email and i.role == :owner
)
)
assert_invitation_exists(site1, new_owner.email, :owner)
assert_email_delivered_with(
to: [nil: new_owner.email],
subject: "[Plausible Analytics] Request to transfer ownership of #{site2.domain}"
)
assert_invitation_exists(site2, new_owner.email, :owner)
end
end
describe "bulk_transfer_ownership_direct/2" do
test "transfers ownership for multiple sites in one action" do
current_owner = insert(:user)
new_owner = insert(:user)
site1 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
site2 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
assert {:ok, _} = Sites.bulk_transfer_ownership_direct([site1, site2], new_owner)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site1.id,
user_id: new_owner.id,
role: :owner
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site2.id,
user_id: new_owner.id,
role: :owner
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site1.id,
user_id: current_owner.id,
role: :admin
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site2.id,
user_id: current_owner.id,
role: :admin
)
end
test "returns error when user is already an owner for one of the sites" do
current_owner = insert(:user)
new_owner = insert(:user)
site1 =
insert(:site, memberships: [build(:site_membership, user: current_owner, role: :owner)])
site2 = insert(:site, memberships: [build(:site_membership, user: new_owner, role: :owner)])
assert {:error, :transfer_to_self} =
Sites.bulk_transfer_ownership_direct([site1, site2], new_owner)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site1.id,
user_id: current_owner.id,
role: :owner
)
assert Repo.get_by(Plausible.Site.Membership,
site_id: site2.id,
user_id: new_owner.id,
role: :owner
)
end
end
describe "get_for_user/2" do describe "get_for_user/2" do
test "get site for super_admin" do test "get site for super_admin" do
user1 = insert(:user) user1 = insert(:user)
@ -386,12 +81,4 @@ defmodule Plausible.SitesTest do
assert %{id: ^site_id} = Sites.get_for_user(user2.id, domain, [:super_admin]) assert %{id: ^site_id} = Sites.get_for_user(user2.id, domain, [:super_admin])
end end
end end
defp assert_invitation_exists(site, email, role) do
assert Repo.exists?(
from(i in Plausible.Auth.Invitation,
where: i.site_id == ^site.id and i.email == ^email and i.role == ^role
)
)
end
end end