diff --git a/app/controllers/api/v1/profile/sessions_controller.rb b/app/controllers/api/v1/profile/sessions_controller.rb new file mode 100644 index 000000000..72e9451eb --- /dev/null +++ b/app/controllers/api/v1/profile/sessions_controller.rb @@ -0,0 +1,36 @@ +class Api::V1::Profile::SessionsController < Api::BaseController + before_action :set_session, only: [:destroy] + + def index + @sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc) + @current_client_id = request.headers['client'] + end + + def destroy + if @session.current?(request.headers['client']) + render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity + return + end + + revoke_token!(@session.client_id) + @session.destroy! + head :ok + end + + private + + def set_session + @session = current_user.user_sessions.find(params[:id]) + end + + def revoke_token!(client_id) + tokens = current_user.tokens + tokens.delete(client_id) + current_user.update!(tokens: tokens) + end + + def active_token_client_ids + now = Time.current.to_i + (current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2f389049d..9dea4b4da 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,6 +3,7 @@ class ApplicationController < ActionController::Base include RequestExceptionHandler include Pundit::Authorization include SwitchLocale + include TrackSessionActivity skip_before_action :verify_authenticity_token diff --git a/app/controllers/concerns/track_session_activity.rb b/app/controllers/concerns/track_session_activity.rb new file mode 100644 index 000000000..f6a512922 --- /dev/null +++ b/app/controllers/concerns/track_session_activity.rb @@ -0,0 +1,22 @@ +module TrackSessionActivity + extend ActiveSupport::Concern + + included do + after_action :update_session_activity + end + + private + + def update_session_activity + return unless current_user + return if request.headers['client'].blank? + + UserSessionTrackingService.new( + user: current_user, + request: request, + client_id: request.headers['client'] + ).update_activity! + rescue StandardError => e + Rails.logger.warn "Session activity update failed: #{e.message}" + end +end diff --git a/app/controllers/devise_overrides/sessions_controller.rb b/app/controllers/devise_overrides/sessions_controller.rb index bd7bb9b44..1ef6d9511 100644 --- a/app/controllers/devise_overrides/sessions_controller.rb +++ b/app/controllers/devise_overrides/sessions_controller.rb @@ -20,6 +20,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController end def render_create_success + track_user_session render partial: 'devise/auth', formats: [:json], locals: { resource: @resource } end @@ -114,6 +115,19 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController def render_mfa_error(message_key, status = :bad_request) render json: { error: I18n.t(message_key) }, status: status end + + def track_user_session + client_id = @token&.try(:client) || response.headers['client'] + return unless client_id.present? && @resource.present? + + UserSessionTrackingService.new( + user: @resource, + request: request, + client_id: client_id + ).create_or_update! + rescue StandardError => e + Rails.logger.warn "Session tracking failed: #{e.message}" + end end DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController') diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index a1b15ee79..b9dc59964 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -106,4 +106,10 @@ export default { const urlData = endPoints('resetAccessToken'); return axios.post(urlData.url); }, + getSessions() { + return axios.get('/api/v1/profile/sessions'); + }, + revokeSession(id) { + return axios.delete(`/api/v1/profile/sessions/${id}`); + }, }; diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 6dc47cfbf..751229404 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -154,6 +154,10 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({ SHARE_CLICKED: 'Year in Review: Share clicked', }); +export const SESSION_EVENTS = Object.freeze({ + REVOKED_FROM_PROFILE: 'Revoked an active session', +}); + export const ONBOARDING_EVENTS = Object.freeze({ ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited', ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed', diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index bfbd920a7..d14175cb5 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -86,6 +86,17 @@ "NOTE": "Manage additional security features for your account.", "MFA_BUTTON": "Manage Two-Factor Authentication" }, + "SESSIONS_SECTION": { + "TITLE": "Active Sessions", + "NOTE": "These are the devices currently logged in to your account.", + "CURRENT": "Current session", + "REVOKE": "Revoke", + "REVOKE_SUCCESS": "Session revoked successfully", + "REVOKE_ERROR": "Unable to revoke session. Please try again.", + "FETCH_ERROR": "Unable to fetch sessions. Please try again.", + "LAST_ACTIVE": "Last active", + "UNKNOWN_DEVICE": "Unknown device" + }, "ACCESS_TOKEN": { "TITLE": "Access Token", "NOTE": "This token can be used if you are building an API based integration", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue new file mode 100644 index 000000000..ed4868462 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/ActiveSessions.vue @@ -0,0 +1,137 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue index 75eb8a2f8..04de7bda2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/Index.vue @@ -20,6 +20,7 @@ import SectionLayout from '../account/components/SectionLayout.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import AccessToken from './AccessToken.vue'; import MfaSettingsCard from './MfaSettingsCard.vue'; +import ActiveSessions from './ActiveSessions.vue'; import Policy from 'dashboard/components/policy.vue'; import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; import { @@ -42,6 +43,7 @@ export default { AudioNotifications, AccessToken, MfaSettingsCard, + ActiveSessions, BaseSettingsHeader, }, setup() { @@ -307,6 +309,13 @@ export default { > + + + e + Rails.logger.warn "UserSessionIpLookupJob failed: #{e.message}" + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4aa38bbcd..729f674d3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -101,6 +101,7 @@ class User < ApplicationRecord has_many :messages, as: :sender, dependent: :nullify has_many :invitees, through: :account_users, class_name: 'User', foreign_key: 'inviter_id', source: :inviter, dependent: :nullify + has_many :user_sessions, dependent: :destroy has_many :custom_filters, dependent: :destroy_async has_many :dashboard_apps, dependent: :nullify has_many :mentions, dependent: :destroy_async @@ -118,6 +119,7 @@ class User < ApplicationRecord before_validation :set_password_and_uid, on: :create after_destroy :remove_macros + after_save :sync_user_sessions, if: :saved_change_to_tokens? scope :order_by_full_name, -> { order('lower(name) ASC') } @@ -214,6 +216,11 @@ class User < ApplicationRecord private + def sync_user_sessions + active_client_ids = (tokens || {}).keys + user_sessions.where.not(client_id: active_client_ids).destroy_all + end + def remove_macros macros.personal.destroy_all end diff --git a/app/models/user_session.rb b/app/models/user_session.rb new file mode 100644 index 000000000..0f2503382 --- /dev/null +++ b/app/models/user_session.rb @@ -0,0 +1,46 @@ +# == Schema Information +# +# Table name: user_sessions +# +# id :bigint not null, primary key +# browser_name :string +# browser_version :string +# city :string +# country :string +# country_code :string +# device_name :string +# ip_address :string +# last_activity_at :datetime +# platform_name :string +# platform_version :string +# user_agent :string +# created_at :datetime not null +# updated_at :datetime not null +# client_id :string not null +# user_id :bigint not null +# +# Indexes +# +# index_user_sessions_on_user_id (user_id) +# index_user_sessions_on_user_id_and_client_id (user_id,client_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# + +class UserSession < ApplicationRecord + ACTIVITY_THROTTLE = 5.minutes + + belongs_to :user + + validates :client_id, presence: true, uniqueness: { scope: :user_id } + + def current?(active_client_id) + client_id == active_client_id + end + + def should_update_activity? + last_activity_at.nil? || last_activity_at < ACTIVITY_THROTTLE.ago + end +end diff --git a/app/services/user_session_tracking_service.rb b/app/services/user_session_tracking_service.rb new file mode 100644 index 000000000..28f272a18 --- /dev/null +++ b/app/services/user_session_tracking_service.rb @@ -0,0 +1,39 @@ +class UserSessionTrackingService + def initialize(user:, request:, client_id:) + @user = user + @request = request + @client_id = client_id + end + + def create_or_update! + session = @user.user_sessions.find_or_initialize_by(client_id: @client_id) + session.assign_attributes(session_attributes) + session.last_activity_at = Time.current + session.save! + UserSessionIpLookupJob.perform_later(session) if session.ip_address.present? + session + end + + def update_activity! + session = @user.user_sessions.find_by(client_id: @client_id) + return unless session&.should_update_activity? + + session.update_columns(last_activity_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + private + + def session_attributes + browser = Browser.new(@request.user_agent) + + { + ip_address: @request.remote_ip, + user_agent: @request.user_agent, + browser_name: browser.name, + browser_version: browser.full_version, + device_name: browser.device.name, + platform_name: browser.platform.name, + platform_version: browser.platform.version + } + end +end diff --git a/app/views/api/v1/profile/sessions/index.json.jbuilder b/app/views/api/v1/profile/sessions/index.json.jbuilder new file mode 100644 index 000000000..b009271e0 --- /dev/null +++ b/app/views/api/v1/profile/sessions/index.json.jbuilder @@ -0,0 +1,15 @@ +json.array! @sessions do |session| + json.id session.id + json.browser_name session.browser_name + json.browser_version session.browser_version + json.device_name session.device_name + json.platform_name session.platform_name + json.platform_version session.platform_version + json.ip_address session.ip_address + json.city session.city + json.country session.country + json.country_code session.country_code + json.last_activity_at session.last_activity_at + json.created_at session.created_at + json.current session.current?(@current_client_id) +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8b8202f1c..826894466 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -47,6 +47,10 @@ en: saml_not_available: SAML authentication is not available in this installation. inbox_deletetion_response: Your inbox deletion request will be processed in some time. + profile_settings: + sessions: + cannot_revoke_current: You cannot revoke the current session. + errors: account: reporting_timezone: diff --git a/config/routes.rb b/config/routes.rb index 885b69062..f86e3f2cb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -437,6 +437,7 @@ Rails.application.routes.draw do post :verify post :backup_codes end + resources :sessions, only: [:index, :destroy] end end diff --git a/db/migrate/20260611184600_create_user_sessions.rb b/db/migrate/20260611184600_create_user_sessions.rb new file mode 100644 index 000000000..96ad2821e --- /dev/null +++ b/db/migrate/20260611184600_create_user_sessions.rb @@ -0,0 +1,23 @@ +class CreateUserSessions < ActiveRecord::Migration[7.0] + def change + create_table :user_sessions do |t| + t.references :user, null: false, foreign_key: true + t.string :client_id, null: false + t.string :ip_address + t.string :user_agent + t.string :browser_name + t.string :browser_version + t.string :device_name + t.string :platform_name + t.string :platform_version + t.string :city + t.string :country + t.string :country_code + t.datetime :last_activity_at + + t.timestamps + end + + add_index :user_sessions, [:user_id, :client_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index eb6b82508..2060bcfda 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do +ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1253,6 +1253,26 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do t.index ["name", "account_id"], name: "index_teams_on_name_and_account_id", unique: true end + create_table "user_sessions", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "client_id", null: false + t.string "ip_address" + t.string "user_agent" + t.string "browser_name" + t.string "browser_version" + t.string "device_name" + t.string "platform_name" + t.string "platform_version" + t.string "city" + t.string "country" + t.string "country_code" + t.datetime "last_activity_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id", "client_id"], name: "index_user_sessions_on_user_id_and_client_id", unique: true + t.index ["user_id"], name: "index_user_sessions_on_user_id" + end + create_table "users", id: :serial, force: :cascade do |t| t.string "provider", default: "email", null: false t.string "uid", default: "", null: false @@ -1325,6 +1345,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_04_000000) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "inboxes", "portals" + add_foreign_key "user_sessions", "users" create_trigger("accounts_after_insert_row_tr", :generated => true, :compatibility => 1). on("accounts"). after(:insert). diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb index 8ee012670..7e12011b2 100644 --- a/spec/controllers/devise_overrides/sessions_controller_spec.rb +++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb @@ -163,4 +163,21 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do expect(response).to redirect_to('/frontend/app/login?error=access-denied') end end + + describe 'session tracking' do + let(:user) { create(:user, password: 'Test@123456') } + let(:browser_ua) { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15' } + + context 'with a successful login' do + before { request.env['HTTP_USER_AGENT'] = browser_ua } + + it 'creates a UserSession row for the new client_id' do + expect { post :create, params: { email: user.email, password: 'Test@123456' } }.to change(user.user_sessions, :count).by(1) + + session = user.user_sessions.last + expect(session.browser_name).to eq('Safari') + expect(session.platform_name).to eq('macOS') + end + end + end end diff --git a/spec/jobs/user_session_ip_lookup_job_spec.rb b/spec/jobs/user_session_ip_lookup_job_spec.rb new file mode 100644 index 000000000..8d7d08a33 --- /dev/null +++ b/spec/jobs/user_session_ip_lookup_job_spec.rb @@ -0,0 +1,45 @@ +require 'rails_helper' + +RSpec.describe UserSessionIpLookupJob do + let(:user) { create(:user) } + let(:session) { user.user_sessions.create!(client_id: 'c', ip_address: '8.8.8.8', last_activity_at: Time.current) } + let(:geo_result) { OpenStruct.new(city: 'Mountain View', country: 'United States', country_code: 'US') } + let(:ip_lookup) { instance_double(IpLookupService) } + + before { allow(IpLookupService).to receive(:new).and_return(ip_lookup) } + + it 'backfills geo data on the session' do + allow(ip_lookup).to receive(:perform).with('8.8.8.8').and_return(geo_result) + + described_class.perform_now(session) + + session.reload + expect(session.city).to eq('Mountain View') + expect(session.country).to eq('United States') + expect(session.country_code).to eq('US') + end + + it 'is a no-op when ip_address is blank' do + session.update_columns(ip_address: nil) # rubocop:disable Rails/SkipsModelValidations + + described_class.perform_now(session) + + expect(IpLookupService).not_to have_received(:new) + end + + it 'leaves the session untouched when lookup returns nil' do + allow(ip_lookup).to receive(:perform).and_return(nil) + + described_class.perform_now(session) + + session.reload + expect(session.city).to be_nil + expect(session.country).to be_nil + end + + it 'swallows lookup errors so a flaky geocoder does not poison the queue' do + allow(ip_lookup).to receive(:perform).and_raise(StandardError.new('boom')) + + expect { described_class.perform_now(session) }.not_to raise_error + end +end diff --git a/spec/models/user_session_spec.rb b/spec/models/user_session_spec.rb new file mode 100644 index 000000000..0c8fa6cf1 --- /dev/null +++ b/spec/models/user_session_spec.rb @@ -0,0 +1,61 @@ +require 'rails_helper' + +RSpec.describe UserSession do + let(:user) { create(:user) } + + describe 'associations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + subject { described_class.new(user: user, client_id: 'abc') } + + it { is_expected.to validate_presence_of(:client_id) } + + it 'validates uniqueness of client_id scoped to user_id' do + described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) + + duplicate = described_class.new(user: user, client_id: 'abc') + expect(duplicate).not_to be_valid + expect(duplicate.errors[:client_id]).to be_present + end + + it 'allows the same client_id for different users' do + other = create(:user) + described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) + + expect(described_class.new(user: other, client_id: 'abc', last_activity_at: Time.current)).to be_valid + end + end + + describe '#current?' do + let(:session) { described_class.create!(user: user, client_id: 'abc', last_activity_at: Time.current) } + + it 'returns true when client_id matches' do + expect(session.current?('abc')).to be true + end + + it 'returns false when client_id differs' do + expect(session.current?('xyz')).to be false + end + end + + describe '#should_update_activity?' do + let(:session) { described_class.new(user: user, client_id: 'abc') } + + it 'returns true when last_activity_at is nil' do + session.last_activity_at = nil + expect(session.should_update_activity?).to be true + end + + it 'returns true when last_activity_at is older than the throttle window' do + session.last_activity_at = 10.minutes.ago + expect(session.should_update_activity?).to be true + end + + it 'returns false when last_activity_at is within the throttle window' do + session.last_activity_at = 1.minute.ago + expect(session.should_update_activity?).to be false + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d263708af..fc7a8953b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -254,4 +254,37 @@ RSpec.describe User do end end end + + describe 'sync_user_sessions callback' do + let(:user_with_tokens) do + u = create(:user) + u.tokens = { + 'client-a' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }, + 'client-b' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i } + } + u.save! + u.user_sessions.create!(client_id: 'client-a', last_activity_at: Time.current) + u.user_sessions.create!(client_id: 'client-b', last_activity_at: Time.current) + u + end + + it 'destroys user_sessions whose client_id is no longer in tokens' do + user_with_tokens.tokens = user_with_tokens.tokens.except('client-a') + + expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-1) + expect(user_with_tokens.user_sessions.pluck(:client_id)).to eq(['client-b']) + end + + it 'leaves user_sessions alone when tokens did not change' do + user_with_tokens.update!(name: 'New Name') + + expect(user_with_tokens.user_sessions.count).to eq(2) + end + + it 'destroys all user_sessions when tokens is cleared' do + user_with_tokens.tokens = {} + + expect { user_with_tokens.save! }.to change(user_with_tokens.user_sessions, :count).by(-2) + end + end end diff --git a/spec/requests/api/v1/profile/sessions_controller_spec.rb b/spec/requests/api/v1/profile/sessions_controller_spec.rb new file mode 100644 index 000000000..239fa8e83 --- /dev/null +++ b/spec/requests/api/v1/profile/sessions_controller_spec.rb @@ -0,0 +1,101 @@ +require 'rails_helper' + +RSpec.describe 'Profile Sessions API', type: :request do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + let(:auth_headers) { user.create_new_auth_token } + let(:current_client_id) { auth_headers['client'] } + + describe 'GET /api/v1/profile/sessions' do + it 'returns 401 without auth' do + get '/api/v1/profile/sessions', as: :json + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns the current user sessions ordered by last_activity_at desc' do + older = user.user_sessions.create!(client_id: current_client_id, browser_name: 'Chrome', last_activity_at: 2.days.ago) + newer = user.user_sessions.create!(client_id: 'other-client', browser_name: 'Firefox', last_activity_at: 1.hour.ago) + user.update!(tokens: user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i })) + + get '/api/v1/profile/sessions', headers: auth_headers, as: :json + + expect(response).to have_http_status(:success) + sessions = response.parsed_body + expect(sessions.map { |s| s['id'] }).to eq([newer.id, older.id]) + expect(sessions.find { |s| s['id'] == older.id }['current']).to be true + expect(sessions.find { |s| s['id'] == newer.id }['current']).to be false + end + + it 'excludes sessions whose token has expired' do + live = user.user_sessions.create!(client_id: current_client_id, last_activity_at: 1.hour.ago) + expired = user.user_sessions.create!(client_id: 'expired-client', last_activity_at: 1.day.ago) + user.update!(tokens: user.tokens.merge('expired-client' => { 'token' => 'x', 'expiry' => 1.day.ago.to_i })) + + get '/api/v1/profile/sessions', headers: auth_headers, as: :json + + expect(response).to have_http_status(:success) + ids = response.parsed_body.map { |s| s['id'] } + expect(ids).to include(live.id) + expect(ids).not_to include(expired.id) + end + + it 'returns an empty array when no sessions exist' do + get '/api/v1/profile/sessions', headers: auth_headers, as: :json + + expect(response).to have_http_status(:success) + expect(response.parsed_body).to eq([]) + end + end + + describe 'DELETE /api/v1/profile/sessions/:id' do + let!(:other_session) { user.user_sessions.create!(client_id: 'other-client', last_activity_at: 1.hour.ago) } + + before do + # Seed tokens hash so revoke can clean it up + user.tokens = user.tokens.merge('other-client' => { 'token' => 'x', 'expiry' => 1.month.from_now.to_i }) + user.save! + end + + it 'destroys the session and removes its token entry' do + expect do + delete "/api/v1/profile/sessions/#{other_session.id}", headers: auth_headers, as: :json + end.to change(user.user_sessions, :count).by(-1) + + expect(response).to have_http_status(:ok) + expect(user.reload.tokens.keys).not_to include('other-client') + end + + it 'returns 422 when trying to revoke the current session' do + current = user.user_sessions.create!(client_id: current_client_id, last_activity_at: Time.current) + + delete "/api/v1/profile/sessions/#{current.id}", headers: auth_headers, as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body['error']).to be_present + expect(user.user_sessions.exists?(id: current.id)).to be true + end + + it 'returns 404 for a nonexistent session id' do + delete '/api/v1/profile/sessions/9999999', headers: auth_headers, as: :json + + expect(response).to have_http_status(:not_found) + end + + it 'does not allow revoking another user' do + other_user = create(:user, account: account) + foreign = other_user.user_sessions.create!(client_id: 'foreign', last_activity_at: 1.hour.ago) + + delete "/api/v1/profile/sessions/#{foreign.id}", headers: auth_headers, as: :json + + expect(response).to have_http_status(:not_found) + expect(other_user.user_sessions.exists?(id: foreign.id)).to be true + end + + it 'returns 401 without auth' do + delete "/api/v1/profile/sessions/#{other_session.id}", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end +end diff --git a/spec/services/user_session_tracking_service_spec.rb b/spec/services/user_session_tracking_service_spec.rb new file mode 100644 index 000000000..71b6d5924 --- /dev/null +++ b/spec/services/user_session_tracking_service_spec.rb @@ -0,0 +1,82 @@ +require 'rails_helper' + +RSpec.describe UserSessionTrackingService do + let(:user) { create(:user) } + let(:client_id) { 'client-abc' } + let(:request) do + instance_double( + ActionDispatch::Request, + user_agent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15', + remote_ip: '8.8.8.8' + ) + end + let(:service) { described_class.new(user: user, request: request, client_id: client_id) } + + describe '#create_or_update!' do + it 'creates a new UserSession with the right client_id and timestamps' do + expect { service.create_or_update! }.to change(user.user_sessions, :count).by(1) + + session = user.user_sessions.last + expect(session.client_id).to eq(client_id) + expect(session.last_activity_at).to be_within(1.second).of(Time.current) + end + + it 'populates request and browser metadata synchronously', :aggregate_failures do + service.create_or_update! + + session = user.user_sessions.last + expect(session.ip_address).to eq('8.8.8.8') + expect(session.browser_name).to eq('Safari') + expect(session.platform_name).to eq('macOS') + end + + it 'does not call IpLookupService synchronously' do + expect(IpLookupService).not_to receive(:new) + + service.create_or_update! + end + + it 'enqueues UserSessionIpLookupJob to backfill geo data' do + expect { service.create_or_update! }.to have_enqueued_job(UserSessionIpLookupJob) + end + + it 'updates an existing session when client_id matches' do + existing = user.user_sessions.create!(client_id: client_id, ip_address: '1.1.1.1', last_activity_at: 1.day.ago) + + expect { service.create_or_update! }.not_to change(user.user_sessions, :count) + expect(existing.reload.ip_address).to eq('8.8.8.8') + expect(existing.last_activity_at).to be_within(1.second).of(Time.current) + end + end + + describe '#update_activity!' do + it 'does nothing when no session exists for the client_id' do + expect { service.update_activity! }.not_to change(user.user_sessions, :count) + end + + it 'does nothing when the session was recently active' do + session = user.user_sessions.create!(client_id: client_id, last_activity_at: 1.minute.ago) + before_ts = session.last_activity_at + + service.update_activity! + + expect(session.reload.last_activity_at).to be_within(1.second).of(before_ts) + end + + it 'bumps last_activity_at when the session is stale' do + session = user.user_sessions.create!(client_id: client_id, last_activity_at: 10.minutes.ago) + + service.update_activity! + + expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current) + end + + it 'bumps last_activity_at when last_activity_at is nil' do + session = user.user_sessions.create!(client_id: client_id, last_activity_at: nil) + + service.update_activity! + + expect(session.reload.last_activity_at).to be_within(1.second).of(Time.current) + end + end +end