chore: add specs for session limit feature
This commit is contained in:
committed by
Vishnu Narayanan
parent
fcf660b7d9
commit
2534875cf3
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user