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.() # for a past_due subscription subscription = subscription |> Plausible.Billing.Subscription.changeset(%{status: :past_due}) |> Repo.update!() conn |> get(Routes.settings_path(conn, :subscription)) |> html_response(200) |> assert_cycles_rendered.() # for a deleted (but not expired) subscription 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 # for a trial user 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.() # for a paused subscription subscription = subscription |> Plausible.Billing.Subscription.changeset(%{status: :paused}) |> Repo.update!() conn |> get(Routes.settings_path(conn, :subscription)) |> html_response(200) |> assert_usage.() # for a free_10k subscription (without a `last_bill_date`) 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) # enable 2FA {: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) # The email come in order in which they are sent. # As the logic sending them does not force any order, # we have to match them in order-independent way. 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) # member with 2FA already enabled is not notified refute_email_delivered_with( subject: "Your team now requires 2FA", to: [nil: member_with_2fa.email] ) # guests are not notified because they are not affected refute_email_delivered_with( subject: "Your team now requires 2FA", to: [nil: guest.email] ) # the user enabling the enforcement is not notified 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 # enable 2FA {: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) # enable TOTP for 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 # enable 2FA {: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) # enable TOTP for 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