defmodule Plausible.Auth.UserSessionsTest do use Plausible.DataCase, async: true import Phoenix.ChannelTest alias Plausible.Auth alias Plausible.Auth.UserSessions alias Plausible.Repo describe "list_for_user/2" do test "lists user sessions" do user = insert(:user) now = NaiveDateTime.utc_now(:second) thirty_minutes_ago = NaiveDateTime.shift(now, minute: -30) ten_hours_ago = NaiveDateTime.shift(now, hour: -10) ten_days_ago = NaiveDateTime.shift(now, day: -10) twenty_days_ago = NaiveDateTime.shift(now, day: -20) recent_session = insert_session(user, "Recent Device", thirty_minutes_ago) old_session = insert_session(user, "Old Device", ten_hours_ago) older_session = insert_session(user, "Older Device", ten_days_ago) _expired_session = insert_session(user, "Expired Device", twenty_days_ago) _rogue_session = insert_session(insert(:user), "Unrelated device", now) assert [session1, session2, session3] = UserSessions.list_for_user(user, now) assert session1.id == recent_session.id assert session2.id == old_session.id assert session3.id == older_session.id end end describe "count_for_users/2" do test "counts user sessions" do now = NaiveDateTime.utc_now(:second) thirty_minutes_ago = NaiveDateTime.shift(now, minute: -30) u1 = insert(:user) u2 = insert(:user) u3 = insert(:user) insert_session(u1, "Recent Device", thirty_minutes_ago) insert_session(u1, "Recent Device 2", thirty_minutes_ago) insert_session(u2, "Recent Device", thirty_minutes_ago) assert UserSessions.count_for_users([u1, u2, u3], now) == [{u1.id, 2}, {u2.id, 1}] end end on_ee do describe "list_for_sso_team/1,2" do test "lists only SSO member sessions for a given team" do %{team: sso_team, site: site, sso_integration: sso_integration} = setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&create_site/1) |> setup_do(&setup_sso/1) %{team: _other_sso_team, sso_integration: other_sso_integration} = %{domain: "example2.com"} |> setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) %{team: _other_team, user: other_member} = setup_do(&create_user/1) |> setup_do(&create_team/1) add_guest(site, user: other_member, role: :editor) _other_member_session = Auth.UserSessions.create!(other_member, "Unknown") %{user: sso_member} = %{ user: %{name: "Jerry Wane", email: "wane@example.com"}, sso_integration: sso_integration } |> setup_do(&provision_sso_user/1) now = NaiveDateTime.utc_now(:second) sso_member_session = insert_session(sso_member, "Unknown", NaiveDateTime.add(now, -1, :hour)) %{user: sso_member2} = %{ user: %{name: "Joan McGuire", email: "joan@example.com"}, sso_integration: sso_integration } |> setup_do(&provision_sso_user/1) sso_member2_session1 = insert_session(sso_member2, "Unknown", NaiveDateTime.add(now, -2, :hour)) sso_member2_session2 = insert_session(sso_member2, "Unknown", now) in_the_past = NaiveDateTime.add(NaiveDateTime.utc_now(:second), -1, :hour) _sso_member2_session_past = Auth.UserSessions.create!(sso_member2, "Unknown", timeout_at: in_the_past) team = new_site().team standard_member = add_member(team, role: :editor) _standard_member_session = insert_session(standard_member, "Unknown", now) %{user: other_sso_member} = %{ user: %{name: "Veronica Dogwright", email: "veronica@example2.com"}, sso_integration: other_sso_integration } |> setup_do(&provision_sso_user/1) _other_sso_member_session = insert_session(other_sso_member, "Unknown", now) %{user: guest_sso_member} = %{ user: %{name: "Jimmy Felon", email: "jimmy@example2.com"}, sso_integration: other_sso_integration } |> setup_do(&provision_sso_user/1) add_guest(site, user: Repo.reload!(guest_sso_member), role: :viewer) _guest_sso_member_session = insert_session(guest_sso_member, "Unknown", now) assert [s1, s2, s3] = Auth.UserSessions.list_sso_for_team(sso_team) assert s1.token == sso_member2_session2.token assert s2.token == sso_member_session.token assert s3.token == sso_member2_session1.token end end describe "revoke_sso_by_id/2" do test "deletes and disconnects user session" do %{team: sso_team, sso_integration: sso_integration} = setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) %{user: user} = %{ user: %{name: "Jerry Wane", email: "wane@example.com"}, sso_integration: sso_integration } |> setup_do(&provision_sso_user/1) now = NaiveDateTime.utc_now(:second) active_session = insert_session(user, "Unknown", now) another_session = insert_session(user, "Unknown", now) live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) assert :ok = UserSessions.revoke_sso_by_id(sso_team, active_session.id) assert [remaining_session] = Repo.preload(user, :sessions).sessions assert_broadcast "disconnect", %{} assert remaining_session.id == another_session.id refute Repo.reload(active_session) assert Repo.reload(another_session) end test "does not delete session of user on another team" do %{team: sso_team, sso_integration: sso_integration} = setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) %{team: _other_sso_team, sso_integration: other_sso_integration} = %{domain: "example2.com"} |> setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) %{user: user} = %{ user: %{name: "Jerry Wane", email: "wane@example.com"}, sso_integration: sso_integration } |> setup_do(&provision_sso_user/1) now = NaiveDateTime.utc_now(:second) active_session = insert_session(user, "Unknown", now) %{user: other_user} = %{ user: %{name: "Judy Wasteland", email: "judy@example2.com"}, sso_integration: other_sso_integration } |> setup_do(&provision_sso_user/1) other_session = insert_session(other_user, "Unknown", now) assert :ok = UserSessions.revoke_sso_by_id(sso_team, other_session.id) assert Repo.reload(active_session) assert Repo.reload(other_session) end test "does not revoke session of standard user" do %{team: sso_team} = setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) user = add_member(sso_team, role: :editor) now = NaiveDateTime.utc_now(:second) active_session = insert_session(user, "Unknown", now) assert :ok = UserSessions.revoke_sso_by_id(sso_team, active_session.id) assert Repo.reload(active_session) end test "does not revoke session of a guest who happens to be SSO user" do %{team: sso_team, site: site} = setup_do(&create_user/1) |> setup_do(&create_site/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) %{team: _other_sso_team, sso_integration: other_sso_integration} = %{domain: "example2.com"} |> setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) %{user: user} = %{ user: %{name: "Judy Wasteland", email: "judy@example2.com"}, sso_integration: other_sso_integration } |> setup_do(&provision_sso_user/1) user = add_guest(site, user: Repo.reload!(user), role: :editor) now = NaiveDateTime.utc_now(:second) active_session = insert_session(user, "Unknown", now) assert :ok = UserSessions.revoke_sso_by_id(sso_team, active_session.id) assert Repo.reload(active_session) end test "executes gracefully when session does not exist" do %{team: sso_team, sso_integration: sso_integration} = setup_do(&create_user/1) |> setup_do(&create_team/1) |> setup_do(&setup_sso/1) %{user: user} = %{ user: %{name: "Jerry Wane", email: "wane@example.com"}, sso_integration: sso_integration } |> setup_do(&provision_sso_user/1) now = NaiveDateTime.utc_now(:second) active_session = insert_session(user, "Unknown", now) Repo.delete!(active_session) assert :ok = UserSessions.revoke_sso_by_id(sso_team, active_session.id) end end end describe "last_used_humanize/2" do test "returns humanized relative time" do user = insert(:user) now = NaiveDateTime.utc_now(:second) thirty_minutes_ago = NaiveDateTime.shift(now, minute: -30) ninety_minutes_ago = NaiveDateTime.shift(now, minute: -90) ten_hours_ago = NaiveDateTime.shift(now, hour: -10) twenty_seven_hours_ago = NaiveDateTime.shift(now, hour: -27) fifty_hours_ago = NaiveDateTime.shift(now, hour: -50) ten_days_ago = NaiveDateTime.shift(now, day: -10) assert last_used_humanize(user, now) == "Just recently" assert last_used_humanize(user, thirty_minutes_ago) == "Just recently" assert last_used_humanize(user, ninety_minutes_ago) == "1 hour ago" assert last_used_humanize(user, ten_hours_ago) == "10 hours ago" assert last_used_humanize(user, twenty_seven_hours_ago) == "Yesterday" assert last_used_humanize(user, fifty_hours_ago) == "2 days ago" assert last_used_humanize(user, ten_days_ago) == "10 days ago" end end describe "get_by_token/1" do setup do user = new_user() session = user |> Auth.UserSession.new_session("A Device") |> Repo.insert!() {:ok, user: user, session: session} end test "fetches token by session", %{session: session, user: user} do user |> subscribe_to_growth_plan() team = team_of(user) assert {:ok, fetched} = UserSessions.get_by_token(session.token) assert fetched.id == session.id assert fetched.user.id == user.id assert [ownership] = fetched.user.team_memberships assert ownership.role == :owner assert ownership.team.id == team.id assert ownership.team.subscription.id assert [owner] = ownership.team.owners assert owner.id == user.id end test "returns not found when no matching session found" do assert {:error, :not_found} = UserSessions.get_by_token(Ecto.UUID.generate()) end test "returns expired when the matching session is expired", %{session: session} do now = NaiveDateTime.utc_now(:second) in_the_past = NaiveDateTime.add(now, -1, :hour) session = session |> Ecto.Changeset.change(timeout_at: in_the_past) |> Repo.update!() assert {:error, :expired, expired_session} = UserSessions.get_by_token(session.token) assert expired_session.id == session.id end end describe "touch/1,2" do setup do user = new_user() session = user |> Auth.UserSession.new_session("A Device") |> Repo.insert!() {:ok, user: user, session: session} end test "refreshes user session timestamps", %{user: user, session: session} do two_days_later = NaiveDateTime.utc_now(:second) |> NaiveDateTime.shift(day: 2) assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session, two_days_later) assert refreshed_session.id == session.id assert NaiveDateTime.compare(refreshed_session.last_used_at, two_days_later) == :eq assert NaiveDateTime.compare(Repo.reload(user).last_seen, two_days_later) == :eq assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :gt end test "does not refresh if timestamps were updated less than hour before", %{ user: user, session: session } do last_seen = Repo.reload(user).last_seen fifty_minutes_later = NaiveDateTime.utc_now(:second) |> NaiveDateTime.shift(minute: 50) assert refreshed_session1 = %Auth.UserSession{} = UserSessions.touch(session, fifty_minutes_later) assert NaiveDateTime.compare( refreshed_session1.last_used_at, session.last_used_at ) == :eq assert NaiveDateTime.compare(Repo.reload(user).last_seen, last_seen) == :eq sixty_five_minutes_later = NaiveDateTime.utc_now(:second) |> NaiveDateTime.shift(minute: 65) assert refreshed_session2 = %Auth.UserSession{} = UserSessions.touch(session, sixty_five_minutes_later) assert NaiveDateTime.compare( refreshed_session2.last_used_at, sixty_five_minutes_later ) == :eq assert NaiveDateTime.compare(Repo.reload(user).last_seen, sixty_five_minutes_later) == :eq end test "handles concurrent refresh gracefully", %{session: session} do # concurrent update now = NaiveDateTime.utc_now(:second) two_days_later = NaiveDateTime.shift(now, day: 2) Repo.update_all( from(us in Auth.UserSession, where: us.token == ^session.token), set: [timeout_at: two_days_later, last_used_at: now] ) assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session) assert refreshed_session.id == session.id assert Repo.reload(session) end test "handles deleted session case gracefully", %{session: session} do Repo.delete!(session) assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session) assert refreshed_session.id == session.id refute Repo.reload(session) end on_ee do test "only records last usage but does not refresh for SSO user", %{ user: user, session: session } do sixty_five_minutes_later = NaiveDateTime.utc_now(:second) |> NaiveDateTime.shift(minute: 65) user |> Ecto.Changeset.change(type: :sso) |> Repo.update!() session = Repo.reload!(session) assert refreshed_session = %Auth.UserSession{} = UserSessions.touch(session, sixty_five_minutes_later) assert refreshed_session.id == session.id assert NaiveDateTime.compare(refreshed_session.last_used_at, sixty_five_minutes_later) == :eq assert NaiveDateTime.compare(refreshed_session.timeout_at, session.timeout_at) == :eq end end end describe "revoke_by_id/2" do setup do user = new_user() session = user |> Auth.UserSession.new_session("A Device") |> Repo.insert!() {:ok, user: user, session: session} end test "deletes and disconnects user session", %{user: user, session: active_session} do live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) another_session = user |> Auth.UserSession.new_session("Some Device") |> Repo.insert!() assert :ok = UserSessions.revoke_by_id(user, active_session.id) assert [remaining_session] = Repo.preload(user, :sessions).sessions assert_broadcast "disconnect", %{} assert remaining_session.id == another_session.id refute Repo.reload(active_session) assert Repo.reload(another_session) end test "does not delete session of another user", %{user: user, session: active_session} do other_session = insert(:user) |> Auth.UserSession.new_session("Some Device") |> Repo.insert!() assert :ok = UserSessions.revoke_by_id(user, other_session.id) assert Repo.reload(active_session) assert Repo.reload(other_session) end test "executes gracefully when session does not exist", %{user: user, session: active_session} do Repo.delete!(active_session) assert :ok = UserSessions.revoke_by_id(user, active_session.id) end end describe "revoke_all/1,2" do setup do user = new_user() session = user |> Auth.UserSession.new_session("A Device") |> Repo.insert!() {:ok, user: user, session: session} end test "deletes and disconnects all user's sessions", %{user: user, session: active_session} do live_socket_id = "user_sessions:" <> Base.url_encode64(active_session.token) Phoenix.PubSub.subscribe(Plausible.PubSub, live_socket_id) another_session = user |> Auth.UserSession.new_session("Some Device") |> Repo.insert!() unrelated_session = insert(:user) |> Auth.UserSession.new_session("Some Device") |> Repo.insert!() assert :ok = UserSessions.revoke_all(user) assert [] = Repo.preload(user, :sessions).sessions assert_broadcast "disconnect", %{} refute Repo.reload(another_session) assert Repo.reload(unrelated_session) end test "executes gracefully when user has no sessions" do user = insert(:user) assert :ok = UserSessions.revoke_all(user) end end defp last_used_humanize(user, dt) do user |> insert_session("Some Device", dt) |> UserSessions.last_used_humanize() end defp insert_session(user, device_name, now) do user |> Auth.UserSession.new_session(device_name, now: now) |> Repo.insert!() end end