diff --git a/spec/controllers/devise_overrides/sessions_controller_spec.rb b/spec/controllers/devise_overrides/sessions_controller_spec.rb index 8ee012670..9a51a4a32 100644 --- a/spec/controllers/devise_overrides/sessions_controller_spec.rb +++ b/spec/controllers/devise_overrides/sessions_controller_spec.rb @@ -163,4 +163,156 @@ RSpec.describe DeviseOverrides::SessionsController, type: :controller do expect(response).to redirect_to('/frontend/app/login?error=access-denied') end end + + describe 'session limit enforcement' 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' } + let(:mobile_ua) { 'okhttp/4.9.3' } + + def seed_token(client_id, expiry_offset_days: 30, with_session: true) + user.tokens = user.tokens.merge( + client_id => { 'token' => 'x', 'expiry' => (Time.current + expiry_offset_days.days).to_i } + ) + user.save! + user.user_sessions.create!(client_id: client_id, last_activity_at: Time.current) if with_session + end + + def login_params + { email: user.email, password: 'Test@123456' } + end + + context 'when under the limit' do + it 'allows login without intervention' do + request.env['HTTP_USER_AGENT'] = browser_ua + 3.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + + post :create, params: login_params + + expect(response).to have_http_status(:success) + end + end + + context 'when at the limit from a browser with full tracking' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + end + + it 'returns 409 with the session list (picker)' do + post :create, params: login_params + + expect(response).to have_http_status(:conflict) + body = response.parsed_body + expect(body['sessions_limit_reached']).to be true + expect(body['sessions'].size).to eq(5) + end + + it 'does not create a new session row' do + expect { post :create, params: login_params }.not_to change(user.user_sessions, :count) + end + end + + context 'when at the limit from a non-browser client' do + before do + request.env['HTTP_USER_AGENT'] = mobile_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30 + i, with_session: false) } + end + + it 'silently evicts the oldest token and lets login proceed' do + post :create, params: login_params + + expect(response).to have_http_status(:success) + expect(user.reload.tokens.keys).not_to include('c0') + end + end + + context 'when at the limit but tracking is partial (legacy tokens present)' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + # one tracked, four legacy (no user_session rows) + seed_token('tracked', expiry_offset_days: 60, with_session: true) + 4.times { |i| seed_token("legacy#{i}", expiry_offset_days: 10 + i, with_session: false) } + end + + it 'silent-evicts instead of showing a partial picker' do + post :create, params: login_params + + expect(response).to have_http_status(:success) + end + + it 'drops an untracked token first, keeping the tracked session alive' do + post :create, params: login_params + + tokens = user.reload.tokens.keys + expect(tokens).to include('tracked') + # legacy0 expires soonest -> evict_oldest_token picks it + expect(tokens).not_to include('legacy0') + end + end + + context 'when at the limit with full tracking (no legacy gap)' do + before do + request.env['HTTP_USER_AGENT'] = mobile_ua + # Five tracked sessions, varying activity timestamps + 5.times do |i| + seed_token("tracked#{i}", expiry_offset_days: 30) + user.user_sessions.find_by(client_id: "tracked#{i}").update!(last_activity_at: (5 - i).days.ago) + end + end + + it 'evicts the oldest tracked session by last_activity_at' do + post :create, params: login_params + + expect(response).to have_http_status(:success) + # tracked0 had the oldest last_activity_at (5 days ago) + expect(user.reload.tokens.keys).not_to include('tracked0') + expect(user.user_sessions.exists?(client_id: 'tracked0')).to be false + end + end + + context 'with revoke_session_id during login' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + end + + it 'revokes the chosen session and proceeds with login' do + target = user.user_sessions.find_by(client_id: 'c2') + + post :create, params: login_params.merge(revoke_session_id: target.id) + + expect(response).to have_http_status(:success) + expect(user.reload.tokens.keys).not_to include('c2') + expect(user.user_sessions.exists?(id: target.id)).to be false + end + end + + context 'with revoke_all_sessions during login' do + before do + request.env['HTTP_USER_AGENT'] = browser_ua + 5.times { |i| seed_token("c#{i}", expiry_offset_days: 30) } + end + + it 'wipes all sessions and tokens, then proceeds with login' do + post :create, params: login_params.merge(revoke_all_sessions: true) + + expect(response).to have_http_status(:success) + expect(user.reload.tokens.keys).not_to include('c0', 'c1', 'c2', 'c3', 'c4') + # the new login adds one fresh token + expect(user.tokens.keys.size).to eq(1) + end + end + + context 'on 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: login_params }.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/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..69b180e1a --- /dev/null +++ b/spec/requests/api/v1/profile/sessions_controller_spec.rb @@ -0,0 +1,87 @@ +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) + + 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 '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..c19d2e057 --- /dev/null +++ b/spec/services/user_session_tracking_service_spec.rb @@ -0,0 +1,86 @@ +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) } + + before do + allow_any_instance_of(IpLookupService).to receive(:perform).and_return( + double('GeocoderResult', city: 'Mountain View', country: 'United States', country_code: 'US') + ) + end + + describe '#create_or_update!' do + it 'creates a new UserSession with parsed metadata' 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.ip_address).to eq('8.8.8.8') + expect(session.browser_name).to eq('Safari') + expect(session.platform_name).to eq('macOS') + expect(session.city).to eq('Mountain View') + expect(session.country).to eq('United States') + expect(session.country_code).to eq('US') + expect(session.last_activity_at).to be_within(1.second).of(Time.current) + 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 + + it 'handles missing geo data gracefully' do + allow_any_instance_of(IpLookupService).to receive(:perform).and_return(nil) + + service.create_or_update! + + session = user.user_sessions.last + expect(session.city).to be_nil + expect(session.country).to be_nil + expect(session.country_code).to be_nil + 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