| defmodule PlausibleWeb.SettingsControllerTest do |
| use PlausibleWeb.ConnCase, async: true |
| use Bamboo.Test |
| use Plausible.Repo |
|
|
| import Mox |
|
|
| import Ecto.Query |
|
|
| require Plausible.Billing.Subscription.Status |
|
|
| alias Plausible.Auth |
| alias Plausible.Billing.Subscription |
|
|
| @v3_plan_id "749355" |
| @v4_plan_id "857097" |
| @configured_enterprise_plan_paddle_plan_id "123" |
|
|
| setup [:verify_on_exit!] |
|
|
| describe "GET /billing/invoices" do |
| setup [:create_user, :log_in] |
|
|
| test "redirects to subscription settings", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :redirect_invoices)) |
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :subscription) |
| end |
| end |
|
|
| describe "GET /billing/subscription" do |
| setup [:create_user, :log_in] |
|
|
| @tag :ee_only |
| test "shows subscription", %{conn: conn, user: user} do |
| subscribe_to_plan(user, "558018") |
| conn = get(conn, Routes.settings_path(conn, :subscription)) |
| assert html_response(conn, 200) =~ "10k monthly pageviews" |
| assert html_response(conn, 200) =~ "/ month" |
| end |
|
|
| @tag :ee_only |
| test "shows yearly subscription", %{conn: conn, user: user} do |
| subscribe_to_plan(user, "590752") |
| conn = get(conn, Routes.settings_path(conn, :subscription)) |
| assert html_response(conn, 200) =~ "100k monthly pageviews" |
| assert html_response(conn, 200) =~ "/ year" |
| end |
|
|
| @tag :ee_only |
| test "shows free subscription", %{conn: conn, user: user} do |
| subscribe_to_plan(user, "free_10k") |
| conn = get(conn, Routes.settings_path(conn, :subscription)) |
| assert html_response(conn, 200) =~ "10k monthly pageviews" |
| assert html_response(conn, 200) =~ "N/A" |
| end |
|
|
| @tag :ee_only |
| test "shows enterprise plan subscription", %{conn: conn, user: user} do |
| configure_enterprise_plan(user) |
|
|
| conn = get(conn, Routes.settings_path(conn, :subscription)) |
| assert html_response(conn, 200) =~ "20M monthly pageviews" |
| assert html_response(conn, 200) =~ "/ year" |
| end |
|
|
| @tag :ee_only |
| test "shows current enterprise plan subscription when user has a new one to upgrade to", %{ |
| conn: conn, |
| user: user |
| } do |
| configure_enterprise_plan(user) |
|
|
| subscribe_to_enterprise_plan(user, |
| paddle_plan_id: "1234", |
| monthly_pageview_limit: 10_000_000, |
| billing_interval: :yearly, |
| subscription?: false |
| ) |
|
|
| conn = get(conn, Routes.settings_path(conn, :subscription)) |
| assert html_response(conn, 200) =~ "20M monthly pageviews" |
| assert html_response(conn, 200) =~ "/ year" |
| end |
|
|
| @tag :ee_only |
| test "shows trial state without days-left pill when user has no team yet", %{conn: conn} do |
| doc = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| upgrade_link = find(doc, "#upgrade-or-change-plan-link") |
|
|
| assert text(upgrade_link) =~ "Choose a plan" |
| assert text_of_attr(upgrade_link, "href") == Routes.billing_path(conn, :choose_plan) |
| assert doc =~ "Your 30-day trial will start when you add your first site" |
| refute doc =~ "days left" |
| end |
|
|
| @tag :ee_only |
| test "shows trial state with days-left pill when user is on trial", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
|
|
| team |
| |> Ecto.Changeset.change(trial_expiry_date: Date.add(Date.utc_today(), 10)) |
| |> Plausible.Repo.update!() |
|
|
| doc = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| upgrade_link = find(doc, "#upgrade-or-change-plan-link") |
|
|
| assert text(upgrade_link) =~ "Choose a plan" |
| assert text_of_attr(upgrade_link, "href") == Routes.billing_path(conn, :choose_plan) |
| assert doc =~ "days left" |
| refute doc =~ "Your 30-day trial will start when you add your first site" |
| end |
|
|
| @tag :ee_only |
| test "renders a link to '/billing/choose-plan' with the text 'Change plan' + cancel link", %{ |
| conn: conn, |
| user: user |
| } do |
| subscribe_to_plan(user, @v3_plan_id) |
|
|
| doc = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert doc =~ "Cancel plan" |
|
|
| change_plan_link = find(doc, "#upgrade-or-change-plan-link") |
|
|
| assert text(change_plan_link) == "Change plan" |
| assert text_of_attr(change_plan_link, "href") == Routes.billing_path(conn, :choose_plan) |
| end |
|
|
| @tag :ee_only |
| test "/billing/choose-plan link does not show up when enterprise subscription is past_due", %{ |
| conn: conn, |
| user: user |
| } do |
| configure_enterprise_plan(user, subscription: [status: Subscription.Status.past_due()]) |
|
|
| doc = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| refute element_exists?(doc, "#upgrade-or-change-plan-link") |
| end |
|
|
| @tag :ee_only |
| test "/billing/choose-plan link does not show up when enterprise subscription is paused", %{ |
| conn: conn, |
| user: user |
| } do |
| configure_enterprise_plan(user, subscription: [status: Subscription.Status.paused()]) |
|
|
| doc = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| refute element_exists?(doc, "#upgrade-or-change-plan-link") |
| end |
|
|
| @tag :ee_only |
| test "renders a link to '/billing/choose-plan' with the text 'Upgrade' for a configured enterprise plan", |
| %{conn: conn, user: user} do |
| subscribe_to_enterprise_plan(user, |
| paddle_plan_id: @configured_enterprise_plan_paddle_plan_id, |
| monthly_pageview_limit: 20_000_000, |
| billing_interval: :yearly, |
| subscription?: false |
| ) |
|
|
| doc = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| upgrade_link = find(doc, "#upgrade-or-change-plan-link") |
|
|
| assert text(upgrade_link) == "Upgrade" |
| assert text_of_attr(upgrade_link, "href") == Routes.billing_path(conn, :choose_plan) |
| end |
|
|
| @tag :ee_only |
| test "links to '/billing/choose-plan' with the text 'Change plan' for a configured enterprise plan with an existing subscription + renders cancel button", |
| %{conn: conn, user: user} do |
| configure_enterprise_plan(user) |
|
|
| doc = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert doc =~ "Cancel plan" |
|
|
| change_plan_link = find(doc, "#upgrade-or-change-plan-link") |
|
|
| assert text(change_plan_link) == "Change plan" |
|
|
| assert text_of_attr(change_plan_link, "href") == |
| Routes.billing_path(conn, :choose_plan) |
| end |
|
|
| @tag :ee_only |
| test "renders cancelled subscription notice", %{conn: conn, user: user} do |
| subscribe_to_plan( |
| user, |
| @v4_plan_id, |
| status: :deleted, |
| next_bill_date: ~D[2023-01-01] |
| ) |
|
|
| notice_text = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> text_of_element("#global-subscription-cancelled-notice") |
|
|
| refute notice_text =~ Plausible.Billing.subscription_cancelled_notice_title() |
| end |
|
|
| @tag :ee_only |
| test "renders cancelled subscription notice with some subscription days still left", %{ |
| conn: conn, |
| user: user |
| } do |
| subscribe_to_plan(user, @v4_plan_id, |
| status: :deleted, |
| next_bill_date: Date.shift(Date.utc_today(), day: 10) |
| ) |
|
|
| notice_text = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> text_of_element("#global-subscription-cancelled-notice") |
|
|
| assert notice_text =~ "Subscription cancelled" |
| assert notice_text =~ "You have access to your stats until" |
| assert notice_text =~ "Upgrade your subscription to make sure you don't lose access" |
| end |
|
|
| @tag :ee_only |
| test "renders cancelled subscription notice with a warning about losing grandfathering", %{ |
| conn: conn, |
| user: user |
| } do |
| subscribe_to_plan( |
| user, |
| @v3_plan_id, |
| status: :deleted, |
| next_bill_date: Date.shift(Date.utc_today(), day: 10) |
| ) |
|
|
| notice_text = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> text_of_element("#global-subscription-cancelled-notice") |
|
|
| assert notice_text =~ "Subscription cancelled" |
| assert notice_text =~ "You have access to your stats until" |
|
|
| assert notice_text =~ |
| "by letting your subscription expire, you lose access to our grandfathered terms" |
| end |
|
|
| @tag :ee_only |
| test "does not show invoice section for a user with no subscription", %{conn: conn} do |
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| refute element_exists?(html, "#invoices") |
| end |
|
|
| @tag :ee_only |
| test "renders billing cycle usage breakdown", %{ |
| conn: conn, |
| user: user |
| } do |
| site = new_site(owner: user) |
|
|
| populate_stats(site, [ |
| build(:event, name: "pageview", timestamp: DateTime.shift(DateTime.utc_now(), day: -5)), |
| build(:event, |
| name: "customevent", |
| timestamp: DateTime.shift(DateTime.utc_now(), day: -20) |
| ), |
| build(:event, name: "pageview", timestamp: DateTime.shift(DateTime.utc_now(), day: -50)), |
| build(:event, |
| name: "customevent", |
| timestamp: DateTime.shift(DateTime.utc_now(), day: -50) |
| ) |
| ]) |
|
|
| last_bill_date = Date.shift(Date.utc_today(), day: -10) |
|
|
| subscribe_to_plan(user, @v4_plan_id, last_bill_date: last_bill_date, status: :deleted) |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert html =~ |
| Date.range(last_bill_date, Date.shift(last_bill_date, month: 1, day: -1)) |
| |> PlausibleWeb.TextHelpers.format_date_range() |
|
|
| assert text_of_element(html, "#pageviews_current_cycle") =~ "Pageviews 1" |
| assert text_of_element(html, "#custom_events_current_cycle") =~ "Custom events 0" |
|
|
| refute element_exists?(html, "#total_pageviews_last_cycle") |
| refute element_exists?(html, "#total_pageviews_penultimate_cycle") |
| end |
|
|
| @tag :ee_only |
| test "renders pageview usage per billing cycle for active subscribers", %{ |
| conn: conn, |
| user: user |
| } do |
| assert_cycles_rendered = fn doc -> |
| refute element_exists?(doc, "#total_pageviews_last_30_days") |
|
|
| assert element_exists?(doc, "#total_pageviews_current_cycle") |
| end |
|
|
| subscribe_to_plan(user, @v4_plan_id, |
| status: :active, |
| last_bill_date: Date.shift(Date.utc_today(), month: -6) |
| ) |
|
|
| subscription = |
| user |
| |> team_of() |
| |> Plausible.Teams.with_subscription() |
| |> Map.fetch!(:subscription) |
|
|
| get(conn, Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> assert_cycles_rendered.() |
|
|
| |
| subscription = |
| subscription |
| |> Plausible.Billing.Subscription.changeset(%{status: :past_due}) |
| |> Repo.update!() |
|
|
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> assert_cycles_rendered.() |
|
|
| |
| subscription |
| |> Plausible.Billing.Subscription.changeset(%{ |
| status: :deleted, |
| next_bill_date: Date.shift(Date.utc_today(), month: 6) |
| }) |
| |> Repo.update!() |
|
|
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> assert_cycles_rendered.() |
| end |
|
|
| @tag :ee_only |
| test "renders last 30 days pageview usage for trials and non-active/free_10k subscriptions", |
| %{ |
| conn: conn, |
| user: user |
| } do |
| site = new_site(owner: user) |
|
|
| populate_stats(site, [ |
| build(:event, name: "pageview", timestamp: DateTime.shift(DateTime.utc_now(), day: -1)), |
| build(:event, |
| name: "customevent", |
| timestamp: DateTime.shift(DateTime.utc_now(), day: -10) |
| ), |
| build(:event, |
| name: "customevent", |
| timestamp: DateTime.shift(DateTime.utc_now(), day: -20) |
| ) |
| ]) |
|
|
| assert_usage = fn doc -> |
| refute element_exists?(doc, "#total_pageviews_current_cycle") |
| assert text_of_element(doc, "#pageviews_last_30_days") =~ "Pageviews 1" |
| assert text_of_element(doc, "#custom_events_last_30_days") =~ "Custom events 2" |
| end |
|
|
| |
| trial_html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert_usage.(trial_html) |
| refute element_exists?(trial_html, "#total_pageviews_last_30_days") |
|
|
| subscribe_to_plan(user, @v4_plan_id, |
| status: :deleted, |
| last_bill_date: ~D[2022-01-01], |
| next_bill_date: ~D[2022-02-01] |
| ) |
|
|
| subscription = |
| user |
| |> team_of() |
| |> Plausible.Teams.with_subscription() |
| |> Map.fetch!(:subscription) |
|
|
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> assert_usage.() |
|
|
| |
| subscription = |
| subscription |
| |> Plausible.Billing.Subscription.changeset(%{status: :paused}) |
| |> Repo.update!() |
|
|
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> assert_usage.() |
|
|
| |
| Repo.delete!(subscription) |
|
|
| user |
| |> team_of() |
| |> Plausible.Billing.Subscription.free() |
| |> Repo.insert!() |
|
|
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
| |> assert_usage.() |
| end |
|
|
| @tag :ee_only |
| test "renders sites usage and limit", %{conn: conn, user: user} do |
| subscribe_to_plan(user, @v3_plan_id) |
| new_site(owner: user) |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| sites_usage_text = text_of_element(html, "[data-test-id='sites-usage']") |
| assert sites_usage_text =~ "1 / 50" |
| end |
|
|
| @tag :ee_only |
| test "renders team members usage and limit", %{conn: conn, user: user} do |
| subscribe_to_plan(user, @v4_plan_id) |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| team_member_usage_text = text_of_element(html, "[data-test-id='team-member-usage']") |
| assert team_member_usage_text =~ "0 / 3" |
| end |
|
|
| @tag :ee_only |
| test "renders team member usage with unlimited limit", %{conn: conn, user: user} do |
| subscribe_to_plan(user, @v3_plan_id) |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| team_member_usage_text = text_of_element(html, "[data-test-id='team-member-usage']") |
| assert team_member_usage_text =~ "/ Unlimited" |
| end |
|
|
| test "does not show invoice section for a free subscription", %{conn: conn, user: user} do |
| new_site(owner: user) |
|
|
| user |
| |> team_of() |
| |> Plausible.Billing.Subscription.free(%{currency_code: "EUR"}) |
| |> Repo.insert!() |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert html =~ "You don't have any invoices yet." |
| end |
|
|
| @tag :ee_only |
| test "shows invoices for subscribed user", %{conn: conn, user: user} do |
| subscribe_to_plan(user, "558018") |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert html =~ "Dec 24, 2020" |
| assert html =~ "€11.11" |
| assert html =~ "Nov 24, 2020" |
| assert html =~ "$22.00" |
| end |
|
|
| @tag :ee_only |
| test "shows message on failed invoice request'", %{conn: conn, user: user} do |
| subscribe_to_plan(user, "558018", paddle_subscription_id: "invalid_subscription_id") |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert html =~ "Invoices" |
| assert text(html) =~ "We couldn't retrieve your invoices" |
| end |
|
|
| @tag :ee_only |
| test "shows dashboard link to the site when team has exactly one site", %{ |
| conn: conn, |
| user: user |
| } do |
| new_site(owner: user) |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert element_exists?(html, "[data-test-id='total-pageviews-dashboard-link']") |
| end |
|
|
| @tag :ee_only |
| test "shows no total dashboard link when team has multiple sites and no consolidated view", %{ |
| conn: conn, |
| user: user |
| } do |
| new_site(owner: user) |
| new_site(owner: user) |
|
|
| html = |
| conn |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| refute element_exists?(html, "[data-test-id='total-pageviews-dashboard-link']") |
| end |
|
|
| on_ee do |
| test "shows consolidated view dashboard link when team has a consolidated view", %{ |
| conn: conn, |
| user: user |
| } do |
| new_site(owner: user) |
| new_site(owner: user) |
| team = team_of(user) |
| new_consolidated_view(team) |
|
|
| html = |
| conn |
| |> set_current_team(team) |
| |> get(Routes.settings_path(conn, :subscription)) |
| |> html_response(200) |
|
|
| assert element_exists?(html, "[data-test-id='total-pageviews-dashboard-link']") |
| end |
| end |
|
|
| @tag :ee_only |
| test "allows owner of a setup team to access the page", %{conn: conn, user: user} do |
| team = new_user(team: [setup_complete: true]) |> team_of() |
| add_member(team, user: user, role: :owner) |
|
|
| conn = |
| conn |
| |> set_current_team(team) |
| |> get(Routes.settings_path(conn, :subscription)) |
|
|
| assert html_response(conn, 200) |
| end |
|
|
| @tag :ee_only |
| test "allows billing role of a setup team to access the page", %{conn: conn, user: user} do |
| owner = new_user(team: [setup_complete: true]) |
| team = team_of(owner) |
| add_member(team, user: user, role: :billing) |
|
|
| conn = |
| conn |
| |> set_current_team(team) |
| |> get(Routes.settings_path(conn, :subscription)) |
|
|
| assert html_response(conn, 200) |
| end |
|
|
| @tag :ee_only |
| test "redirects admin role of a setup team away from the page", %{conn: conn, user: user} do |
| owner = new_user(team: [setup_complete: true]) |
| team = team_of(owner) |
| add_member(team, user: user, role: :admin) |
|
|
| conn = |
| conn |
| |> set_current_team(team) |
| |> get(Routes.settings_path(conn, :subscription)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
| end |
|
|
| @tag :ee_only |
| test "redirects editor role of a setup team away from the page", %{conn: conn, user: user} do |
| owner = new_user(team: [setup_complete: true]) |
| team = team_of(owner) |
| add_member(team, user: user, role: :editor) |
|
|
| conn = |
| conn |
| |> set_current_team(team) |
| |> get(Routes.settings_path(conn, :subscription)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
| end |
|
|
| @tag :ee_only |
| test "redirects viewer role of a setup team away from the page", %{conn: conn, user: user} do |
| owner = new_user(team: [setup_complete: true]) |
| team = team_of(owner) |
| add_member(team, user: user, role: :viewer) |
|
|
| conn = |
| conn |
| |> set_current_team(team) |
| |> get(Routes.settings_path(conn, :subscription)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
| end |
|
|
| test "allows any role on a non-setup (personal) team to access the page", %{ |
| conn: conn, |
| user: user |
| } do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
|
|
| refute Plausible.Teams.setup?(team) |
|
|
| conn = |
| conn |
| |> set_current_team(team) |
| |> get(Routes.settings_path(conn, :subscription)) |
|
|
| assert html_response(conn, 200) |
| end |
| end |
|
|
| describe "GET /security" do |
| setup [:create_user, :log_in] |
|
|
| test "renders 2FA section in disabled state", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :security)) |
|
|
| assert html_response(conn, 200) =~ "Enable 2FA" |
| end |
|
|
| test "renders 2FA in enabled state", %{conn: conn, user: user} do |
| {:ok, user, _} = Auth.TOTP.initiate(user) |
| {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) |
|
|
| conn = get(conn, Routes.settings_path(conn, :security)) |
|
|
| assert html_response(conn, 200) =~ "Disable 2FA" |
| end |
|
|
| test "renders active user sessions with an option to revoke them", %{conn: conn, user: user} do |
| now = NaiveDateTime.utc_now(:second) |
| seventy_minutes_ago = NaiveDateTime.shift(now, minute: -70) |
|
|
| another_session = |
| user |
| |> Auth.UserSession.new_session("Some Device", now: seventy_minutes_ago) |
| |> Repo.insert!() |
|
|
| conn = get(conn, Routes.settings_path(conn, :security)) |
|
|
| assert html = html_response(conn, 200) |
|
|
| assert html =~ "Unknown" |
| assert html =~ "Current session" |
| assert html =~ "Just recently" |
| assert html =~ "Some Device" |
| assert html =~ "1 hour ago" |
| assert html =~ Routes.settings_path(conn, :delete_session, another_session.id) |
| end |
| end |
|
|
| describe "DELETE /security/user-sessions/:id" do |
| setup [:create_user, :log_in] |
|
|
| test "deletes session", %{conn: conn, user: user} do |
| another_session = |
| user |
| |> Auth.UserSession.new_session("Some Device") |
| |> Repo.insert!() |
|
|
| conn = delete(conn, Routes.settings_path(conn, :delete_session, another_session.id)) |
|
|
| assert Phoenix.Flash.get(conn.assigns.flash, :success) == "Session logged out successfully" |
|
|
| assert redirected_to(conn, 302) == |
| Routes.settings_path(conn, :security) <> "#user-sessions" |
|
|
| refute Repo.reload(another_session) |
| end |
|
|
| test "refuses deletion when not logged in" do |
| another_session = |
| insert(:user) |
| |> Auth.UserSession.new_session("Some Device") |
| |> Repo.insert!() |
|
|
| conn = build_conn() |
| conn = delete(conn, Routes.settings_path(conn, :delete_session, another_session.id)) |
|
|
| assert redirected_to(conn, 302) == Routes.auth_path(conn, :login_form) |
| assert Repo.reload(another_session) |
| end |
| end |
|
|
| describe "POST /preferences/name" do |
| setup [:create_user, :log_in] |
|
|
| test "updates user's name", %{conn: conn, user: user} do |
| conn = |
| post(conn, Routes.settings_path(conn, :update_name), %{"user" => %{"name" => "New name"}}) |
|
|
| assert redirected_to(conn, 302) == |
| Routes.settings_path(conn, :preferences) <> "#update-name" |
|
|
| user = Plausible.Repo.get(Plausible.Auth.User, user.id) |
| assert user.name == "New name" |
| end |
|
|
| test "renders form with error if form validations fail", %{conn: conn} do |
| conn = post(conn, Routes.settings_path(conn, :update_name), %{"user" => %{"name" => ""}}) |
|
|
| assert text(html_response(conn, 200)) =~ "can't be blank" |
| end |
| end |
|
|
| on_ee do |
| describe "POST /preferences/name - SSO user" do |
| setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] |
|
|
| test "refuses to update for SSO user", %{conn: conn, user: user} do |
| conn = |
| post(conn, Routes.settings_path(conn, :update_name), %{ |
| "user" => %{"name" => "New name"} |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
|
|
| assert Repo.reload!(user).name == user.name |
| end |
| end |
| end |
|
|
| describe "POST /security/password" do |
| setup [:create_user, :log_in] |
|
|
| test "updates the password and kills other sessions", %{conn: conn, user: user} do |
| password = "very-long-very-secret-123" |
| new_password = "super-long-super-secret-999" |
|
|
| another_session = |
| user |
| |> Auth.UserSession.new_session("Some Device") |
| |> Repo.insert!() |
|
|
| original = |
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => new_password, |
| "old_password" => password, |
| "password_confirmation" => new_password |
| } |
| }) |
|
|
| assert redirected_to(conn, 302) == |
| Routes.settings_path(conn, :security) <> "#update-password" |
|
|
| current_hash = Repo.reload!(user).password_hash |
| assert current_hash != original.password_hash |
| assert Plausible.Auth.Password.match?(new_password, current_hash) |
|
|
| assert [remaining_session] = Repo.preload(user, :sessions).sessions |
| assert remaining_session.id != another_session.id |
| end |
|
|
| test "fails to update weak password", %{conn: conn} do |
| password = "very-long-very-secret-123" |
| new_password = "weak" |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => new_password, |
| "old_password" => password, |
| "password_confirmation" => new_password |
| } |
| }) |
|
|
| assert html = html_response(conn, 200) |
| assert html =~ "is too weak" |
| end |
|
|
| test "fails to update confirmation mismatch", %{conn: conn} do |
| password = "very-long-very-secret-123" |
| new_password = "super-long-super-secret-999" |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => new_password, |
| "old_password" => password, |
| "password_confirmation" => new_password <> "mismatch" |
| } |
| }) |
|
|
| assert html = html_response(conn, 200) |
| assert html =~ "does not match confirmation" |
| end |
|
|
| test "updates the password when 2FA is enabled", %{conn: conn, user: user} do |
| password = "very-long-very-secret-123" |
| new_password = "super-long-super-secret-999" |
|
|
| original = |
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| {:ok, user, _} = Auth.TOTP.initiate(user) |
| {:ok, user, _} = Auth.TOTP.enable(user, :skip_verify) |
|
|
| code = NimbleTOTP.verification_code(user.totp_secret) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => new_password, |
| "old_password" => password, |
| "password_confirmation" => new_password, |
| "two_factor_code" => code |
| } |
| }) |
|
|
| assert redirected_to(conn, 302) == |
| Routes.settings_path(conn, :security) <> "#update-password" |
|
|
| current_hash = Repo.reload!(user).password_hash |
| assert current_hash != original.password_hash |
| assert Plausible.Auth.Password.match?(new_password, current_hash) |
| end |
|
|
| test "fails to update with wrong 2fa code", %{conn: conn, user: user} do |
| password = "very-long-very-secret-123" |
|
|
| user = |
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| new_password = "super-long-super-secret-999" |
|
|
| {:ok, user, _} = Auth.TOTP.initiate(user) |
| {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => new_password, |
| "old_password" => password, |
| "password_confirmation" => new_password, |
| "two_factor_code" => "111111" |
| } |
| }) |
|
|
| assert html = html_response(conn, 200) |
| assert html =~ "invalid 2FA code" |
| end |
|
|
| test "fails to update with missing 2fa code", %{conn: conn, user: user} do |
| password = "very-long-very-secret-123" |
|
|
| user = |
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| new_password = "super-long-super-secret-999" |
|
|
| {:ok, user, _} = Auth.TOTP.initiate(user) |
| {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => new_password, |
| "old_password" => password, |
| "password_confirmation" => new_password |
| } |
| }) |
|
|
| assert html = html_response(conn, 200) |
| assert html =~ "invalid 2FA code" |
| end |
|
|
| test "fails to update with no input", %{conn: conn} do |
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => "", |
| "old_password" => "", |
| "password_confirmation" => "" |
| } |
| }) |
|
|
| assert html = html_response(conn, 200) |
| assert text(html) =~ "can't be blank" |
| end |
| end |
|
|
| on_ee do |
| describe "POST /security/password - SSO user" do |
| setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] |
|
|
| test "refuses to update for SSO user", %{conn: conn, user: user} do |
| password = "very-long-very-secret-123" |
| new_password = "super-long-super-secret-999" |
|
|
| original = |
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_password), %{ |
| "user" => %{ |
| "password" => new_password, |
| "old_password" => password, |
| "password_confirmation" => new_password |
| } |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
|
|
| current_hash = Repo.reload!(user).password_hash |
| assert current_hash == original.password_hash |
| assert Plausible.Auth.Password.match?(password, current_hash) |
| end |
| end |
| end |
|
|
| describe "POST /security/email" do |
| setup [:create_user, :log_in] |
|
|
| test "updates email and forces reverification", %{conn: conn, user: user} do |
| password = "very-long-very-secret-123" |
|
|
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| assert user.email_verified |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_email), %{ |
| "user" => %{"email" => "new" <> user.email, "password" => password} |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.auth_path(conn, :activate) |
|
|
| updated_user = Repo.reload!(user) |
|
|
| assert updated_user.email == "new" <> user.email |
| assert updated_user.previous_email == user.email |
| refute updated_user.email_verified |
|
|
| assert_delivered_email_matches(%{to: [{_, user_email}], subject: subject}) |
| assert user_email == updated_user.email |
| assert subject =~ "is your Plausible email verification code" |
| end |
|
|
| test "renders an error on third change attempt (allows 2 per hour)", %{conn: conn, user: user} do |
| payload = %{ |
| "user" => %{"email" => "new" <> user.email, "password" => "badpass"} |
| } |
|
|
| resp1 = |
| conn |> post(Routes.settings_path(conn, :update_email), payload) |> html_response(200) |
|
|
| assert resp1 =~ "is invalid" |
| refute resp1 =~ "too many requests, try again in an hour" |
|
|
| resp2 = |
| conn |> post(Routes.settings_path(conn, :update_email), payload) |> html_response(200) |
|
|
| assert resp2 =~ "is invalid" |
| refute resp2 =~ "too many requests, try again in an hour" |
|
|
| resp3 = |
| conn |> post(Routes.settings_path(conn, :update_email), payload) |> html_response(200) |
|
|
| assert resp3 =~ "is invalid" |
| assert resp3 =~ "too many requests, try again in an hour" |
| end |
|
|
| test "renders form with error on no fields filled", %{conn: conn} do |
| conn = post(conn, Routes.settings_path(conn, :update_email), %{"user" => %{"email" => ""}}) |
|
|
| assert text(html_response(conn, 200)) =~ "can't be blank" |
| end |
|
|
| test "renders form with error on invalid password", %{conn: conn, user: user} do |
| conn = |
| post(conn, Routes.settings_path(conn, :update_email), %{ |
| "user" => %{"password" => "invalid", "email" => "new" <> user.email} |
| }) |
|
|
| assert html_response(conn, 200) =~ "is invalid" |
| end |
|
|
| test "renders form with error on already taken email", %{conn: conn, user: user} do |
| other_user = insert(:user) |
|
|
| password = "very-long-very-secret-123" |
|
|
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_email), %{ |
| "user" => %{"password" => password, "email" => other_user.email} |
| }) |
|
|
| assert html_response(conn, 200) =~ "has already been taken" |
| end |
|
|
| test "renders form with error when email is identical with the current one", %{ |
| conn: conn, |
| user: user |
| } do |
| password = "very-long-very-secret-123" |
|
|
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_email), %{ |
| "user" => %{"password" => password, "email" => user.email} |
| }) |
|
|
| assert html_response(conn, 200) =~ htmlize_quotes("can't be the same") |
| end |
| end |
|
|
| on_ee do |
| describe "POST /security/email - SSO user" do |
| setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] |
|
|
| test "refuses to update for SSO user", %{conn: conn, user: user} do |
| password = "very-long-very-secret-123" |
|
|
| user |
| |> Auth.User.set_password(password) |
| |> Repo.update!() |
|
|
| assert user.email_verified |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_email), %{ |
| "user" => %{"email" => "new" <> user.email, "password" => password} |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
|
|
| updated_user = Repo.reload!(user) |
|
|
| assert updated_user.email == user.email |
| assert updated_user.email_verified |
| end |
| end |
| end |
|
|
| describe "POST /security/email/cancel" do |
| setup [:create_user, :log_in] |
|
|
| test "cancels email reverification in progress", %{conn: conn, user: user} do |
| user = |
| user |
| |> Ecto.Changeset.change( |
| email_verified: false, |
| email: "new" <> user.email, |
| previous_email: user.email |
| ) |
| |> Repo.update!() |
|
|
| conn = post(conn, Routes.settings_path(conn, :cancel_update_email)) |
|
|
| assert redirected_to(conn, 302) == |
| Routes.settings_path(conn, :security) <> "#update-email" |
|
|
| updated_user = Repo.reload!(user) |
|
|
| assert updated_user.email_verified |
| assert updated_user.email == user.previous_email |
| refute updated_user.previous_email |
| end |
|
|
| test "fails to cancel reverification when previous email is already retaken", %{ |
| conn: conn, |
| user: user |
| } do |
| user = |
| user |
| |> Ecto.Changeset.change( |
| email_verified: false, |
| email: "new" <> user.email, |
| previous_email: user.email |
| ) |
| |> Repo.update!() |
|
|
| _other_user = insert(:user, email: user.previous_email) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :cancel_update_email)) |
|
|
| assert redirected_to(conn, 302) == Routes.auth_path(conn, :activate_form) |
|
|
| assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ |
| "Could not cancel email update" |
| end |
|
|
| test "crashes when previous email is empty on cancel (should not happen)", %{ |
| conn: conn, |
| user: user |
| } do |
| user |
| |> Ecto.Changeset.change( |
| email_verified: false, |
| email: "new" <> user.email, |
| previous_email: nil |
| ) |
| |> Repo.update!() |
|
|
| assert_raise RuntimeError, ~r/Previous email is empty for user/, fn -> |
| post(conn, Routes.settings_path(conn, :cancel_update_email)) |
| end |
| end |
| end |
|
|
| describe "GET /settings/api-keys" do |
| setup [:create_user, :log_in] |
|
|
| test "handles user without a team gracefully", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :api_keys)) |
|
|
| assert html_response(conn, 200) |
| end |
|
|
| @tag :ee_only |
| test "lists types of keys", %{conn: conn, user: user} do |
| user |
| |> subscribe_to_enterprise_plan( |
| features: [Plausible.Billing.Feature.StatsAPI, Plausible.Billing.Feature.SitesAPI] |
| ) |
|
|
| insert(:api_key, user: user) |
| insert(:api_key, user: user, scopes: ["sites:provision:*"]) |
|
|
| conn = get(conn, Routes.settings_path(conn, :api_keys)) |
|
|
| assert html = html_response(conn, 200) |
|
|
| assert html =~ "Stats API" |
| assert html =~ "Sites API" |
| end |
| end |
|
|
| describe "POST /settings/api-keys" do |
| setup [:create_user, :log_in] |
|
|
| test "can create an API key", %{conn: conn, user: user} do |
| new_site(owner: user) |
|
|
| team = team_of(user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :api_keys), %{ |
| "api_key" => %{ |
| "user_id" => user.id, |
| "name" => "all your code are belong to us", |
| "key" => "swordfish", |
| "type" => "stats_api" |
| } |
| }) |
|
|
| key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one() |
| assert conn.status == 302 |
| assert key.name == "all your code are belong to us" |
| assert key.team_id == team.id |
| end |
|
|
| test "can create a Sites API key", %{conn: conn, user: user} do |
| user |
| |> subscribe_to_enterprise_plan( |
| features: [ |
| Plausible.Billing.Feature.StatsAPI, |
| Plausible.Billing.Feature.SitesAPI |
| ] |
| ) |
|
|
| new_site(owner: user) |
|
|
| team = team_of(user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :api_keys), %{ |
| "api_key" => %{ |
| "user_id" => user.id, |
| "name" => "all your code are belong to us", |
| "key" => "swordfish", |
| "type" => "sites_api" |
| } |
| }) |
|
|
| key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one() |
| assert conn.status == 302 |
| assert key.name == "all your code are belong to us" |
| assert key.team_id == team.id |
| assert key.scopes == ["sites:provision:*"] |
| end |
|
|
| test "can't create a Sites API key without Sites API feature", %{conn: conn, user: user} do |
| user |> subscribe_to_business_plan() |
|
|
| new_site(owner: user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :api_keys), %{ |
| "api_key" => %{ |
| "user_id" => user.id, |
| "name" => "all your code are belong to us", |
| "key" => "swordfish", |
| "type" => "sites_api" |
| } |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :new_api_key) |
|
|
| assert Phoenix.Flash.get(conn.assigns.flash, :error) == |
| "Your current subscription plan does not include Sites API access" |
| end |
|
|
| test "can create an API key when switched to another team", %{conn: conn, user: user} do |
| new_site(owner: user) |
|
|
| team = new_site().team |> Plausible.Teams.complete_setup() |
|
|
| add_member(team, user: user, role: :editor) |
|
|
| conn = set_current_team(conn, team) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :api_keys), %{ |
| "api_key" => %{ |
| "user_id" => user.id, |
| "name" => "all your code are belong to us", |
| "key" => "swordfish", |
| "type" => "stats_api" |
| } |
| }) |
|
|
| key = Plausible.Auth.ApiKey |> where(user_id: ^user.id) |> Repo.one() |
| assert conn.status == 302 |
| assert key.name == "all your code are belong to us" |
| assert key.team_id == team.id |
| end |
|
|
| test "cannot create a duplicate API key", %{conn: conn, user: user} do |
| new_site(owner: user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :api_keys), %{ |
| "api_key" => %{ |
| "user_id" => user.id, |
| "name" => "all your code are belong to us", |
| "key" => "swordfish", |
| "type" => "stats_api" |
| } |
| }) |
|
|
| conn2 = |
| post(conn, Routes.settings_path(conn, :api_keys), %{ |
| "api_key" => %{ |
| "user_id" => user.id, |
| "name" => "all your code are belong to us", |
| "key" => "swordfish", |
| "type" => "stats_api" |
| } |
| }) |
|
|
| assert html_response(conn2, 200) =~ "has already been taken" |
| end |
|
|
| test "can't create api key into another site", %{conn: conn, user: me} do |
| _my_site = new_site(owner: me) |
| other_user = new_user() |
| _other_site = new_site(owner: other_user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :api_keys), %{ |
| "api_key" => %{ |
| "user_id" => other_user.id, |
| "name" => "all your code are belong to us", |
| "key" => "swordfish", |
| "type" => "stats_api" |
| } |
| }) |
|
|
| assert conn.status == 302 |
|
|
| refute Plausible.Auth.ApiKey |> where(user_id: ^other_user.id) |> Repo.one() |
| end |
| end |
|
|
| describe "DELETE /settings/api-keys/:id" do |
| setup [:create_user, :log_in] |
| alias Plausible.Auth.ApiKey |
|
|
| test "can't delete api key that doesn't belong to me", %{conn: conn} do |
| other_user = new_user() |
| new_site(owner: other_user) |
| team = team_of(other_user) |
|
|
| assert {:ok, %ApiKey{} = api_key} = |
| %ApiKey{user_id: other_user.id} |
| |> ApiKey.changeset(team, %{"name" => "other user's key"}) |
| |> Repo.insert() |
|
|
| conn = delete(conn, Routes.settings_path(conn, :delete_api_key, api_key.id)) |
| assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Could not find API Key to delete" |
| assert Repo.get(ApiKey, api_key.id) |
| end |
| end |
|
|
| describe "GET /settings/danger-zone" do |
| setup [:create_user, :log_in, :create_team] |
|
|
| test "without active subscription", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :danger_zone)) |
|
|
| assert html = html_response(conn, 200) |
|
|
| refute html =~ |
| "You have an active subscription. To delete your account, cancel your subscription first." |
|
|
| assert html =~ "Delete my account" |
| end |
|
|
| test "with active subscription", %{conn: conn, user: user} do |
| subscribe_to_growth_plan(user) |
| conn = get(conn, Routes.settings_path(conn, :danger_zone)) |
|
|
| assert html = html_response(conn, 200) |
|
|
| assert html =~ |
| "You have an active subscription. To delete your account, cancel your subscription first." |
|
|
| refute html =~ "Delete my account" |
| end |
|
|
| test "with a setup team", %{conn: conn, user: user} do |
| new_site(owner: user) |
|
|
| _team = |
| user |
| |> team_of() |
| |> Plausible.Teams.complete_setup() |
|
|
| conn = get(conn, Routes.settings_path(conn, :danger_zone)) |
|
|
| assert html = html_response(conn, 200) |
|
|
| assert html =~ "You're the sole owner of one or more teams" |
| refute html =~ "Delete my account" |
| end |
| end |
|
|
| @menu_items [ |
| preferences: {"Preferences", "/settings/preferences"}, |
| security: {"Security", "/settings/security"}, |
| subscription: {"Subscription", "/settings/billing/subscription"}, |
| api_keys: {"API keys", "/settings/api-keys"}, |
| danger_zone: {"Danger zone", "/settings/danger-zone"}, |
| team_general: {"General", "/settings/team/general"}, |
| sso: {"Single Sign-On", "/settings/sso/info"}, |
| team_danger_zone: {"Danger zone", "/settings/team/delete"} |
| ] |
|
|
| on_ee do |
| describe "Account settings - SSO user" do |
| setup [:create_user, :create_site, :create_team, :setup_sso, :provision_sso_user, :log_in] |
|
|
| test "shows only expected menu items", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| assert html = html_response(conn, 200) |
|
|
| expected_account_menu = [:preferences, :security, :subscription, :api_keys] |
|
|
| html |
| |> refute_unexpected_menu_items([ |
| :team_general, |
| :sso, |
| :team_danger_zone, |
| :danger_zone |
| ]) |
| |> LazyHTML.from_document() |
| |> assert_sidebar_menu(expected_account_menu) |
| |> assert_mobile_menu(expected_account_menu) |
| end |
|
|
| test "does not allow to update name in preferences", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| assert html = html_response(conn, 200) |
| refute html =~ "Change name" |
| end |
|
|
| test "does not allow to update email in security settings", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :security)) |
| assert html = html_response(conn, 200) |
| refute html =~ "Change email" |
| end |
|
|
| test "does not allow to change password in security settings", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :security)) |
| assert html = html_response(conn, 200) |
| refute html =~ "Change password" |
| end |
|
|
| test "does not allow to disable 2FA in security settings", %{conn: conn, user: user} do |
| {:ok, user, _} = Auth.TOTP.initiate(user) |
| {:ok, _, _} = Auth.TOTP.enable(user, :skip_verify) |
|
|
| conn = get(conn, Routes.settings_path(conn, :security)) |
| assert html = html_response(conn, 200) |
| assert text_of_element(html, "button[disabled]") =~ "Disable 2FA" |
| end |
| end |
| end |
|
|
| describe "Team settings" do |
| setup [:create_user, :log_in] |
|
|
| test "when no team is assigned & the user doesn't have a subscription, limited account menu is present", |
| %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| html = html_response(conn, 200) |
| refute html =~ "Team" |
|
|
| expected_account_menu = |
| if(ee?(), |
| do: [:preferences, :security, :subscription, :api_keys, :danger_zone], |
| else: [:preferences, :security, :api_keys, :danger_zone] |
| ) |
|
|
| html |
| |> refute_unexpected_menu_items( |
| if(ee?(), |
| do: [:team_general, :sso], |
| else: [:subscription, :team_general, :sso] |
| ) |
| ) |
| |> LazyHTML.from_document() |
| |> assert_sidebar_menu(expected_account_menu) |
| |> assert_mobile_menu(expected_account_menu) |
| end |
|
|
| test "when no team is assigned & the user has a subscription, the account menu shows subscription", |
| %{ |
| conn: conn, |
| user: user |
| } do |
| subscribe_to_growth_plan(user) |
|
|
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| html = html_response(conn, 200) |
|
|
| expected_account_menu = |
| if(ee?(), |
| do: [:preferences, :security, :subscription, :api_keys, :danger_zone], |
| else: [:preferences, :security, :api_keys, :danger_zone] |
| ) |
|
|
| html |
| |> refute_unexpected_menu_items( |
| if(ee?(), |
| do: [:team_general, :sso], |
| else: [:subscription, :team_general, :sso] |
| ) |
| ) |
| |> LazyHTML.from_document() |
| |> assert_sidebar_menu(expected_account_menu) |
| |> assert_mobile_menu(expected_account_menu) |
| end |
|
|
| test "when team is set up & there's no subscription, renders limited account & team menu", |
| %{ |
| conn: conn, |
| user: user |
| } do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| team = Plausible.Teams.complete_setup(team) |
| conn = set_current_team(conn, team) |
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| html = html_response(conn, 200) |
| assert html =~ ~r/Team.*#{Regex.escape(team.name)}/s |
| assert html =~ team.name |
|
|
| expected_account_menu = [ |
| :preferences, |
| :security, |
| :danger_zone |
| ] |
|
|
| expected_team_menu = |
| if(ee?(), |
| do: [:team_general, :subscription, :api_keys, :sso, :team_danger_zone], |
| else: [:team_general, :api_keys, :team_danger_zone] |
| ) |
|
|
| html |
| |> LazyHTML.from_document() |
| |> assert_sidebar_menu(expected_account_menu, expected_team_menu) |
| |> assert_mobile_menu(expected_account_menu, expected_team_menu) |
| end |
|
|
| test "when team is set up, and there's a subscription, renders account & team menu with subscription", |
| %{ |
| conn: conn, |
| user: user |
| } do |
| subscribe_to_growth_plan(user) |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| team = Plausible.Teams.complete_setup(team) |
| conn = set_current_team(conn, team) |
|
|
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| html = html_response(conn, 200) |
| assert html =~ ~r/Team.*#{Regex.escape(team.name)}/s |
| assert html =~ team.name |
|
|
| expected_account_menu = [ |
| :preferences, |
| :security, |
| :danger_zone |
| ] |
|
|
| expected_team_menu = |
| if(ee?(), |
| do: [ |
| :team_general, |
| :subscription, |
| :api_keys, |
| :sso, |
| :team_danger_zone |
| ], |
| else: [:team_general, :api_keys, :team_danger_zone] |
| ) |
|
|
| html |
| |> LazyHTML.from_document() |
| |> assert_sidebar_menu(expected_account_menu, expected_team_menu) |
| |> assert_mobile_menu(expected_account_menu, expected_team_menu) |
| end |
|
|
| test "does not render team settings, when team not set up", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| html = html_response(conn, 200) |
| refute html =~ ~r/Team.*#{Regex.escape(team.name)}/s |
| refute html =~ team.name |
| end |
|
|
| test "GET /settings/team/general", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| team = Plausible.Teams.complete_setup(team) |
| conn = set_current_team(conn, team) |
| conn = get(conn, Routes.settings_path(conn, :team_general)) |
| html = html_response(conn, 200) |
| assert html =~ "Team name" |
| assert html =~ "Change the name of your team" |
| assert text_of_attr(html, "input#team_name", "value") == team.name |
| end |
|
|
| test "POST /settings/team/general/name", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_team_name), %{ |
| "team" => %{"name" => "New name"} |
| }) |
|
|
| assert redirected_to(conn, 302) == |
| Routes.settings_path(conn, :team_general) <> "#update-name" |
|
|
| assert Repo.reload!(team).name == "New name" |
| end |
|
|
| test "POST /settings/team/general/name - changeset error", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| team = Plausible.Teams.complete_setup(team) |
| conn = set_current_team(conn, team) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :update_team_name), %{ |
| "team" => %{"name" => ""} |
| }) |
|
|
| assert text(html_response(conn, 200)) =~ "can't be blank" |
| end |
|
|
| test "POST /settings/team/leave", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| team = Plausible.Teams.complete_setup(team) |
| conn = set_current_team(conn, team) |
| add_member(team, role: :owner) |
|
|
| conn = post(conn, Routes.settings_path(conn, :leave_team)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: "none") |
| assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ "You have left" |
| end |
|
|
| test "POST /settings/team/leave - only owner", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| team = Plausible.Teams.complete_setup(team) |
| conn = set_current_team(conn, team) |
|
|
| conn = post(conn, Routes.settings_path(conn, :leave_team)) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
| assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "You can't leave" |
| end |
|
|
| test "GET /settings/team/delete - without active subscription", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
|
|
| team = |
| team |
| |> Plausible.Teams.complete_setup() |
| |> Ecto.Changeset.change(name: "Foo Crew") |
| |> Repo.update!() |
|
|
| conn = set_current_team(conn, team) |
| conn = get(conn, Routes.settings_path(conn, :team_danger_zone)) |
|
|
| assert html = html_response(conn, 200) |
|
|
| refute html =~ "The team cannot be deleted because it has an active subscription" |
| assert html =~ "Delete \"Foo Crew\"" |
| end |
|
|
| test "GET /settings/team/delete - with active subscription", %{conn: conn, user: user} do |
| user = subscribe_to_growth_plan(user) |
| team = team_of(user) |
|
|
| team = |
| team |
| |> Plausible.Teams.complete_setup() |
| |> Ecto.Changeset.change(name: "Foo Crew") |
| |> Repo.update!() |
|
|
| conn = set_current_team(conn, team) |
| conn = get(conn, Routes.settings_path(conn, :team_danger_zone)) |
|
|
| assert html = html_response(conn, 200) |
|
|
| assert html =~ "The team cannot be deleted because it has an active subscription" |
| refute html =~ "Delete \"Foo Crew\"" |
| end |
|
|
| test "GET /settings/team/delete - permission denied", %{conn: conn, user: user} do |
| another_user = new_user() |> subscribe_to_growth_plan() |
| team = team_of(another_user) |
| add_member(team, user: user, role: :admin) |
| conn = set_current_team(conn, team) |
| conn = get(conn, Routes.settings_path(conn, :team_danger_zone)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
| end |
|
|
| test "DELETE /settings/team/delete - deletes a team", %{conn: conn, user: user} do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
|
|
| team = |
| team |
| |> Plausible.Teams.complete_setup() |
| |> Ecto.Changeset.change(name: "Foo Crew") |
| |> Repo.update!() |
|
|
| conn = set_current_team(conn, team) |
| conn = delete(conn, Routes.settings_path(conn, :delete_team)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index, __team: "none") |
|
|
| assert Phoenix.Flash.get(conn.assigns.flash, :success) == "Team \"Foo Crew\" deleted" |
| end |
|
|
| test "DELETE /settings/team/delete - fails when there's an active subscription", %{ |
| conn: conn, |
| user: user |
| } do |
| subscribe_to_growth_plan(user) |
| team = team_of(user) |
|
|
| team = |
| team |
| |> Plausible.Teams.complete_setup() |
| |> Ecto.Changeset.change(name: "Foo Crew") |
| |> Repo.update!() |
|
|
| conn = set_current_team(conn, team) |
| conn = delete(conn, Routes.settings_path(conn, :delete_team)) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_danger_zone) |
|
|
| assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Team has an active subscription" |
| end |
|
|
| test "DELETE /settings/team/delete - permission denied", %{conn: conn, user: user} do |
| another_user = new_user() |> subscribe_to_growth_plan() |
| team = team_of(another_user) |
| add_member(team, user: user, role: :admin) |
| conn = set_current_team(conn, team) |
| conn = delete(conn, Routes.settings_path(conn, :delete_team)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
| end |
| end |
|
|
| describe "POST /team/force_2fa/enable" do |
| setup [:create_user, :log_in, :create_team, :setup_team] |
|
|
| test "enables enforcing 2FA", %{conn: conn, team: team} do |
| refute team.policy.force_2fa |
|
|
| conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
|
|
| assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ |
| "2FA is now required for all team members" |
|
|
| assert Repo.reload!(team).policy.force_2fa |
| end |
|
|
| on_ee do |
| test "adds entry to audit log", %{conn: conn, team: team, user: user} do |
| conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
|
|
| assert_matches [ |
| %{ |
| name: "force_2fa_enabled", |
| user_id: ^user.id, |
| team_id: ^team.id, |
| actor_type: :user, |
| change: %{ |
| "before" => %{"policy" => %{"force_2fa" => false}}, |
| "after" => %{"policy" => %{"after" => %{"force_2fa" => true}}} |
| } |
| } |
| ] = |
| Plausible.Audit.list_entries( |
| entity: "Plausible.Teams.Team", |
| entity_id: "#{team.id}" |
| ) |
| end |
| end |
|
|
| test "sends e-mail to all other team members", %{conn: conn, team: team, user: user} do |
| site = new_site(team: team) |
|
|
| member1 = add_member(team, role: :viewer) |
| member2 = add_member(team, role: :owner) |
|
|
| member_with_2fa = add_member(team, role: :editor) |
|
|
| |
| {:ok, member_with_2fa, _} = Plausible.Auth.TOTP.initiate(member_with_2fa) |
| code = NimbleTOTP.verification_code(member_with_2fa.totp_secret) |
| {:ok, _member_with_2fa, _} = Plausible.Auth.TOTP.enable(member_with_2fa, code) |
|
|
| guest = add_guest(site, role: :viewer) |
|
|
| conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
|
|
| |
| |
| |
| Enum.reduce(1..2, [member1.email, member2.email], fn _, emails -> |
| assert assert_delivered_email_matches(%{ |
| subject: "Your team now requires 2FA", |
| to: [{_, email}] |
| }) |
|
|
| assert email in emails |
|
|
| List.delete(emails, email) |
| end) |
|
|
| |
| refute_email_delivered_with( |
| subject: "Your team now requires 2FA", |
| to: [nil: member_with_2fa.email] |
| ) |
|
|
| |
| refute_email_delivered_with( |
| subject: "Your team now requires 2FA", |
| to: [nil: guest.email] |
| ) |
|
|
| |
| refute_email_delivered_with( |
| subject: "Your team now requires 2FA", |
| to: [nil: user.email] |
| ) |
| end |
|
|
| test "is idempotent", %{conn: conn, user: user, team: team} do |
| {:ok, team} = Plausible.Teams.disable_force_2fa(team, user, "password") |
|
|
| conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
| assert Repo.reload!(team).policy.force_2fa |
| end |
|
|
| test "can't be enabled by anyone other than owner", %{conn: conn, team: team} do |
| admin = add_member(team, role: :admin) |
| {:ok, ctx} = log_in(%{conn: conn, user: admin}) |
|
|
| conn = |
| ctx |
| |> Keyword.fetch!(:conn) |
| |> set_current_team(team) |
|
|
| conn = post(conn, Routes.settings_path(conn, :enable_team_force_2fa)) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
| refute Repo.reload!(team).policy.force_2fa |
| end |
| end |
|
|
| describe "POST /team/force_2fa/disable" do |
| setup [:create_user, :log_in, :create_team, :setup_team] |
|
|
| setup %{user: user} do |
| |
| {:ok, user, _} = Plausible.Auth.TOTP.initiate(user) |
| code = NimbleTOTP.verification_code(user.totp_secret) |
| {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, code) |
|
|
| {:ok, user: user} |
| end |
|
|
| test "disables enforcing 2FA", %{conn: conn, team: team, user: user} do |
| {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ |
| "password" => "password" |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
|
|
| assert Phoenix.Flash.get(conn.assigns.flash, :success) =~ |
| "2FA is no longer enforced for team members" |
|
|
| refute Repo.reload!(team).policy.force_2fa |
| end |
|
|
| on_ee do |
| test "adds entry to audit log", %{conn: conn, user: user, team: team} do |
| {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ |
| "password" => "password" |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
|
|
| assert_matches [ |
| %{ |
| name: "force_2fa_disabled", |
| user_id: ^user.id, |
| team_id: ^team.id, |
| actor_type: :user, |
| change: %{ |
| "before" => %{"policy" => %{"force_2fa" => true}}, |
| "after" => %{"policy" => %{"after" => %{"force_2fa" => false}}} |
| } |
| }, |
| _ |
| ] = |
| Plausible.Audit.list_entries( |
| entity: "Plausible.Teams.Team", |
| entity_id: "#{team.id}" |
| ) |
| end |
| end |
|
|
| test "is idempotent", %{conn: conn, team: team} do |
| conn = |
| post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ |
| "password" => "password" |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
| refute Repo.reload!(team).policy.force_2fa |
| end |
|
|
| test "returns error on invalid password", %{conn: conn} do |
| conn = |
| post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{"password" => "invalid"}) |
|
|
| assert redirected_to(conn, 302) == Routes.settings_path(conn, :team_general) |
| assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "Incorrect password provided" |
| end |
|
|
| test "can't be disabled by anyone other than owner", %{conn: conn, team: team, user: user} do |
| {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) |
|
|
| admin = add_member(team, role: :admin) |
|
|
| |
| {:ok, admin, _} = Plausible.Auth.TOTP.initiate(admin) |
| code = NimbleTOTP.verification_code(admin.totp_secret) |
| {:ok, _admin, _} = Plausible.Auth.TOTP.enable(admin, code) |
|
|
| {:ok, ctx} = log_in(%{conn: conn, user: admin}) |
|
|
| conn = |
| ctx |
| |> Keyword.fetch!(:conn) |
| |> set_current_team(team) |
|
|
| conn = |
| post(conn, Routes.settings_path(conn, :disable_team_force_2fa), %{ |
| "password" => "password" |
| }) |
|
|
| assert redirected_to(conn, 302) == Routes.site_path(conn, :index) |
| assert Repo.reload!(team).policy.force_2fa |
| end |
| end |
|
|
| describe "GET /settings/team/general - enforce 2FA disabled" do |
| setup [:create_user, :log_in, :create_team, :setup_team] |
|
|
| test "is visible to owner", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :team_general)) |
| html = html_response(conn, 200) |
|
|
| assert element_exists?(html, "div#enable-force-2fa") |
| refute element_exists?(html, "div#disable-force-2fa") |
| end |
|
|
| test "is not visible to anyone other than owner", %{conn: conn, team: team} do |
| admin = add_member(team, role: :admin) |
| {:ok, ctx} = log_in(%{conn: conn, user: admin}) |
|
|
| conn = |
| ctx |
| |> Keyword.fetch!(:conn) |
| |> set_current_team(team) |
|
|
| conn = get(conn, Routes.settings_path(conn, :team_general)) |
| html = html_response(conn, 200) |
|
|
| refute element_exists?(html, "div#force-2fa") |
| refute element_exists?(html, "div#enable-force-2fa") |
| refute element_exists?(html, "div#disable-force-2fa") |
| end |
| end |
|
|
| describe "GET /settings/team/general - enforce 2FA enabled" do |
| setup [:create_user, :log_in, :create_team, :setup_team] |
|
|
| setup %{user: user, team: team} do |
| |
| {:ok, user, _} = Plausible.Auth.TOTP.initiate(user) |
| code = NimbleTOTP.verification_code(user.totp_secret) |
| {:ok, _user, _} = Plausible.Auth.TOTP.enable(user, code) |
|
|
| {:ok, team} = Plausible.Teams.enable_force_2fa(team, user) |
|
|
| {:ok, user: user, team: team} |
| end |
|
|
| test "is visible to owner", %{conn: conn} do |
| conn = get(conn, Routes.settings_path(conn, :team_general)) |
| html = html_response(conn, 200) |
|
|
| refute element_exists?(html, "div#enable-force-2fa") |
| assert element_exists?(html, "div#disable-force-2fa") |
| end |
|
|
| test "is not visible to anyone other than owner", %{conn: conn, team: team} do |
| admin = add_member(team, role: :admin) |
|
|
| |
| {:ok, admin, _} = Plausible.Auth.TOTP.initiate(admin) |
| code = NimbleTOTP.verification_code(admin.totp_secret) |
| {:ok, _admin, _} = Plausible.Auth.TOTP.enable(admin, code) |
|
|
| {:ok, ctx} = log_in(%{conn: conn, user: admin}) |
|
|
| conn = |
| ctx |
| |> Keyword.fetch!(:conn) |
| |> set_current_team(team) |
|
|
| conn = get(conn, Routes.settings_path(conn, :team_general)) |
| html = html_response(conn, 200) |
|
|
| refute element_exists?(html, "div#force-2fa") |
| refute element_exists?(html, "div#enable-force-2fa") |
| refute element_exists?(html, "div#disable-force-2fa") |
| end |
| end |
|
|
| describe "account dropdown menu (_header.html)" do |
| setup [:create_user, :log_in] |
|
|
| test "renders the 'Create a team' option", %{conn: conn, user: user} do |
| subscribe_to_growth_plan(user) |
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| html = html_response(conn, 200) |
| assert text_of_element(html, ~s/[data-test="create-a-team-cta"]/) == "Create a team" |
| end |
|
|
| test "does not render the 'Create a team' option if a team is already set up", %{ |
| conn: conn, |
| user: user |
| } do |
| {:ok, team} = Plausible.Teams.get_or_create(user) |
| Plausible.Teams.complete_setup(team) |
| conn = get(conn, Routes.settings_path(conn, :preferences)) |
| html = html_response(conn, 200) |
| refute element_exists?(html, ~s/[data-test="create-a-team-cta"]/) |
| end |
| end |
|
|
| defp configure_enterprise_plan(user, attrs \\ []) do |
| subscribe_to_enterprise_plan( |
| user, |
| Keyword.merge( |
| [ |
| paddle_plan_id: @configured_enterprise_plan_paddle_plan_id, |
| monthly_pageview_limit: 20_000_000, |
| billing_interval: :yearly |
| ], |
| attrs |
| ) |
| ) |
| end |
|
|
| defp assert_sidebar_menu(document, ordered_account_menu_keys, ordered_team_menu_keys \\ []) do |
| ordered_menu_keys = Enum.concat(ordered_account_menu_keys, ordered_team_menu_keys) |
| assert get_expected_menu(ordered_menu_keys) == get_sidebar_menu_items(document) |
|
|
| document |
| end |
|
|
| defp assert_mobile_menu( |
| document, |
| ordered_account_menu_keys, |
| ordered_team_menu_keys \\ [] |
| ) do |
| expected_account_items = |
| ordered_account_menu_keys |
| |> get_expected_menu() |
| |> Enum.map(fn {text, "/settings/" <> path_fragment} -> |
| {"Account: #{text}", path_fragment} |
| end) |
|
|
| expected_team_items = |
| ordered_team_menu_keys |
| |> get_expected_menu() |
| |> Enum.map(fn {text, "/settings/" <> path_fragment} -> |
| {"Team: #{text}", path_fragment} |
| end) |
|
|
| assert Enum.concat( |
| expected_account_items, |
| expected_team_items |
| ) == |
| get_mobile_menu_options(document) |
|
|
| document |
| end |
|
|
| defp get_expected_menu(ordered_menu_keys) do |
| ordered_menu_keys |
| |> Keyword.new(&{&1, nil}) |
| |> Keyword.intersect(@menu_items) |
| |> Keyword.values() |
| end |
|
|
| defp refute_unexpected_menu_items(html, unexpected_menu_keys) do |
| refuted_menu_items = @menu_items |> Keyword.take(unexpected_menu_keys) |> Keyword.values() |
|
|
| for {text, link} <- refuted_menu_items do |
| refute html =~ text |
| refute html =~ link |
| end |
|
|
| html |
| end |
|
|
| defp get_mobile_menu_options(document) do |
| LazyHTML.query(document, "[data-testid='mobile-nav-dropdown'] option") |
| |> Enum.map(&parse_option/1) |
| end |
|
|
| defp parse_option(option), |
| do: {LazyHTML.text(option), LazyHTML.attribute(option, "value") |> List.first()} |
|
|
| defp get_sidebar_menu_items(document) do |
| LazyHTML.query(document, "[data-testid='settings-sidebar'] a") |
| |> Enum.map(&parse_link/1) |
| end |
|
|
| defp parse_link(link), |
| do: {LazyHTML.text(link) |> String.trim(), LazyHTML.attribute(link, "href") |> List.first()} |
| end |
|
|