diff --git a/extra/lib/plausible/auth/sso.ex b/extra/lib/plausible/auth/sso.ex index 1e4e88ab56..3d74021d68 100644 --- a/extra/lib/plausible/auth/sso.ex +++ b/extra/lib/plausible/auth/sso.ex @@ -11,6 +11,10 @@ defmodule Plausible.Auth.SSO do alias Plausible.Repo alias Plausible.Teams + @type policy_attr() :: + {:sso_default_role, Teams.Policy.sso_member_role()} + | {:sso_session_timeout_minutes, non_neg_integer()} + @spec get_integration(String.t()) :: {:ok, SSO.Integration.t()} | {:error, :not_found} def get_integration(identifier) when is_binary(identifier) do query = @@ -69,6 +73,199 @@ defmodule Plausible.Auth.SSO do end end + @spec deprovision_user!(Auth.User.t()) :: Auth.User.t() + def deprovision_user!(%{type: :standard} = user), do: user + + def deprovision_user!(user) do + user = Repo.preload(user, :sso_integration) + + :ok = Auth.UserSessions.revoke_all(user) + + user + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_change(:type, :standard) + |> Ecto.Changeset.put_change(:sso_identity_id, nil) + |> Ecto.Changeset.put_assoc(:sso_integration, nil) + |> Repo.update!() + end + + @spec update_policy(Teams.Team.t(), [policy_attr()]) :: + {:ok, Teams.Team.t()} | {:error, Ecto.Changeset.t()} + def update_policy(team, attrs \\ []) do + params = Map.new(attrs) + policy_changeset = Teams.Policy.update_changeset(team.policy, params) + + team + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_embed(:policy, policy_changeset) + |> Repo.update() + end + + @spec set_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) :: + {:ok, Teams.Team.t()} + | {:error, + :no_integration + | :no_domain + | :no_verified_domain + | :owner_mfa_disabled + | :no_sso_user} + def set_force_sso(team, mode) do + with :ok <- check_force_sso(team, mode) do + policy_changeset = Teams.Policy.force_sso_changeset(team.policy, mode) + + team + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_embed(:policy, policy_changeset) + |> Repo.update() + end + end + + @spec check_force_sso(Teams.Team.t(), Teams.Policy.force_sso_mode()) :: + :ok + | {:error, + :no_integration + | :no_domain + | :no_verified_domain + | :owner_mfa_disabled + | :no_sso_user} + def check_force_sso(_team, :none), do: :ok + + def check_force_sso(team, :all_but_owners) do + with :ok <- check_integration_configured(team), + :ok <- check_sso_user_present(team) do + check_owners_mfa_enabled(team) + end + end + + @spec check_can_remove_integration(SSO.Integration.t()) :: + :ok | {:error, :force_sso_enabled | :sso_users_present} + def check_can_remove_integration(integration) do + team = Repo.preload(integration, :team).team + + cond do + team.policy.force_sso != :none -> + {:error, :force_sso_enabled} + + check_sso_user_present(integration) == :ok -> + {:error, :sso_users_present} + + true -> + :ok + end + end + + @spec remove_integration(SSO.Integration.t(), Keyword.t()) :: + :ok | {:error, :force_sso_enabled | :sso_users_present} + def remove_integration(integration, opts \\ []) do + force_deprovision? = Keyword.get(opts, :force_deprovision?, false) + check = check_can_remove_integration(integration) + + case {check, force_deprovision?} do + {:ok, _} -> + Repo.delete!(integration) + :ok + + {{:error, :sso_users_present}, true} -> + users = Repo.preload(integration, :users).users + + {:ok, :ok} = + Repo.transaction(fn -> + Enum.each(users, &deprovision_user!/1) + Repo.delete!(integration) + :ok + end) + + :ok + + {{:error, error}, _} -> + {:error, error} + end + end + + defp check_integration_configured(team) do + integrations = + Repo.all( + from( + i in SSO.Integration, + left_join: d in assoc(i, :sso_domains), + where: i.team_id == ^team.id, + preload: [sso_domains: d] + ) + ) + + domains = Enum.flat_map(integrations, & &1.sso_domains) + no_verified_domains? = Enum.all?(domains, &(&1.status != :validated)) + + cond do + integrations == [] -> {:error, :no_integration} + domains == [] -> {:error, :no_domain} + no_verified_domains? -> {:error, :no_verified_domain} + true -> :ok + end + end + + defp check_sso_user_present(%Teams.Team{} = team) do + sso_user_count = + Repo.aggregate( + from( + tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id, + where: tm.role != :guest, + where: u.type == :sso + ), + :count + ) + + if sso_user_count > 0 do + :ok + else + {:error, :no_sso_user} + end + end + + defp check_sso_user_present(%SSO.Integration{} = integration) do + sso_user_count = + Repo.aggregate( + from( + i in SSO.Integration, + inner_join: u in assoc(i, :users), + inner_join: tm in assoc(u, :team_memberships), + on: tm.team_id == i.team_id, + where: i.id == ^integration.id, + where: tm.role != :guest, + where: u.type == :sso + ), + :count + ) + + if sso_user_count > 0 do + :ok + else + {:error, :no_sso_user} + end + end + + defp check_owners_mfa_enabled(team) do + disabled_mfa_count = + Repo.aggregate( + from( + tm in Teams.Membership, + inner_join: u in assoc(tm, :user), + where: tm.team_id == ^team.id, + where: tm.role == :owner, + where: u.totp_enabled == false or is_nil(u.totp_secret) + ), + :count + ) + + if disabled_mfa_count == 0 do + :ok + else + {:error, :owner_mfa_disabled} + end + end + defp find_user(identity) do case find_user_with_fallback(identity) do {:ok, type, user, integration} -> @@ -152,6 +349,7 @@ defmodule Plausible.Auth.SSO do with :ok <- ensure_team_member(integration.team, user), :ok <- ensure_one_membership(user, integration.team), + :ok <- Auth.UserSessions.revoke_all(user), {:ok, user} <- Repo.update(changeset) do {:ok, :standard, integration.team, user} end diff --git a/extra/lib/plausible/auth/sso/integration.ex b/extra/lib/plausible/auth/sso/integration.ex index e8d51b40e3..6fddbc39dc 100644 --- a/extra/lib/plausible/auth/sso/integration.ex +++ b/extra/lib/plausible/auth/sso/integration.ex @@ -31,6 +31,7 @@ defmodule Plausible.Auth.SSO.Integration do belongs_to :team, Plausible.Teams.Team has_many :users, Plausible.Auth.User, foreign_key: :sso_integration_id + has_many :sso_domains, SSO.Domain, foreign_key: :sso_integration_id timestamps() end diff --git a/lib/plausible/auth/user.ex b/lib/plausible/auth/user.ex index 6f3cdda1b7..3d511d1ed5 100644 --- a/lib/plausible/auth/user.ex +++ b/lib/plausible/auth/user.ex @@ -46,7 +46,7 @@ defmodule Plausible.Auth.User do field :sso_identity_id, :string field :last_sso_login, :naive_datetime - belongs_to :sso_integration, Plausible.Auth.SSO.Integration + belongs_to :sso_integration, Plausible.Auth.SSO.Integration, on_replace: :nilify end has_many :sessions, Plausible.Auth.UserSession diff --git a/lib/plausible/auth/user_session.ex b/lib/plausible/auth/user_session.ex index 8bf6b40f59..9e8aa5038b 100644 --- a/lib/plausible/auth/user_session.ex +++ b/lib/plausible/auth/user_session.ex @@ -29,19 +29,11 @@ defmodule Plausible.Auth.UserSession do @spec timeout_duration() :: Duration.t() def timeout_duration(), do: @timeout - @spec new_session(Auth.User.t(), String.t(), NaiveDateTime.t()) :: Ecto.Changeset.t() - def new_session(user, device, now \\ NaiveDateTime.utc_now(:second)) do - %__MODULE__{} - |> cast(%{device: device}, [:device]) - |> generate_token() - |> put_assoc(:user, user) - |> put_change(:timeout_at, NaiveDateTime.shift(now, @timeout)) - |> touch_session(now) - end + @spec new_session(Auth.User.t(), String.t(), Keyword.t()) :: Ecto.Changeset.t() + def new_session(user, device, opts \\ []) do + now = Keyword.get(opts, :now, NaiveDateTime.utc_now(:second)) + timeout_at = Keyword.get(opts, :timeout_at, NaiveDateTime.shift(now, @timeout)) - @spec new_sso_session(Auth.User.t(), String.t(), NaiveDateTime.t(), NaiveDateTime.t()) :: - Ecto.Changeset.t() - def new_sso_session(user, device, timeout_at, now \\ NaiveDateTime.utc_now(:second)) do %__MODULE__{} |> cast(%{device: device}, [:device]) |> generate_token() diff --git a/lib/plausible/auth/user_sessions.ex b/lib/plausible/auth/user_sessions.ex index 821f94a40e..e822742f8c 100644 --- a/lib/plausible/auth/user_sessions.ex +++ b/lib/plausible/auth/user_sessions.ex @@ -3,10 +3,12 @@ defmodule Plausible.Auth.UserSessions do Functions for interacting with user sessions. """ - import Ecto.Query, only: [from: 2] + import Ecto.Query alias Plausible.Auth alias Plausible.Repo + @socket_id_prefix "user_sessions:" + @spec list_for_user(Auth.User.t(), NaiveDateTime.t()) :: [Auth.UserSession.t()] def list_for_user(user, now \\ NaiveDateTime.utc_now(:second)) do Repo.all( @@ -30,4 +32,118 @@ defmodule Plausible.Auth.UserSessions do true -> "#{diff_days} days ago" end end + + @spec get_by_token(String.t()) :: {:ok, Auth.UserSession.t()} | {:error, :not_found} + def get_by_token(token) do + now = NaiveDateTime.utc_now(:second) + + last_team_subscription_query = Plausible.Teams.last_subscription_join_query() + + token_query = + from(us in Auth.UserSession, + inner_join: u in assoc(us, :user), + as: :user, + left_join: tm in assoc(u, :team_memberships), + on: tm.role != :guest, + left_join: t in assoc(tm, :team), + as: :team, + left_join: o in assoc(t, :owners), + left_lateral_join: ts in subquery(last_team_subscription_query), + on: true, + where: us.token == ^token and us.timeout_at > ^now, + order_by: t.id, + preload: [user: {u, team_memberships: {tm, team: {t, subscription: ts, owners: o}}}] + ) + + case Repo.one(token_query) do + %Auth.UserSession{} = user_session -> + {:ok, user_session} + + nil -> + {:error, :not_found} + end + end + + @spec create!(Auth.User.t(), String.t(), Keyword.t()) :: Auth.UserSession.t() + def create!(user, device_name, opts \\ []) do + user + |> Auth.UserSession.new_session(device_name, opts) + |> Repo.insert!() + end + + @spec remove_by_token(String.t()) :: :ok + def remove_by_token(token) do + Repo.delete_all(from us in Auth.UserSession, where: us.token == ^token) + :ok + end + + @spec touch(Auth.UserSession.t(), NaiveDateTime.t()) :: Auth.UserSession.t() + def touch(user_session, now \\ NaiveDateTime.utc_now(:second)) do + if NaiveDateTime.diff(now, user_session.last_used_at, :hour) >= 1 do + Plausible.Users.bump_last_seen(user_session.user_id, now) + + user_session + |> Repo.preload(:user) + |> Auth.UserSession.touch_session(now) + |> Repo.update!(allow_stale: true) + else + user_session + end + end + + @spec revoke_by_id(Auth.User.t(), pos_integer()) :: :ok + def revoke_by_id(user, session_id) do + {_, tokens} = + Repo.delete_all( + from us in Auth.UserSession, + where: us.user_id == ^user.id and us.id == ^session_id, + select: us.token + ) + + case tokens do + [token] -> + disconnect_by_token(token) + + _ -> + :pass + end + + :ok + end + + @spec revoke_all(Auth.User.t(), Keyword.t()) :: :ok + def revoke_all(user, opts \\ []) do + except = Keyword.get(opts, :except) + + delete_query = from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token + + delete_query = + if except do + where(delete_query, [us], us.id != ^except.id) + else + delete_query + end + + {_count, tokens} = Repo.delete_all(delete_query) + + Enum.each(tokens, &disconnect_by_token/1) + end + + @spec disconnect_by_token(String.t()) :: :ok + def disconnect_by_token(token_or_socket_id) do + socket_id = + if String.starts_with?(token_or_socket_id, @socket_id_prefix) do + token_or_socket_id + else + socket_id(token_or_socket_id) + end + + PlausibleWeb.Endpoint.broadcast(socket_id, "disconnect", %{}) + :ok + end + + @spec socket_id(String.t()) :: String.t() + def socket_id(token) do + @socket_id_prefix <> Base.url_encode64(token) + end end diff --git a/lib/plausible/teams/policy.ex b/lib/plausible/teams/policy.ex index 19d939e748..56e5db7447 100644 --- a/lib/plausible/teams/policy.ex +++ b/lib/plausible/teams/policy.ex @@ -8,9 +8,19 @@ defmodule Plausible.Teams.Policy do import Ecto.Changeset @sso_member_roles Plausible.Teams.Membership.roles() -- [:guest] + @force_sso_modes [:none, :all_but_owners] @update_fields [:sso_default_role, :sso_session_timeout_minutes] + @default_timeout_minutes 6 * 60 + @max_timeout_minutes 12 * 60 + + @type t() :: %__MODULE__{} + + @type sso_member_role() :: unquote(Enum.reduce(@sso_member_roles, &{:|, [], [&1, &2]})) + + @type force_sso_mode() :: unquote(Enum.reduce(@force_sso_modes, &{:|, [], [&1, &2]})) + embedded_schema do # SSO options apply to all team's integrations, should there # ever be more than one allowed at once. @@ -28,15 +38,21 @@ defmodule Plausible.Teams.Policy do # Default session timeout for SSO-enabled accounts. We might also # consider accepting session timeout from assertion, if present. - field :sso_session_timeout_minutes, :integer, default: 360 + field :sso_session_timeout_minutes, :integer, default: @default_timeout_minutes end + @spec update_changeset(t(), map()) :: Ecto.Changeset.t() def update_changeset(policy, params) do policy |> cast(params, @update_fields) |> validate_required(@update_fields) + |> validate_number(:sso_session_timeout_minutes, + greater_than: 0, + less_than: @max_timeout_minutes + ) end + @spec force_sso_changeset(t(), force_sso_mode()) :: Ecto.Changeset.t() def force_sso_changeset(policy, mode) do policy |> cast(%{force_sso: mode}, [:force_sso]) diff --git a/lib/plausible_web/controllers/settings_controller.ex b/lib/plausible_web/controllers/settings_controller.ex index c1757eaaf5..5d0c2b5852 100644 --- a/lib/plausible_web/controllers/settings_controller.ex +++ b/lib/plausible_web/controllers/settings_controller.ex @@ -3,7 +3,6 @@ defmodule PlausibleWeb.SettingsController do use Plausible.Repo alias Plausible.Auth - alias PlausibleWeb.UserAuth alias Plausible.Teams require Logger @@ -293,7 +292,7 @@ defmodule PlausibleWeb.SettingsController do with :ok <- Auth.rate_limit(:password_change_user, user), {:ok, user} <- do_update_password(user, params) do - UserAuth.revoke_all_user_sessions(user, except: user_session) + Auth.UserSessions.revoke_all(user, except: user_session) conn |> put_flash(:success, "Your password is now changed") @@ -342,7 +341,7 @@ defmodule PlausibleWeb.SettingsController do def delete_session(conn, %{"id" => session_id}) do current_user = conn.assigns.current_user - :ok = UserAuth.revoke_user_session(current_user, session_id) + :ok = Auth.UserSessions.revoke_by_id(current_user, session_id) conn |> put_flash(:success, "Session logged out successfully") diff --git a/lib/plausible_web/live/reset_password_form.ex b/lib/plausible_web/live/reset_password_form.ex index 60c835ed40..d39e6c2d0f 100644 --- a/lib/plausible_web/live/reset_password_form.ex +++ b/lib/plausible_web/live/reset_password_form.ex @@ -7,7 +7,6 @@ defmodule PlausibleWeb.Live.ResetPasswordForm do alias Plausible.Auth alias Plausible.Repo - alias PlausibleWeb.UserAuth def mount(_params, %{"email" => email}, socket) do socket = @@ -94,7 +93,7 @@ defmodule PlausibleWeb.Live.ResetPasswordForm do case result do {:ok, user} -> - UserAuth.revoke_all_user_sessions(user) + Auth.UserSessions.revoke_all(user) {:noreply, assign(socket, trigger_submit: true)} {:error, changeset} -> diff --git a/lib/plausible_web/plugs/user_session_touch.ex b/lib/plausible_web/plugs/user_session_touch.ex index 5802a0c231..5af39c4d3c 100644 --- a/lib/plausible_web/plugs/user_session_touch.ex +++ b/lib/plausible_web/plugs/user_session_touch.ex @@ -5,7 +5,7 @@ defmodule PlausibleWeb.Plugs.UserSessionTouch do import Plug.Conn - alias PlausibleWeb.UserAuth + alias Plausible.Auth def init(opts \\ []) do opts @@ -16,7 +16,7 @@ defmodule PlausibleWeb.Plugs.UserSessionTouch do assign( conn, :current_user_session, - UserAuth.touch_user_session(user_session) + Auth.UserSessions.touch(user_session) ) else conn diff --git a/lib/plausible_web/user_auth.ex b/lib/plausible_web/user_auth.ex index bc718d87fa..1c48dd0994 100644 --- a/lib/plausible_web/user_auth.ex +++ b/lib/plausible_web/user_auth.ex @@ -5,10 +5,7 @@ defmodule PlausibleWeb.UserAuth do use Plausible - import Ecto.Query - alias Plausible.Auth - alias Plausible.Repo alias PlausibleWeb.TwoFactor alias PlausibleWeb.Router.Helpers, as: Routes @@ -27,10 +24,11 @@ defmodule PlausibleWeb.UserAuth do def log_in_user(conn, %Auth.User{} = user, redirect_path) do redirect_to = login_redirect_path(conn, redirect_path) - {token, _} = create_user_session(conn, user) + device_name = get_device_name(conn) + session = Auth.UserSessions.create!(user, device_name) conn - |> set_user_token(token) + |> set_user_token(session.token) |> set_logged_in_cookie() |> Phoenix.Controller.redirect(to: redirect_to) end @@ -38,16 +36,13 @@ defmodule PlausibleWeb.UserAuth do on_ee do def log_in_user(conn, %Auth.SSO.Identity{} = identity, redirect_path) do case Auth.SSO.provision_user(identity) do - {:ok, provisioning_from, team, user} -> - if provisioning_from == :standard do - :ok = revoke_all_user_sessions(user) - end - + {:ok, _provisioning_from, team, user} -> redirect_to = login_redirect_path(conn, redirect_path) - {token, _} = create_sso_user_session(conn, user, identity.expires_at) + device_name = get_device_name(conn) + session = Auth.UserSessions.create!(user, device_name, timeout_at: identity.expires_at) conn - |> set_user_token(token) + |> set_user_token(session.token) |> Plug.Conn.put_session("current_team_id", team.identifier) |> set_logged_in_cookie() |> Phoenix.Controller.redirect(to: redirect_to) @@ -75,28 +70,17 @@ defmodule PlausibleWeb.UserAuth do log_in_user(conn, user, redirect_path) end end - - defp create_sso_user_session(conn, user, expires_at) do - device_name = get_device_name(conn) - - user_session = - user - |> Auth.UserSession.new_sso_session(device_name, expires_at) - |> Repo.insert!() - - {user_session.token, user_session} - end end @spec log_out_user(Plug.Conn.t()) :: Plug.Conn.t() def log_out_user(conn) do case get_user_token(conn) do - {:ok, token} -> remove_user_session(token) + {:ok, token} -> Auth.UserSessions.remove_by_token(token) {:error, _} -> :pass end if live_socket_id = Plug.Conn.get_session(conn, :live_socket_id) do - PlausibleWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) + Auth.UserSessions.disconnect_by_token(live_socket_id) end conn @@ -112,62 +96,11 @@ defmodule PlausibleWeb.UserAuth do def get_user_session(conn_or_session) do with {:ok, token} <- get_user_token(conn_or_session) do - get_session_by_token(token) - end - end - - @spec touch_user_session(Auth.UserSession.t(), NaiveDateTime.t()) :: Auth.UserSession.t() - def touch_user_session(user_session, now \\ NaiveDateTime.utc_now(:second)) do - if NaiveDateTime.diff(now, user_session.last_used_at, :hour) >= 1 do - Plausible.Users.bump_last_seen(user_session.user_id, now) - - user_session - |> Repo.preload(:user) - |> Auth.UserSession.touch_session(now) - |> Repo.update!(allow_stale: true) - else - user_session - end - end - - @spec revoke_user_session(Auth.User.t(), pos_integer()) :: :ok - def revoke_user_session(user, session_id) do - {_, tokens} = - Repo.delete_all( - from us in Auth.UserSession, - where: us.user_id == ^user.id and us.id == ^session_id, - select: us.token - ) - - case tokens do - [token] -> - PlausibleWeb.Endpoint.broadcast(live_socket_id(token), "disconnect", %{}) - - _ -> - :pass - end - - :ok - end - - @spec revoke_all_user_sessions(Auth.User.t(), Keyword.t()) :: :ok - def revoke_all_user_sessions(user, opts \\ []) do - except = Keyword.get(opts, :except) - - delete_query = from us in Auth.UserSession, where: us.user_id == ^user.id, select: us.token - - delete_query = - if except do - where(delete_query, [us], us.id != ^except.id) - else - delete_query + case Auth.UserSessions.get_by_token(token) do + {:ok, session} -> {:ok, session} + {:error, :not_found} -> {:error, :session_not_found} end - - {_count, tokens} = Repo.delete_all(delete_query) - - Enum.each(tokens, fn token -> - PlausibleWeb.Endpoint.broadcast(live_socket_id(token), "disconnect", %{}) - end) + end end @doc """ @@ -185,36 +118,6 @@ defmodule PlausibleWeb.UserAuth do ) end - defp get_session_by_token(token) do - now = NaiveDateTime.utc_now(:second) - - last_team_subscription_query = Plausible.Teams.last_subscription_join_query() - - token_query = - from(us in Auth.UserSession, - inner_join: u in assoc(us, :user), - as: :user, - left_join: tm in assoc(u, :team_memberships), - on: tm.role != :guest, - left_join: t in assoc(tm, :team), - as: :team, - left_join: o in assoc(t, :owners), - left_lateral_join: ts in subquery(last_team_subscription_query), - on: true, - where: us.token == ^token and us.timeout_at > ^now, - order_by: t.id, - preload: [user: {u, team_memberships: {tm, team: {t, subscription: ts, owners: o}}}] - ) - - case Repo.one(token_query) do - %Auth.UserSession{} = user_session -> - {:ok, user_session} - - nil -> - {:error, :session_not_found} - end - end - defp set_user_token(conn, token) do conn |> renew_session() @@ -245,11 +148,7 @@ defmodule PlausibleWeb.UserAuth do defp put_token_in_session(conn, token) do conn |> Plug.Conn.put_session(:user_token, token) - |> Plug.Conn.put_session(:live_socket_id, live_socket_id(token)) - end - - defp live_socket_id(token) do - "user_sessions:#{Base.url_encode64(token)}" + |> Plug.Conn.put_session(:live_socket_id, Auth.UserSessions.socket_id(token)) end defp get_user_token(%Plug.Conn{} = conn) do @@ -266,22 +165,6 @@ defmodule PlausibleWeb.UserAuth do {:error, :no_valid_token} end - defp create_user_session(conn, user) do - device_name = get_device_name(conn) - - user_session = - user - |> Auth.UserSession.new_session(device_name) - |> Repo.insert!() - - {user_session.token, user_session} - end - - defp remove_user_session(token) do - Repo.delete_all(from us in Auth.UserSession, where: us.token == ^token) - :ok - end - @unknown_label "Unknown" defp get_device_name(%Plug.Conn{} = conn) do diff --git a/test/plausible/auth/sso_test.exs b/test/plausible/auth/sso_test.exs index db98639459..3268e212a9 100644 --- a/test/plausible/auth/sso_test.exs +++ b/test/plausible/auth/sso_test.exs @@ -5,6 +5,7 @@ defmodule Plausible.Auth.SSOTest do on_ee do use Plausible.Teams.Test + alias Plausible.Auth alias Plausible.Auth.SSO describe "initiate_saml_integration/1" do @@ -304,6 +305,381 @@ defmodule Plausible.Auth.SSOTest do end end + describe "deprovision_user!/1" do + test "deprovisions SSO user" do + team = new_site().team + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + identity = new_identity("Clarence Fortridge", "clarence@" <> domain) + {:ok, _, _, user} = SSO.provision_user(identity) + + user = Repo.reload!(user) + session = Auth.UserSessions.create!(user, "Unknown") + + updated_user = SSO.deprovision_user!(user) + + refute Repo.reload(session) + assert updated_user.id == user.id + assert updated_user.type == :standard + refute updated_user.sso_identity_id + refute updated_user.sso_integration_id + end + + test "handles standard user gracefully without revoking existing sessions" do + user = new_user() + session = Auth.UserSessions.create!(user, "Unknown") + + assert updated_user = SSO.deprovision_user!(user) + + assert Repo.reload(session) + assert updated_user.id == user.id + assert updated_user.type == :standard + refute updated_user.sso_identity_id + refute updated_user.sso_integration_id + end + end + + describe "update_policy/2" do + test "updates team policy attributes" do + team = new_site().team + + assert team.policy.sso_default_role == :viewer + assert team.policy.sso_session_timeout_minutes == 360 + + assert {:ok, team} = + SSO.update_policy( + team, + sso_default_role: "editor", + sso_session_timeout_minutes: 600 + ) + + assert team.policy.sso_default_role == :editor + assert team.policy.sso_session_timeout_minutes == 600 + end + + test "accepts single attributes leaving others as they are" do + team = new_site().team + + assert team.policy.sso_default_role == :viewer + assert team.policy.sso_session_timeout_minutes == 360 + + assert {:ok, team} = SSO.update_policy(team, sso_default_role: "editor") + + assert team.policy.sso_default_role == :editor + assert team.policy.sso_session_timeout_minutes == 360 + end + end + + describe "check_force_sso/2" do + test "returns ok when conditions are met for setting all_but_owners" do + # Owner with MFA enabled + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, _sso_user} = SSO.provision_user(identity) + + assert :ok = SSO.check_force_sso(team, :all_but_owners) + end + + test "returns error when one owner does not have MFA configured" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Owner without MFA + another_owner = new_user() + add_member(team, user: another_owner, role: :owner) + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Carrie Mower", "lance@" <> domain) + {:ok, _, _, _sso_user} = SSO.provision_user(identity) + + assert {:error, :owner_mfa_disabled} = SSO.check_force_sso(team, :all_but_owners) + end + + test "returns error when there's no provisioned SSO user present" do + # Owner with MFA enabled + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + assert {:error, :no_sso_user} = SSO.check_force_sso(team, :all_but_owners) + end + + test "returns error when there's no verified SSO domain present" do + # Owner with MFA enabled + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + # Unverified domain + {:ok, _sso_domain} = SSO.Domains.add(integration, domain) + + assert {:error, :no_verified_domain} = SSO.check_force_sso(team, :all_but_owners) + end + + test "returns error when there's no SSO domain present" do + # Owner with MFA enabled + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + _integration = SSO.initiate_saml_integration(team) + + assert {:error, :no_domain} = SSO.check_force_sso(team, :all_but_owners) + end + + test "returns error when there's no SSO integration present" do + # Owner with MFA enabled + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + assert {:error, :no_integration} = SSO.check_force_sso(team, :all_but_owners) + end + + test "returns ok when setting to none" do + team = new_site().team + + assert :ok = SSO.check_force_sso(team, :none) + end + end + + describe "set_enforce_sso/2" do + test "sets enforce mode to all_but_owners when conditions met" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, _sso_user} = SSO.provision_user(identity) + + assert {:ok, updated_team} = SSO.set_force_sso(team, :all_but_owners) + + assert updated_team.id == team.id + assert updated_team.policy.force_sso == :all_but_owners + end + + test "returns error when conditions not met" do + team = new_site().team + + assert {:error, :no_integration} = SSO.set_force_sso(team, :all_but_owners) + end + + test "sets enforce mode to none" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, _sso_user} = SSO.provision_user(identity) + + {:ok, team} = SSO.set_force_sso(team, :all_but_owners) + + assert {:ok, updated_team} = SSO.set_force_sso(team, :none) + + assert updated_team.id == team.id + assert updated_team.policy.force_sso == :none + end + end + + describe "check_can_remove_integration/1" do + test "returns ok if conditions to remove integration met" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, sso_user} = SSO.provision_user(identity) + + # SSO user deprovisioned + _user = SSO.deprovision_user!(sso_user) + + integration = Repo.reload!(integration) + assert :ok = SSO.check_can_remove_integration(integration) + end + + test "returns error if force SSO enabled" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, sso_user} = SSO.provision_user(identity) + + # Force SSO enabled + {:ok, _} = SSO.set_force_sso(team, :all_but_owners) + + # SSO user deprovisioned + _user = SSO.deprovision_user!(sso_user) + + integration = Repo.reload!(integration) + assert {:error, :force_sso_enabled} = SSO.check_can_remove_integration(integration) + end + + test "returns error if SSO user present" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + _sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, _sso_user} = SSO.provision_user(identity) + + integration = Repo.reload!(integration) + assert {:error, :sso_users_present} = SSO.check_can_remove_integration(integration) + end + end + + describe "remove_integration/1,2" do + test "removes integration when conditions met" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, sso_user} = SSO.provision_user(identity) + + # SSO user deprovisioned + _user = SSO.deprovision_user!(sso_user) + + integration = Repo.reload!(integration) + + assert :ok = SSO.remove_integration(integration) + refute Repo.reload(integration) + refute Repo.reload(sso_domain) + end + + test "returns error when conditions not met" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Lance Wurst", "lance@" <> domain) + {:ok, _, _, _sso_user} = SSO.provision_user(identity) + + integration = Repo.reload!(integration) + + assert {:error, :sso_users_present} = SSO.remove_integration(integration) + assert Repo.reload(integration) + assert Repo.reload(sso_domain) + end + + test "succeeds when SSO user present and force flag set" do + owner = new_user(totp_enabled: true, totp_secret: "secret") + team = new_site(owner: owner).team + + # Setup integration + integration = SSO.initiate_saml_integration(team) + domain = "example-#{Enum.random(1..10_000)}.com" + + {:ok, sso_domain} = SSO.Domains.add(integration, domain) + sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true) + + # Provisioned SSO identity + # + identity = new_identity("Carrie Mower", "carrie@" <> domain) + {:ok, _, _, sso_user} = SSO.provision_user(identity) + + integration = Repo.reload!(integration) + + assert :ok = SSO.remove_integration(integration, force_deprovision?: true) + refute Repo.reload(integration) + refute Repo.reload(sso_domain) + + # SSO user is deprovisioned + sso_user = Repo.reload(sso_user) + + assert sso_user.type == :standard + refute sso_user.sso_identity_id + refute sso_user.sso_integration_id + end + end + defp new_identity(name, email, id \\ Ecto.UUID.generate()) do %SSO.Identity{ id: id, diff --git a/test/plausible/auth/user_sessions_test.exs b/test/plausible/auth/user_sessions_test.exs index 673d2e795f..dae8844b48 100644 --- a/test/plausible/auth/user_sessions_test.exs +++ b/test/plausible/auth/user_sessions_test.exs @@ -1,5 +1,9 @@ defmodule Plausible.Auth.UserSessionsTest do use Plausible.DataCase, async: true + use Plausible.Teams.Test + use Plausible + + import Phoenix.ChannelTest alias Plausible.Auth alias Plausible.Auth.UserSessions @@ -50,6 +54,212 @@ defmodule Plausible.Auth.UserSessionsTest do end end + describe "touch/1,2" do + setup do + user = new_user() + + session = + user + |> Auth.UserSession.new_session("A Device") + |> Repo.insert!() + + {:ok, user: user, session: session} + end + + test "refreshes user session timestamps", %{user: user, session: session} do + two_days_later = + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(day: 2) + + assert refreshed_session = + %Auth.UserSession{} = UserSessions.touch(session, two_days_later) + + assert refreshed_session.id == session.id + assert NaiveDateTime.compare(refreshed_session.last_used_at, two_days_later) == :eq + assert NaiveDateTime.compare(Repo.reload(user).last_seen, two_days_later) == :eq + assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :gt + end + + test "does not refresh if timestamps were updated less than hour before", %{ + user: user, + session: session + } do + last_seen = Repo.reload(user).last_seen + + fifty_minutes_later = + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(minute: 50) + + assert refreshed_session1 = + %Auth.UserSession{} = + UserSessions.touch(session, fifty_minutes_later) + + assert NaiveDateTime.compare( + refreshed_session1.last_used_at, + session.last_used_at + ) == :eq + + assert NaiveDateTime.compare(Repo.reload(user).last_seen, last_seen) == :eq + + sixty_five_minutes_later = + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(minute: 65) + + assert refreshed_session2 = + %Auth.UserSession{} = + UserSessions.touch(session, sixty_five_minutes_later) + + assert NaiveDateTime.compare( + refreshed_session2.last_used_at, + sixty_five_minutes_later + ) == :eq + + assert NaiveDateTime.compare(Repo.reload(user).last_seen, sixty_five_minutes_later) == :eq + end + + test "handles concurrent refresh gracefully", %{session: session} do + # concurrent update + now = NaiveDateTime.utc_now(:second) + two_days_later = NaiveDateTime.shift(now, day: 2) + + Repo.update_all( + from(us in Auth.UserSession, where: us.token == ^session.token), + set: [timeout_at: two_days_later, last_used_at: now] + ) + + assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session) + + assert refreshed_session.id == session.id + assert Repo.reload(session) + end + + test "handles deleted session case gracefully", %{session: session} do + Repo.delete!(session) + + assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session) + + assert refreshed_session.id == session.id + + refute Repo.reload(session) + end + + on_ee do + test "only records last usage but does not refresh for SSO user", %{ + user: user, + session: session + } do + sixty_five_minutes_later = + NaiveDateTime.utc_now(:second) + |> NaiveDateTime.shift(minute: 65) + + user |> Ecto.Changeset.change(type: :sso) |> Repo.update!() + + session = Repo.reload!(session) + + assert refreshed_session = + %Auth.UserSession{} = + UserSessions.touch(session, sixty_five_minutes_later) + + assert refreshed_session.id == session.id + + assert NaiveDateTime.compare(refreshed_session.last_used_at, sixty_five_minutes_later) == + :eq + + assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :eq + end + end + end + + describe "revoke_by_id/2" do + setup do + user = new_user() + + session = + user + |> Auth.UserSession.new_session("A Device") + |> Repo.insert!() + + {:ok, user: user, session: session} + end + + test "deletes and disconnects user session", %{user: user, session: active_session} do + live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) + Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) + + another_session = + user + |> Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + assert :ok = UserSessions.revoke_by_id(user, active_session.id) + + assert [remaining_session] = Repo.preload(user, :sessions).sessions + assert_broadcast "disconnect", %{} + assert remaining_session.id == another_session.id + refute Repo.reload(active_session) + assert Repo.reload(another_session) + end + + test "does not delete session of another user", %{user: user, session: active_session} do + other_session = + insert(:user) + |> Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + assert :ok = UserSessions.revoke_by_id(user, other_session.id) + + assert Repo.reload(active_session) + assert Repo.reload(other_session) + end + + test "executes gracefully when session does not exist", %{user: user, session: active_session} do + Repo.delete!(active_session) + + assert :ok = UserSessions.revoke_by_id(user, active_session.id) + end + end + + describe "revoke_all/1,2" do + setup do + user = new_user() + + session = + user + |> Auth.UserSession.new_session("A Device") + |> Repo.insert!() + + {:ok, user: user, session: session} + end + + test "deletes and disconnects all user's sessions", %{user: user, session: active_session} do + live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) + Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) + + another_session = + user + |> Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + unrelated_session = + insert(:user) + |> Auth.UserSession.new_session("Some Device") + |> Repo.insert!() + + assert :ok = UserSessions.revoke_all(user) + + assert [] = Repo.preload(user, :sessions).sessions + assert_broadcast "disconnect", %{} + refute Repo.reload(another_session) + assert Repo.reload(unrelated_session) + end + + test "executes gracefully when user has no sessions" do + user = insert(:user) + + assert :ok = UserSessions.revoke_all(user) + end + end + defp last_used_humanize(user, dt) do user |> insert_session("Some Device", dt) @@ -58,7 +268,7 @@ defmodule Plausible.Auth.UserSessionsTest do defp insert_session(user, device_name, now) do user - |> Auth.UserSession.new_session(device_name, now) + |> Auth.UserSession.new_session(device_name, now: now) |> Repo.insert!() end end diff --git a/test/plausible_web/controllers/settings_controller_test.exs b/test/plausible_web/controllers/settings_controller_test.exs index 249e423f1f..da3f6b4955 100644 --- a/test/plausible_web/controllers/settings_controller_test.exs +++ b/test/plausible_web/controllers/settings_controller_test.exs @@ -603,7 +603,7 @@ defmodule PlausibleWeb.SettingsControllerTest do another_session = user - |> Auth.UserSession.new_session("Some Device", seventy_minutes_ago) + |> Auth.UserSession.new_session("Some Device", now: seventy_minutes_ago) |> Repo.insert!() conn = get(conn, Routes.settings_path(conn, :security)) diff --git a/test/plausible_web/user_auth_test.exs b/test/plausible_web/user_auth_test.exs index 5458c799cb..295619674c 100644 --- a/test/plausible_web/user_auth_test.exs +++ b/test/plausible_web/user_auth_test.exs @@ -3,10 +3,6 @@ defmodule PlausibleWeb.UserAuthTest do use Plausible use Plausible.Teams.Test - import Ecto.Query, only: [from: 2] - import Phoenix.ChannelTest - - alias Plausible.Auth alias Plausible.Repo alias PlausibleWeb.UserAuth @@ -297,191 +293,6 @@ defmodule PlausibleWeb.UserAuthTest do end end - describe "touch_user_session/1" do - setup [:create_user, :log_in] - - test "refreshes user session timestamps", %{user: user} do - %{sessions: [user_session]} = Repo.preload(user, :sessions) - - two_days_later = - NaiveDateTime.utc_now(:second) - |> NaiveDateTime.shift(day: 2) - - assert refreshed_session = - %Auth.UserSession{} = UserAuth.touch_user_session(user_session, two_days_later) - - assert refreshed_session.id == user_session.id - assert NaiveDateTime.compare(refreshed_session.last_used_at, two_days_later) == :eq - assert NaiveDateTime.compare(Repo.reload(user).last_seen, two_days_later) == :eq - assert NaiveDateTime.compare(refreshed_session.timeout_at, user_session.timeout_at) == :gt - end - - test "does not refresh if timestamps were updated less than hour before", %{user: user} do - %{sessions: [user_session]} = Repo.preload(user, :sessions) - user_session = Repo.reload(user_session) - last_seen = Repo.reload(user).last_seen - - fifty_minutes_later = - NaiveDateTime.utc_now(:second) - |> NaiveDateTime.shift(minute: 50) - - assert refreshed_session1 = - %Auth.UserSession{} = - UserAuth.touch_user_session(user_session, fifty_minutes_later) - - assert NaiveDateTime.compare( - refreshed_session1.last_used_at, - user_session.last_used_at - ) == :eq - - assert NaiveDateTime.compare(Repo.reload(user).last_seen, last_seen) == :eq - - sixty_five_minutes_later = - NaiveDateTime.utc_now(:second) - |> NaiveDateTime.shift(minute: 65) - - assert refreshed_session2 = - %Auth.UserSession{} = - UserAuth.touch_user_session(user_session, sixty_five_minutes_later) - - assert NaiveDateTime.compare( - refreshed_session2.last_used_at, - sixty_five_minutes_later - ) == :eq - - assert NaiveDateTime.compare(Repo.reload(user).last_seen, sixty_five_minutes_later) == :eq - end - - test "handles concurrent refresh gracefully", %{user: user} do - %{sessions: [user_session]} = Repo.preload(user, :sessions) - - # concurrent update - now = NaiveDateTime.utc_now(:second) - two_days_later = NaiveDateTime.shift(now, day: 2) - - Repo.update_all( - from(us in Auth.UserSession, where: us.token == ^user_session.token), - set: [timeout_at: two_days_later, last_used_at: now] - ) - - assert refreshed_session = - %Auth.UserSession{} = UserAuth.touch_user_session(user_session) - - assert refreshed_session.id == user_session.id - assert Repo.reload(user_session) - end - - test "handles deleted session case gracefully", %{user: user} do - %{sessions: [user_session]} = Repo.preload(user, :sessions) - Repo.delete!(user_session) - - assert refreshed_session = - %Auth.UserSession{} = UserAuth.touch_user_session(user_session) - - assert refreshed_session.id == user_session.id - - refute Repo.reload(user_session) - end - - on_ee do - test "only records last usage but does not refresh for SSO user", %{user: user} do - sixty_five_minutes_later = - NaiveDateTime.utc_now(:second) - |> NaiveDateTime.shift(minute: 65) - - user = user |> Ecto.Changeset.change(type: :sso) |> Repo.update!() - - %{sessions: [session]} = Repo.preload(user, :sessions) - - assert refreshed_session = - %Auth.UserSession{} = - UserAuth.touch_user_session(session, sixty_five_minutes_later) - - assert refreshed_session.id == session.id - - assert NaiveDateTime.compare(refreshed_session.last_used_at, sixty_five_minutes_later) == - :eq - - assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :eq - end - end - end - - describe "revoke_user_session/2" do - setup [:create_user, :log_in] - - test "deletes and disconnects user session", %{user: user} do - assert [active_session] = Repo.preload(user, :sessions).sessions - live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) - Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) - - another_session = - user - |> Auth.UserSession.new_session("Some Device") - |> Repo.insert!() - - assert :ok = UserAuth.revoke_user_session(user, active_session.id) - assert [remaining_session] = Repo.preload(user, :sessions).sessions - assert_broadcast "disconnect", %{} - assert remaining_session.id == another_session.id - refute Repo.reload(active_session) - assert Repo.reload(another_session) - end - - test "does not delete session of another user", %{user: user} do - assert [active_session] = Repo.preload(user, :sessions).sessions - - other_session = - insert(:user) - |> Auth.UserSession.new_session("Some Device") - |> Repo.insert!() - - assert :ok = UserAuth.revoke_user_session(user, other_session.id) - - assert Repo.reload(active_session) - assert Repo.reload(other_session) - end - - test "executes gracefully when session does not exist", %{user: user} do - assert [active_session] = Repo.preload(user, :sessions).sessions - Repo.delete!(active_session) - - assert :ok = UserAuth.revoke_user_session(user, active_session.id) - end - end - - describe "revoke_all_user_sessions/1" do - setup [:create_user, :log_in] - - test "deletes and disconnects all user's sessions", %{user: user} do - assert [active_session] = Repo.preload(user, :sessions).sessions - live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) - Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) - - another_session = - user - |> Auth.UserSession.new_session("Some Device") - |> Repo.insert!() - - unrelated_session = - insert(:user) - |> Auth.UserSession.new_session("Some Device") - |> Repo.insert!() - - assert :ok = UserAuth.revoke_all_user_sessions(user) - assert [] = Repo.preload(user, :sessions).sessions - assert_broadcast "disconnect", %{} - refute Repo.reload(another_session) - assert Repo.reload(unrelated_session) - end - - test "executes gracefully when user has no sessions" do - user = insert(:user) - - assert :ok = UserAuth.revoke_all_user_sessions(user) - end - end - @user_agent "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.110 Safari/537.36" @user_agent_mobile "Mozilla/5.0 (Linux; Android 6.0; U007 Pro Build/MRA58K; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/44.0.2403.119 Mobile Safari/537.36" @user_agent_tablet "Mozilla/5.0 (Linux; U; Android 4.2.2; it-it; Surfing TAB B 9.7 3G Build/JDQ39) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30" diff --git a/test/support/teams/test.ex b/test/support/teams/test.ex index d7aa04e5fb..4774458157 100644 --- a/test/support/teams/test.ex +++ b/test/support/teams/test.ex @@ -2,6 +2,8 @@ defmodule Plausible.Teams.Test do @moduledoc """ Convenience assertions for teams schema transition """ + use Plausible + alias Plausible.Repo alias Plausible.Teams @@ -56,6 +58,11 @@ defmodule Plausible.Teams.Test do def new_user(args \\ []) do {team_args, args} = Keyword.pop(args, :team, []) {trial_expiry_date, args} = Keyword.pop(args, :trial_expiry_date) + + on_ee do + args = Keyword.merge([type: :standard], args) + end + user = insert(:user, args) trial_expiry_date = diff --git a/test/workers/clean_user_sessions_test.exs b/test/workers/clean_user_sessions_test.exs index e6006866ea..8d677c1e65 100644 --- a/test/workers/clean_user_sessions_test.exs +++ b/test/workers/clean_user_sessions_test.exs @@ -28,7 +28,7 @@ defmodule Plausible.Workers.CleanUserSessionsTest do user = insert(:user) user - |> UserSession.new_session("Unknown", now) + |> UserSession.new_session("Unknown", now: now) |> Repo.insert!() end end