analytics / test /plausible_web /controllers /settings_controller_test.exs
Leon4gr45's picture
Upload folder using huggingface_hub (part 6)
6778ee0 verified
Raw
History Blame Contribute Delete
69 kB
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