From ad05af08a4598d1bc33ab55a45be9642e43216ce Mon Sep 17 00:00:00 2001 From: Vini Brasil Date: Wed, 25 Oct 2023 09:00:31 -0300 Subject: [PATCH] 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 --- lib/plausible/billing/quota.ex | 39 +- lib/plausible/site/admin.ex | 8 +- lib/plausible/site/memberships.ex | 8 + .../site/memberships/create_invitation.ex | 181 +++++++++ lib/plausible/sites.ex | 143 +------ .../controllers/site/membership_controller.ex | 16 +- .../memberships/create_invitation_test.exs | 376 ++++++++++++++++++ test/plausible/site/sites_test.exs | 313 --------------- 8 files changed, 608 insertions(+), 476 deletions(-) create mode 100644 lib/plausible/site/memberships/create_invitation.ex create mode 100644 test/plausible/site/memberships/create_invitation_test.exs diff --git a/lib/plausible/billing/quota.ex b/lib/plausible/billing/quota.ex index 89595c596..acf88ab87 100644 --- a/lib/plausible/billing/quota.ex +++ b/lib/plausible/billing/quota.ex @@ -108,27 +108,34 @@ defmodule Plausible.Billing.Quota do with the user's sites. """ 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 = - from os in subquery(owned_sites_query(user)), + from os in subquery(owned_sites_query), inner_join: sm in Plausible.Site.Membership, on: sm.site_id == os.site_id, 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, - inner_join: os in subquery(owned_sites_query(user)), - on: i.site_id == os.site_id, - where: i.role != :owner, - select: %{email: i.email}, - 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) + from i in Plausible.Auth.Invitation, + inner_join: os in subquery(owned_sites_query), + on: i.site_id == os.site_id, + where: i.role != :owner, + select: i.email, + union: ^team_members_query end @spec features_usage(Plausible.Auth.User.t()) :: [atom()] diff --git a/lib/plausible/site/admin.ex b/lib/plausible/site/admin.ex index 3fac915a6..f6cf07590 100644 --- a/lib/plausible/site/admin.ex +++ b/lib/plausible/site/admin.ex @@ -82,7 +82,11 @@ defmodule Plausible.SiteAdmin do if new_owner do {: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 ) @@ -100,7 +104,7 @@ defmodule Plausible.SiteAdmin do new_owner = Plausible.Auth.find_user_by(email: email) 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 {:error, :transfer_to_self} -> {:error, "User is already an owner of one of the sites"} end diff --git a/lib/plausible/site/memberships.ex b/lib/plausible/site/memberships.ex index 592d95b75..e71752fe7 100644 --- a/lib/plausible/site/memberships.ex +++ b/lib/plausible/site/memberships.ex @@ -14,6 +14,14 @@ defmodule Plausible.Site.Memberships do defdelegate reject_invitation(invitation_id, user), to: Memberships.RejectInvitation 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() def any?(user) do user diff --git a/lib/plausible/site/memberships/create_invitation.ex b/lib/plausible/site/memberships/create_invitation.ex new file mode 100644 index 000000000..91244d8d3 --- /dev/null +++ b/lib/plausible/site/memberships/create_invitation.ex @@ -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 diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index 0e9465f83..18cf00e28 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -1,15 +1,7 @@ defmodule Plausible.Sites do - alias Plausible.{Repo, Site, Site.SharedLink, Auth.User, Billing.Quota} - alias PlausibleWeb.Email + alias Plausible.{Repo, Site, Site.SharedLink, Billing.Quota} 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 Repo.get_by(Site, domain: domain) end @@ -47,139 +39,6 @@ defmodule Plausible.Sites do 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 @doc """ Returns the date of the first event of the given site, or `nil` if the site diff --git a/lib/plausible_web/controllers/site/membership_controller.ex b/lib/plausible_web/controllers/site/membership_controller.ex index c6004cbf1..2d0001a11 100644 --- a/lib/plausible_web/controllers/site/membership_controller.ex +++ b/lib/plausible_web/controllers/site/membership_controller.ex @@ -13,7 +13,7 @@ defmodule PlausibleWeb.Site.MembershipController do use PlausibleWeb, :controller use Plausible.Repo alias Plausible.Sites - alias Plausible.Site.Membership + alias Plausible.Site.{Membership, Memberships} @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 = 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} -> conn |> put_flash( @@ -98,7 +98,7 @@ defmodule PlausibleWeb.Site.MembershipController do site_domain = conn.assigns[: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} -> conn |> 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") |> 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} -> errors = Plausible.ChangesetHelpers.traverse_errors(changeset) diff --git a/test/plausible/site/memberships/create_invitation_test.exs b/test/plausible/site/memberships/create_invitation_test.exs new file mode 100644 index 000000000..7f6460674 --- /dev/null +++ b/test/plausible/site/memberships/create_invitation_test.exs @@ -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 diff --git a/test/plausible/site/sites_test.exs b/test/plausible/site/sites_test.exs index 663859c4a..98af8da6e 100644 --- a/test/plausible/site/sites_test.exs +++ b/test/plausible/site/sites_test.exs @@ -1,6 +1,5 @@ defmodule Plausible.SitesTest do use Plausible.DataCase - use Bamboo.Test alias Plausible.Sites describe "is_member?" do @@ -68,310 +67,6 @@ defmodule Plausible.SitesTest do 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 test "get site for super_admin" do user1 = insert(:user) @@ -386,12 +81,4 @@ defmodule Plausible.SitesTest do assert %{id: ^site_id} = Sites.get_for_user(user2.id, domain, [:super_admin]) 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