473 lines
15 KiB
Elixir
473 lines
15 KiB
Elixir
defmodule PlausibleWeb.SSOControllerTest do
|
|
use PlausibleWeb.ConnCase, async: true
|
|
use Plausible
|
|
|
|
@moduletag :ee_only
|
|
|
|
on_ee do
|
|
import Plausible.Teams.Test
|
|
import Plausible.Test.Support.HTML
|
|
|
|
alias Plausible.Auth
|
|
alias Plausible.Auth.SSO
|
|
alias Plausible.Repo
|
|
|
|
setup do
|
|
owner = new_user()
|
|
team = new_site(owner: owner).team |> Plausible.Teams.complete_setup()
|
|
integration = SSO.initiate_saml_integration(team)
|
|
domain = "example-#{Enum.random(1..10_000)}.com"
|
|
|
|
{:ok, sso_domain} = SSO.Domains.add(integration, domain)
|
|
sso_domain = SSO.Domains.verify(sso_domain, skip_checks?: true)
|
|
|
|
{:ok,
|
|
owner: owner, team: team, integration: integration, domain: domain, sso_domain: sso_domain}
|
|
end
|
|
|
|
describe "settings item visibility" do
|
|
test "sso team settings item renders when SSO feature plan is added", %{conn: conn} do
|
|
user =
|
|
new_user() |> subscribe_to_enterprise_plan(features: [Plausible.Billing.Feature.SSO])
|
|
|
|
team = new_site(owner: user).team |> Plausible.Teams.complete_setup()
|
|
{:ok, conn: conn} = log_in(%{conn: conn, user: user})
|
|
conn = set_current_team(conn, team)
|
|
|
|
conn = get(conn, Routes.settings_path(conn, :team_general))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "/settings/sso/general"
|
|
refute html =~ "/settings/sso/info"
|
|
end
|
|
|
|
test "sso team settings item is hidden when there's no SSO plan feature", %{conn: conn} do
|
|
user = new_user()
|
|
team = new_site(owner: user).team |> Plausible.Teams.complete_setup()
|
|
{:ok, conn: conn} = log_in(%{conn: conn, user: user})
|
|
conn = set_current_team(conn, team)
|
|
|
|
conn = get(conn, Routes.settings_path(conn, :team_general))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
refute html =~ "/settings/sso/general"
|
|
assert html =~ "/settings/sso/info"
|
|
end
|
|
end
|
|
|
|
describe "login_form/2" do
|
|
test "renders login view", %{conn: conn} do
|
|
conn = get(conn, Routes.sso_path(conn, :login_form, prefer: "sso"))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Enter your Single Sign-On email"
|
|
assert element_exists?(html, "input[name=email]")
|
|
assert text_of_attr(html, ~s|input[name="return_to"]|, "value") == nil
|
|
end
|
|
|
|
test "renders autosubmit js snippet when instructed", %{conn: conn} do
|
|
conn =
|
|
get(
|
|
conn,
|
|
Routes.sso_path(conn, :login_form,
|
|
prefer: "sso",
|
|
email: "user@example.com",
|
|
autosubmit: true
|
|
)
|
|
)
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Enter your Single Sign-On email"
|
|
assert text_of_attr(html, "input[name=email]", "value") == "user@example.com"
|
|
assert html =~ ~s|document.getElementById("sso-login-form").submit()|
|
|
end
|
|
|
|
test "passes return_to parameter to form", %{conn: conn} do
|
|
conn = get(conn, Routes.sso_path(conn, :login_form, return_to: "/sites", prefer: "sso"))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert text_of_attr(html, "input[name=return_to]", "value") == "/sites"
|
|
end
|
|
|
|
test "renders error if provided in login_error flash message", %{conn: conn} do
|
|
conn =
|
|
conn
|
|
|> init_session()
|
|
|> fetch_session()
|
|
|> fetch_flash()
|
|
|> put_flash(:login_error, "Wrong email.")
|
|
|
|
conn = get(conn, Routes.sso_path(conn, :login_form, return_to: "/sites"))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Wrong email."
|
|
assert element_exists?(html, "input[name=email]")
|
|
assert text_of_attr(html, "input[name=return_to]", "value") == "/sites"
|
|
end
|
|
end
|
|
|
|
describe "login/2" do
|
|
test "redirects to SAML signin on matching integration", %{
|
|
conn: conn,
|
|
domain: domain,
|
|
integration: integration
|
|
} do
|
|
email = "paul@" <> domain
|
|
|
|
conn = post(conn, Routes.sso_path(conn, :login), %{"email" => email})
|
|
|
|
assert redirected_to(conn, 302) ==
|
|
Routes.sso_path(conn, :saml_signin, integration.identifier,
|
|
email: email,
|
|
return_to: ""
|
|
)
|
|
end
|
|
|
|
test "passes redirect path if provided", %{
|
|
conn: conn,
|
|
domain: domain,
|
|
integration: integration
|
|
} do
|
|
email = "claire@" <> domain
|
|
|
|
conn =
|
|
post(conn, Routes.sso_path(conn, :login), %{"email" => email, "return_to" => "/sites"})
|
|
|
|
assert redirected_to(conn, 302) ==
|
|
Routes.sso_path(conn, :saml_signin, integration.identifier,
|
|
email: email,
|
|
return_to: "/sites"
|
|
)
|
|
end
|
|
|
|
test "renders login form with error on no matching integration", %{conn: conn} do
|
|
conn =
|
|
post(conn, Routes.sso_path(conn, :login), %{
|
|
"email" => "nomatch@example.com",
|
|
"return_to" => "/sites"
|
|
})
|
|
|
|
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form)
|
|
|
|
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Wrong email."
|
|
end
|
|
|
|
test "limits login attempts to 5 per minute", %{conn: conn} do
|
|
conn = put_req_header(conn, "x-forwarded-for", "1.2.3.9")
|
|
email = "invalid@example.com"
|
|
|
|
response =
|
|
eventually(
|
|
fn ->
|
|
Enum.each(1..5, fn _ ->
|
|
post(conn, Routes.sso_path(conn, :login), %{"email" => email})
|
|
end)
|
|
|
|
conn = post(conn, Routes.sso_path(conn, :login), %{"email" => email})
|
|
|
|
{conn.status == 429, conn}
|
|
end,
|
|
500
|
|
)
|
|
|
|
assert html_response(response, 429) =~ "Too many login attempts"
|
|
end
|
|
end
|
|
|
|
describe "saml_signin/2 (fake SAML)" do
|
|
test "renders autosubmitted form", %{conn: conn, domain: domain, integration: integration} do
|
|
email = "jesper@" <> domain
|
|
|
|
conn =
|
|
get(
|
|
conn,
|
|
Routes.sso_path(conn, :saml_signin, integration.identifier,
|
|
email: email,
|
|
return_to: "/sites"
|
|
)
|
|
)
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Processing Single Sign-On request..."
|
|
|
|
assert text_of_attr(html, "form#sso-req-form", "action") ==
|
|
Routes.sso_path(conn, :saml_consume, integration.identifier)
|
|
|
|
assert text_of_attr(html, "input[name=email]", "value") == email
|
|
assert text_of_attr(html, "input[name=return_to]", "value") == "/sites"
|
|
end
|
|
end
|
|
|
|
describe "saml_consume/2 (fake SAML)" do
|
|
test "provisions identity for new user", %{
|
|
conn: conn,
|
|
domain: domain,
|
|
integration: integration
|
|
} do
|
|
email = "dana.lake@" <> domain
|
|
|
|
conn =
|
|
post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), %{
|
|
"email" => email,
|
|
"return_to" => "/sites"
|
|
})
|
|
|
|
assert redirected_to(conn, 302) == "/sites"
|
|
|
|
assert %{sessions: [sso_session]} =
|
|
user = Repo.get_by(Plausible.Auth.User, email: email) |> Repo.preload(:sessions)
|
|
|
|
assert user.type == :sso
|
|
assert user.email == email
|
|
assert user.name == "Dana Lake"
|
|
assert get_session(conn, :user_token) == sso_session.token
|
|
end
|
|
|
|
test "provisions identity for existing user", %{
|
|
conn: conn,
|
|
team: team,
|
|
domain: domain,
|
|
integration: integration
|
|
} do
|
|
email = "dana@" <> domain
|
|
|
|
existing_user = new_user(name: "Dana Woodworth", email: email)
|
|
|
|
add_member(team, user: existing_user, role: :admin)
|
|
|
|
conn =
|
|
post(conn, Routes.sso_path(conn, :saml_consume, integration.identifier), %{
|
|
"email" => email,
|
|
"return_to" => "/sites"
|
|
})
|
|
|
|
assert redirected_to(conn, 302) == "/sites"
|
|
|
|
assert %{sessions: [sso_session]} =
|
|
user = Repo.get_by(Plausible.Auth.User, email: email) |> Repo.preload(:sessions)
|
|
|
|
assert user.type == :sso
|
|
assert user.email == email
|
|
assert user.name == "Dana Woodworth"
|
|
assert get_session(conn, :user_token) == sso_session.token
|
|
end
|
|
|
|
test "redirects to login when no matching integration found", %{conn: conn} do
|
|
conn =
|
|
post(conn, Routes.sso_path(conn, :saml_consume, Ecto.UUID.generate()), %{
|
|
"email" => "missed@example.com",
|
|
"return_to" => "/sites"
|
|
})
|
|
|
|
assert redirected_to(conn, 302) == Routes.sso_path(conn, :login_form, return_to: "/sites")
|
|
|
|
assert Phoenix.Flash.get(conn.assigns.flash, :login_error) == "Wrong email."
|
|
end
|
|
end
|
|
|
|
describe "sso_settings/2" do
|
|
setup [:create_user, :log_in, :create_team]
|
|
|
|
test "redirects when team is not setup", %{conn: conn, team: team} do
|
|
conn = set_current_team(conn, team)
|
|
conn = get(conn, Routes.sso_path(conn, :sso_settings))
|
|
|
|
assert redirected_to(conn, 302) == "/sites"
|
|
end
|
|
|
|
test "redirects when team lacks SSO plan feature", %{conn: conn, team: team} do
|
|
team = Plausible.Teams.complete_setup(team)
|
|
conn = set_current_team(conn, team)
|
|
conn = get(conn, Routes.sso_path(conn, :sso_settings))
|
|
|
|
assert redirected_to(conn, 302) == "/sites"
|
|
end
|
|
|
|
test "renders when team has SSO plan feature", %{conn: conn, team: team, user: user} do
|
|
user |> subscribe_to_enterprise_plan(features: [Plausible.Billing.Feature.SSO])
|
|
team = Plausible.Teams.complete_setup(team)
|
|
conn = set_current_team(conn, team)
|
|
conn = get(conn, Routes.sso_path(conn, :sso_settings))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Configure and manage Single Sign-On for your team"
|
|
end
|
|
end
|
|
|
|
describe "provision_notice/2" do
|
|
test "renders the notice", %{conn: conn} do
|
|
conn = get(conn, Routes.sso_path(conn, :provision_notice))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Single Sign-On enforcement"
|
|
assert html =~ "To access this team, you must first"
|
|
assert html =~ "log out"
|
|
assert html =~ "and log in as SSO user"
|
|
end
|
|
end
|
|
|
|
describe "provision_issue/2" do
|
|
test "renders issue for not_a_member", %{conn: conn} do
|
|
conn = get(conn, Routes.sso_path(conn, :provision_issue, issue: "not_a_member"))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Single Sign-On enforcement"
|
|
assert html =~ "To access this team, you must join as a team member first"
|
|
end
|
|
|
|
test "renders issue for multiple_memberships", %{conn: conn} do
|
|
conn = get(conn, Routes.sso_path(conn, :provision_issue, issue: "multiple_memberships"))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Single Sign-On enforcement"
|
|
assert html =~ "To access this team, you must first leave all other teams"
|
|
end
|
|
|
|
test "renders issue for multiple_memberships_noforce", %{conn: conn} do
|
|
conn =
|
|
get(
|
|
conn,
|
|
Routes.sso_path(conn, :provision_issue, issue: "multiple_memberships_noforce")
|
|
)
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Single Sign-On enforcement"
|
|
assert html =~ "To log in as an SSO user, you must first leave all other teams"
|
|
|
|
assert html =~ "Log in"
|
|
assert html =~ "with your email and password"
|
|
end
|
|
|
|
test "renders issue for active_personal_team", %{conn: conn} do
|
|
conn = get(conn, Routes.sso_path(conn, :provision_issue, issue: "active_personal_team"))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Single Sign-On enforcement"
|
|
assert html =~ "To access this team, you must either remove or transfer all sites"
|
|
end
|
|
|
|
test "renders issue for active_personal_team_noforce", %{conn: conn} do
|
|
conn =
|
|
get(
|
|
conn,
|
|
Routes.sso_path(conn, :provision_issue, issue: "active_personal_team_noforce")
|
|
)
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "Single Sign-On enforcement"
|
|
|
|
assert html =~
|
|
"To log in as an SSO user, you must either remove or transfer all sites"
|
|
|
|
assert html =~ "Log in"
|
|
assert html =~ "with your email and password"
|
|
end
|
|
end
|
|
|
|
describe "team_sessions/2" do
|
|
setup %{conn: conn, team: team, owner: owner} do
|
|
%{conn: conn} =
|
|
%{conn: conn, user: owner}
|
|
|> setup_do(&log_in/1)
|
|
|
|
conn = set_current_team(conn, team)
|
|
|
|
{:ok, conn: conn}
|
|
end
|
|
|
|
test "lists SSO sessions", %{conn: conn, domain: domain, integration: integration} do
|
|
now = NaiveDateTime.utc_now(:second)
|
|
|
|
%{user: user1} =
|
|
%{user: %{name: "Frank Rubin", email: "frank@" <> domain}, sso_integration: integration}
|
|
|> setup_do(&provision_sso_user/1)
|
|
|
|
Auth.UserSessions.create!(user1, "Device 1", now: NaiveDateTime.shift(now, hour: -3))
|
|
|
|
%{user: user2} =
|
|
%{
|
|
user: %{name: "Grace Holmes", email: "grace@" <> domain},
|
|
sso_integration: integration
|
|
}
|
|
|> setup_do(&provision_sso_user/1)
|
|
|
|
Auth.UserSessions.create!(user2, "Device 2")
|
|
Auth.UserSessions.create!(user2, "Device 3", now: NaiveDateTime.shift(now, hour: -6))
|
|
|
|
%{user: user3} =
|
|
%{user: %{name: "Kate Loselet", email: "kate@" <> domain}, sso_integration: integration}
|
|
|> setup_do(&provision_sso_user/1)
|
|
|
|
Auth.UserSessions.create!(user3, "Device 4", now: NaiveDateTime.shift(now, hour: -2))
|
|
|
|
conn = get(conn, Routes.sso_path(conn, :team_sessions))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert ["Grace Holmes", "Kate Loselet", "Frank Rubin", "Grace Holmes"] =
|
|
find(html, "table#sso-sessions-list tr td:nth-of-type(1)")
|
|
|> Enum.map(&text/1)
|
|
|
|
assert ["Device 2", "Device 4", "Device 1", "Device 3"] =
|
|
find(html, "table#sso-sessions-list tr td:nth-of-type(2)")
|
|
|> Enum.map(&text/1)
|
|
end
|
|
|
|
test "shows empty state when there are no sessions", %{conn: conn} do
|
|
conn = get(conn, Routes.sso_path(conn, :team_sessions))
|
|
|
|
assert html = html_response(conn, 200)
|
|
|
|
assert html =~ "There are currently no active SSO sessions"
|
|
end
|
|
end
|
|
|
|
describe "delete_session/2" do
|
|
setup %{conn: conn, team: team, owner: owner} do
|
|
%{conn: conn} =
|
|
%{conn: conn, user: owner}
|
|
|> setup_do(&log_in/1)
|
|
|
|
conn = set_current_team(conn, team)
|
|
|
|
{:ok, conn: conn}
|
|
end
|
|
|
|
test "revokes session and redirects back to sessions list", %{
|
|
conn: conn,
|
|
domain: domain,
|
|
integration: integration
|
|
} do
|
|
%{user: user} =
|
|
%{user: %{name: "Frank Rubin", email: "frank@" <> domain}, sso_integration: integration}
|
|
|> setup_do(&provision_sso_user/1)
|
|
|
|
session = Auth.UserSessions.create!(user, "Unknown")
|
|
|
|
conn = delete(conn, Routes.sso_path(conn, :delete_session, session.id))
|
|
|
|
assert redirected_to(conn, 302) == Routes.sso_path(conn, :team_sessions)
|
|
|
|
assert Phoenix.Flash.get(conn.assigns.flash, :success) ==
|
|
"Session logged out successfully"
|
|
|
|
refute Repo.reload(session)
|
|
end
|
|
end
|
|
end
|
|
end
|