| 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 |
|
|