defmodule PlausibleWeb.SSOControllerTest do use PlausibleWeb.ConnCase, async: true @moduletag :ee_only on_ee do 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 =~ "Sign in with SSO" 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 =~ "Sign in with SSO" 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, "We couldn't find a Single Sign-On account for that email.") conn = get(conn, Routes.sso_path(conn, :login_form, return_to: "/sites")) assert html = html_response(conn, 200) assert html =~ htmlize_quotes("We couldn't find a Single Sign-On account for that 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) == "We couldn't find a Single Sign-On account for that 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) == "We couldn't find a Single Sign-On account for that 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 required" assert html =~ "has turned off email and password logins" assert html =~ "To access this team," assert html =~ "sign out" assert html =~ "and sign in with SSO" 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 required" assert html =~ "To access this team, you must be added 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 required" assert html =~ "To access this team, you must leave all other teams first" 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 required" assert html =~ "To sign in with SSO, you must leave all other teams first" assert html =~ "Sign in with email and password" assert html =~ "to resolve the issue" 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 required" assert html =~ "To access this team, remove or transfer all sites under" assert html =~ "My Personal Sites" assert html =~ "cancel its subscription if active" 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 required" assert html =~ "To sign in with SSO, remove or transfer all sites under" assert html =~ "My Personal Sites" assert html =~ "cancel its subscription if active" assert html =~ "Sign in with email and password" assert html =~ "to resolve the issue" 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