diff --git a/.gitignore b/.gitignore
index a630037b..3e1cbccd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,7 +44,6 @@
# Ignore VS Code
.vscode/*
-!.vscode/settings.json
!.vscode/extensions.json
!.vscode/*.code-snippets
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index f94a8b2b..00000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "[javascript]": {
- "editor.formatOnSave": true,
- "editor.defaultFormatter": "biomejs.biome",
- }
-}
\ No newline at end of file
diff --git a/Gemfile.lock b/Gemfile.lock
index 21810fe3..51ea10a8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -110,7 +110,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
- brakeman (6.2.1)
+ brakeman (6.2.2)
racc
builder (3.3.0)
capybara (3.40.0)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c5145b17..b6fa8e0c 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
- include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
+ include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable
include Pagy::Backend
private
diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb
index cd210cca..a66d1b3f 100644
--- a/app/controllers/concerns/authentication.rb
+++ b/app/controllers/concerns/authentication.rb
@@ -14,7 +14,7 @@ module Authentication
private
def authenticate_user!
- if session_record = Session.find_by_id(cookies.signed[:session_token])
+ if session_record = find_session_by_cookie
Current.session = session_record
else
if self_hosted_first_login?
@@ -25,6 +25,10 @@ module Authentication
end
end
+ def find_session_by_cookie
+ Session.find_by(id: cookies.signed[:session_token])
+ end
+
def create_session_for(user)
session = user.sessions.create!
cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }
diff --git a/app/controllers/concerns/impersonatable.rb b/app/controllers/concerns/impersonatable.rb
new file mode 100644
index 00000000..857b68c4
--- /dev/null
+++ b/app/controllers/concerns/impersonatable.rb
@@ -0,0 +1,21 @@
+module Impersonatable
+ extend ActiveSupport::Concern
+
+ included do
+ after_action :create_impersonation_session_log
+ end
+
+ private
+ def create_impersonation_session_log
+ return unless Current.session&.active_impersonator_session.present?
+
+ Current.session.active_impersonator_session.logs.create!(
+ controller: controller_name,
+ action: action_name,
+ path: request.fullpath,
+ method: request.method,
+ ip_address: request.ip,
+ user_agent: request.user_agent
+ )
+ end
+end
diff --git a/app/controllers/impersonation_sessions_controller.rb b/app/controllers/impersonation_sessions_controller.rb
new file mode 100644
index 00000000..1a8c5db7
--- /dev/null
+++ b/app/controllers/impersonation_sessions_controller.rb
@@ -0,0 +1,58 @@
+class ImpersonationSessionsController < ApplicationController
+ before_action :require_super_admin!, only: [ :create, :join, :leave ]
+ before_action :set_impersonation_session, only: [ :approve, :reject, :complete ]
+
+ def create
+ Current.true_user.request_impersonation_for(session_params[:impersonated_id])
+ redirect_to root_path, notice: t(".success")
+ end
+
+ def join
+ @impersonation_session = Current.true_user.impersonator_support_sessions.find_by(id: params[:impersonation_session_id])
+ Current.session.update!(active_impersonator_session: @impersonation_session)
+ redirect_to root_path, notice: t(".success")
+ end
+
+ def leave
+ Current.session.update!(active_impersonator_session: nil)
+ redirect_to root_path, notice: t(".success")
+ end
+
+ def approve
+ raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
+
+ @impersonation_session.approve!
+ redirect_to root_path, notice: t(".success")
+ end
+
+ def reject
+ raise_unauthorized! unless @impersonation_session.impersonated == Current.true_user
+
+ @impersonation_session.reject!
+ redirect_to root_path, notice: t(".success")
+ end
+
+ def complete
+ @impersonation_session.complete!
+ redirect_to root_path, notice: t(".success")
+ end
+
+ private
+ def session_params
+ params.require(:impersonation_session).permit(:impersonated_id)
+ end
+
+ def set_impersonation_session
+ @impersonation_session =
+ Current.true_user.impersonated_support_sessions.find_by(id: params[:id]) ||
+ Current.true_user.impersonator_support_sessions.find_by(id: params[:id])
+ end
+
+ def require_super_admin!
+ raise_unauthorized! unless Current.true_user&.super_admin?
+ end
+
+ def raise_unauthorized!
+ raise ActionController::RoutingError.new("Not Found")
+ end
+end
diff --git a/app/helpers/impersonation_sessions_helper.rb b/app/helpers/impersonation_sessions_helper.rb
new file mode 100644
index 00000000..f955b896
--- /dev/null
+++ b/app/helpers/impersonation_sessions_helper.rb
@@ -0,0 +1,2 @@
+module ImpersonationSessionsHelper
+end
diff --git a/app/models/current.rb b/app/models/current.rb
index d12fc0a9..86b9e97a 100644
--- a/app/models/current.rb
+++ b/app/models/current.rb
@@ -1,7 +1,19 @@
class Current < ActiveSupport::CurrentAttributes
- attribute :session
attribute :user_agent, :ip_address
- delegate :user, to: :session, allow_nil: true
+ attribute :session
+
delegate :family, to: :user, allow_nil: true
+
+ def user
+ impersonated_user || session&.user
+ end
+
+ def impersonated_user
+ session&.active_impersonator_session&.impersonated
+ end
+
+ def true_user
+ session&.user
+ end
end
diff --git a/app/models/impersonation_session.rb b/app/models/impersonation_session.rb
new file mode 100644
index 00000000..0e0fd582
--- /dev/null
+++ b/app/models/impersonation_session.rb
@@ -0,0 +1,39 @@
+class ImpersonationSession < ApplicationRecord
+ belongs_to :impersonator, class_name: "User"
+ belongs_to :impersonated, class_name: "User"
+
+ has_many :logs, class_name: "ImpersonationSessionLog"
+
+ enum :status, { pending: "pending", in_progress: "in_progress", complete: "complete", rejected: "rejected" }
+
+ scope :initiated, -> { where(status: [ :pending, :in_progress ]) }
+
+ validate :impersonator_is_super_admin
+ validate :impersonated_is_not_super_admin
+ validate :impersonator_different_from_impersonated
+
+ def approve!
+ update! status: :in_progress
+ end
+
+ def reject!
+ update! status: :rejected
+ end
+
+ def complete!
+ update! status: :complete
+ end
+
+ private
+ def impersonator_is_super_admin
+ errors.add(:impersonator, "must be a super admin to impersonate") unless impersonator.super_admin?
+ end
+
+ def impersonated_is_not_super_admin
+ errors.add(:impersonated, "cannot be a super admin") if impersonated.super_admin?
+ end
+
+ def impersonator_different_from_impersonated
+ errors.add(:impersonator, "cannot be the same as the impersonated user") if impersonator == impersonated
+ end
+end
diff --git a/app/models/impersonation_session_log.rb b/app/models/impersonation_session_log.rb
new file mode 100644
index 00000000..d7fa5ac3
--- /dev/null
+++ b/app/models/impersonation_session_log.rb
@@ -0,0 +1,3 @@
+class ImpersonationSessionLog < ApplicationRecord
+ belongs_to :impersonation_session
+end
diff --git a/app/models/session.rb b/app/models/session.rb
index 8e94aa81..ce26938a 100644
--- a/app/models/session.rb
+++ b/app/models/session.rb
@@ -1,5 +1,9 @@
class Session < ApplicationRecord
belongs_to :user
+ belongs_to :active_impersonator_session,
+ -> { where(status: :in_progress) },
+ class_name: "ImpersonationSession",
+ optional: true
before_create do
self.user_agent = Current.user_agent
diff --git a/app/models/user.rb b/app/models/user.rb
index fe2a72c9..789e39df 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -3,6 +3,8 @@ class User < ApplicationRecord
belongs_to :family
has_many :sessions, dependent: :destroy
+ has_many :impersonator_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonator_id, dependent: :destroy
+ has_many :impersonated_support_sessions, class_name: "ImpersonationSession", foreign_key: :impersonated_id, dependent: :destroy
accepts_nested_attributes_for :family
validates :email, presence: true, uniqueness: true
@@ -11,7 +13,7 @@ class User < ApplicationRecord
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }
- enum :role, { member: "member", admin: "admin" }, validate: true
+ enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true
has_one_attached :profile_image do |attachable|
attachable.variant :thumbnail, resize_to_fill: [ 300, 300 ]
@@ -23,6 +25,11 @@ class User < ApplicationRecord
password_salt&.last(10)
end
+ def request_impersonation_for(user_id)
+ impersonated = User.find(user_id)
+ impersonator_support_sessions.create!(impersonated: impersonated)
+ end
+
def display_name
[ first_name, last_name ].compact.join(" ").presence || email
end
diff --git a/app/views/impersonation_sessions/_approval_bar.html.erb b/app/views/impersonation_sessions/_approval_bar.html.erb
new file mode 100644
index 00000000..2bc83087
--- /dev/null
+++ b/app/views/impersonation_sessions/_approval_bar.html.erb
@@ -0,0 +1,21 @@
+<% pending_session = Current.true_user.impersonated_support_sessions.pending.first %>
+<% in_progress_session = Current.true_user.impersonated_support_sessions.in_progress.first %>
+
+
+
+ <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %>
+ Access <%= in_progress_session.present? ? "Session" : "Request" %>
+
+
+ <% if pending_session.present? %>
+
Maybe support staff has requested access to your account (likely to help you with a support request). If you approve the request, all activity they take will be logged for security and audit purposes.
+ <%= button_to "Approve", approve_impersonation_session_path(pending_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500" %>
+ <%= button_to "Reject", reject_impersonation_session_path(pending_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
+ <% elsif in_progress_session.present? %>
+
Someone from the Maybe Finance team is currently viewing your data. You may end the session at any time.
+ <%= button_to "End Session", complete_impersonation_session_path(in_progress_session), method: :put, class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
+ <% else %>
+
Something went wrong. Please contact us.
+ <% end %>
+
+
diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb
new file mode 100644
index 00000000..fddb270b
--- /dev/null
+++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb
@@ -0,0 +1,35 @@
+
+
+ <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %>
+ Super Admin
+
+
+ <% if Current.session.active_impersonator_session.present? %>
+
+
+ Impersonating: <%= Current.impersonated_user.email %>
+
+ <%= button_to "Leave", leave_impersonation_sessions_path, method: :delete, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
+ <%= button_to "Terminate", complete_impersonation_session_path(Current.session.active_impersonator_session), method: :put, class: "items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
+
+ <% else %>
+ <% if Current.true_user.impersonator_support_sessions.in_progress.any? %>
+ <%= form_with url: join_impersonation_sessions_path, class: "flex items-center space-x-2 mr-4" do |f| %>
+ <%= f.select :impersonation_session_id,
+ Current.true_user.impersonator_support_sessions.in_progress.map { |session|
+ ["#{session.impersonated.email} (#{session.status})", session.id]
+ },
+ { prompt: "Join a session" },
+ { class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono" } %>
+ <%= f.submit "Join",
+ class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
+ <% end %>
+ <% end %>
+
+ <%= form_with model: ImpersonationSession.new, class: "flex items-center space-x-2" do |f| %>
+ <%= f.text_field :impersonated_id, class: "rounded-md text-sm border-0 focus:ring-0 ring-0 text-black font-mono w-96", placeholder: "UUID", autocomplete: "off" %>
+ <%= f.submit "Request Impersonation", class: "inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
+ <% end %>
+ <% end %>
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index e7712e13..386e839d 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -25,6 +25,9 @@
+ <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? %>
+ <%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %>
+
<%= render_flash_notifications %>
diff --git a/app/views/shared/_text_tooltip.erb b/app/views/shared/_text_tooltip.erb
index 03de7e48..53f93dd3 100644
--- a/app/views/shared/_text_tooltip.erb
+++ b/app/views/shared/_text_tooltip.erb
@@ -2,4 +2,4 @@
<%= tooltip_text %>
-
\ No newline at end of file
+
diff --git a/config/locales/views/impersonation_sessions/en.yml b/config/locales/views/impersonation_sessions/en.yml
new file mode 100644
index 00000000..be18173f
--- /dev/null
+++ b/config/locales/views/impersonation_sessions/en.yml
@@ -0,0 +1,15 @@
+---
+en:
+ impersonation_sessions:
+ create:
+ success: "Request sent to user. Waiting for approval."
+ join:
+ success: "Joined session"
+ leave:
+ success: "Left session"
+ approve:
+ success: "Request approved"
+ reject:
+ success: "Request rejected"
+ complete:
+ success: "Session completed"
\ No newline at end of file
diff --git a/config/routes.rb b/config/routes.rb
index 9a84ce22..2cfa2b2d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -110,6 +110,17 @@ Rails.application.routes.draw do
resources :currencies, only: %i[show]
+ resources :impersonation_sessions, only: [ :create ] do
+ post :join, on: :collection
+ delete :leave, on: :collection
+
+ member do
+ put :approve
+ put :reject
+ put :complete
+ end
+ end
+
# Stripe webhook endpoint
post "webhooks/stripe", to: "webhooks#stripe"
diff --git a/db/migrate/20241009214601_add_super_admin_to_users.rb b/db/migrate/20241009214601_add_super_admin_to_users.rb
new file mode 100644
index 00000000..cc1fadb4
--- /dev/null
+++ b/db/migrate/20241009214601_add_super_admin_to_users.rb
@@ -0,0 +1,23 @@
+class AddSuperAdminToUsers < ActiveRecord::Migration[7.2]
+ def change
+ reversible do |dir|
+ dir.up do
+ change_column :users, :role, :string, default: 'member'
+
+ execute <<-SQL
+ DROP TYPE user_role;
+ SQL
+ end
+
+ dir.down do
+ execute <<-SQL
+ CREATE TYPE user_role AS ENUM ('admin', 'member');
+ SQL
+
+ change_column_default :users, :role, nil
+ change_column :users, :role, :user_role, using: 'role::user_role'
+ change_column_default :users, :role, 'member'
+ end
+ end
+ end
+end
diff --git a/db/migrate/20241017162347_create_impersonation_sessions.rb b/db/migrate/20241017162347_create_impersonation_sessions.rb
new file mode 100644
index 00000000..1e7cf38e
--- /dev/null
+++ b/db/migrate/20241017162347_create_impersonation_sessions.rb
@@ -0,0 +1,12 @@
+class CreateImpersonationSessions < ActiveRecord::Migration[7.2]
+ def change
+ create_table :impersonation_sessions, id: :uuid do |t|
+ t.references :impersonator, null: false, foreign_key: { to_table: :users }, type: :uuid
+ t.references :impersonated, null: false, foreign_key: { to_table: :users }, type: :uuid
+ t.string :status, null: false, default: 'pending'
+ t.timestamps
+ end
+
+ add_reference :sessions, :active_impersonator_session, type: :uuid, foreign_key: { to_table: :impersonation_sessions }
+ end
+end
diff --git a/db/migrate/20241017162536_create_impersonation_session_logs.rb b/db/migrate/20241017162536_create_impersonation_session_logs.rb
new file mode 100644
index 00000000..ebf68d91
--- /dev/null
+++ b/db/migrate/20241017162536_create_impersonation_session_logs.rb
@@ -0,0 +1,14 @@
+class CreateImpersonationSessionLogs < ActiveRecord::Migration[7.2]
+ def change
+ create_table :impersonation_session_logs, id: :uuid do |t|
+ t.references :impersonation_session, type: :uuid, foreign_key: true, null: false
+ t.string :controller
+ t.string :action
+ t.text :path
+ t.string :method
+ t.string :ip_address
+ t.text :user_agent
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 49e43264..c5a313ea 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -19,7 +19,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
# Note that some types may not work with other database engines. Be careful if changing database.
create_enum "account_status", ["ok", "syncing", "error"]
create_enum "import_status", ["pending", "importing", "complete", "failed"]
- create_enum "user_role", ["admin", "member"]
+ create_enum "user_role", ["admin", "member", "super_admin"]
create_table "account_balances", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "account_id", null: false
@@ -493,6 +493,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
t.string "ip_address"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.uuid "active_impersonator_session_id"
+ t.index ["active_impersonator_session_id"], name: "index_sessions_on_active_impersonator_session_id"
t.index ["user_id"], name: "index_sessions_on_user_id"
end
@@ -535,7 +537,6 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
t.string "last_alerted_upgrade_commit_sha"
t.enum "role", default: "member", null: false, enum_type: "user_role"
t.boolean "active", default: true, null: false
- t.boolean "super_admin", default: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
end
@@ -573,6 +574,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_10_17_204250) do
add_foreign_key "imports", "families"
add_foreign_key "institutions", "families"
add_foreign_key "merchants", "families"
+ add_foreign_key "sessions", "impersonation_sessions", column: "active_impersonator_session_id"
add_foreign_key "sessions", "users"
add_foreign_key "taggings", "tags"
add_foreign_key "tags", "families"
diff --git a/test/controllers/impersonation_sessions_controller_test.rb b/test/controllers/impersonation_sessions_controller_test.rb
new file mode 100644
index 00000000..65fe4bf5
--- /dev/null
+++ b/test/controllers/impersonation_sessions_controller_test.rb
@@ -0,0 +1,112 @@
+require "test_helper"
+
+class ImpersonationSessionsControllerTest < ActionDispatch::IntegrationTest
+ test "impersonation session logs all activity for auditing" do
+ sign_in impersonator = users(:maybe_support_staff)
+ impersonated = users(:family_member)
+
+ impersonator_session = impersonation_sessions(:in_progress)
+
+ post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id }
+
+ assert_difference "impersonator_session.logs.count", 2 do
+ get root_path
+ get account_path(impersonated.family.accounts.first)
+ end
+ end
+
+ test "super admin can request an impersonation session" do
+ sign_in users(:maybe_support_staff)
+
+ post impersonation_sessions_path, params: { impersonation_session: { impersonated_id: users(:family_member).id } }
+
+ assert_equal "Request sent to user. Waiting for approval.", flash[:notice]
+ assert_redirected_to root_path
+ end
+
+ test "super admin can join and leave an in progress impersonation session" do
+ sign_in super_admin = users(:maybe_support_staff)
+
+ impersonator_session = impersonation_sessions(:in_progress)
+
+ super_admin_session = super_admin.sessions.order(created_at: :desc).first
+
+ assert_nil super_admin_session.active_impersonator_session
+
+ # Joining the session
+ post join_impersonation_sessions_path, params: { impersonation_session_id: impersonator_session.id }
+ assert_equal impersonator_session, super_admin_session.reload.active_impersonator_session
+ assert_equal "Joined session", flash[:notice]
+ assert_redirected_to root_path
+
+ follow_redirect!
+
+ # Leaving the session
+ delete leave_impersonation_sessions_path
+ assert_nil super_admin_session.reload.active_impersonator_session
+ assert_equal "Left session", flash[:notice]
+ assert_redirected_to root_path
+
+ # Impersonation session still in progress because nobody has ended it yet
+ assert_equal "in_progress", impersonator_session.reload.status
+ end
+
+ test "super admin can complete an impersonation session" do
+ sign_in super_admin = users(:maybe_support_staff)
+
+ impersonator_session = impersonation_sessions(:in_progress)
+
+ put complete_impersonation_session_path(impersonator_session)
+
+ assert_equal "Session completed", flash[:notice]
+ assert_nil super_admin.sessions.order(created_at: :desc).first.active_impersonator_session
+ assert_equal "complete", impersonator_session.reload.status
+ assert_redirected_to root_path
+ end
+
+ test "regular user can complete an impersonation session" do
+ sign_in regular_user = users(:family_member)
+
+ impersonator_session = impersonation_sessions(:in_progress)
+
+ put complete_impersonation_session_path(impersonator_session)
+
+ assert_equal "Session completed", flash[:notice]
+ assert_equal "complete", impersonator_session.reload.status
+ assert_redirected_to root_path
+ end
+
+ test "super admin cannot accept an impersonation session" do
+ sign_in super_admin = users(:maybe_support_staff)
+
+ impersonator_session = impersonation_sessions(:in_progress)
+
+ put approve_impersonation_session_path(impersonator_session)
+
+ assert_response :not_found
+ end
+
+ test "regular user can accept an impersonation session" do
+ sign_in regular_user = users(:family_member)
+
+ impersonator_session = impersonation_sessions(:in_progress)
+
+ put approve_impersonation_session_path(impersonator_session)
+
+ assert_equal "Request approved", flash[:notice]
+ assert_equal "in_progress", impersonator_session.reload.status
+ assert_redirected_to root_path
+ end
+
+ test "regular user can reject an impersonation session" do
+ sign_in regular_user = users(:family_member)
+
+ impersonator_session = impersonation_sessions(:in_progress)
+
+ put reject_impersonation_session_path(impersonator_session)
+
+ assert_equal "Request rejected", flash[:notice]
+ assert_equal "rejected", impersonator_session.reload.status
+ assert_redirected_to root_path
+ end
+end
diff --git a/test/fixtures/impersonation_session_logs.yml b/test/fixtures/impersonation_session_logs.yml
new file mode 100644
index 00000000..e11ea93e
--- /dev/null
+++ b/test/fixtures/impersonation_session_logs.yml
@@ -0,0 +1,11 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+# This model initially had no columns defined. If you add columns to the
+# model remove the "{}" from the fixture names and add the columns immediately
+# below each fixture, per the syntax in the comments below
+#
+#one: {}
+# column: value
+#
+#two: {}
+# column: value
diff --git a/test/fixtures/impersonation_sessions.yml b/test/fixtures/impersonation_sessions.yml
new file mode 100644
index 00000000..becd9e7c
--- /dev/null
+++ b/test/fixtures/impersonation_sessions.yml
@@ -0,0 +1,4 @@
+in_progress:
+ impersonator: maybe_support_staff
+ impersonated: family_member
+ status: in_progress
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index 3c9aa4ed..b717c9cb 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -5,6 +5,14 @@ empty:
email: user1@email.com
password_digest: <%= BCrypt::Password.create('password') %>
+maybe_support_staff:
+ family: empty
+ first_name: Support
+ last_name: Admin
+ email: support@maybe.co
+ password_digest: <%= BCrypt::Password.create('password') %>
+ role: super_admin
+
family_admin:
family: dylan_family
first_name: Bob
diff --git a/test/models/impersonation_session_log_test.rb b/test/models/impersonation_session_log_test.rb
new file mode 100644
index 00000000..f620ebb1
--- /dev/null
+++ b/test/models/impersonation_session_log_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class ImpersonationSessionLogTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/impersonation_session_test.rb b/test/models/impersonation_session_test.rb
new file mode 100644
index 00000000..1946e6da
--- /dev/null
+++ b/test/models/impersonation_session_test.rb
@@ -0,0 +1,40 @@
+require "test_helper"
+
+class ImpersonationSessionTest < ActiveSupport::TestCase
+ test "only super admin can impersonate" do
+ regular_user = users(:family_member)
+
+ assert_not regular_user.super_admin?
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ ImpersonationSession.create!(
+ impersonator: regular_user,
+ impersonated: users(:maybe_support_staff)
+ )
+ end
+ end
+
+ test "super admin cannot be impersonated" do
+ super_admin = users(:maybe_support_staff)
+
+ assert super_admin.super_admin?
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ ImpersonationSession.create!(
+ impersonator: users(:family_member),
+ impersonated: super_admin
+ )
+ end
+ end
+
+ test "impersonation session must have different impersonator and impersonated" do
+ super_admin = users(:maybe_support_staff)
+
+ assert_raises(ActiveRecord::RecordInvalid) do
+ ImpersonationSession.create!(
+ impersonator: super_admin,
+ impersonated: super_admin
+ )
+ end
+ end
+end