+
+
+
+
+
-
+ { '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
+
+ it 'does not count expired tokens toward the cap' do
+ request.env['HTTP_USER_AGENT'] = browser_ua
+ # 3 expired + 2 active = 5 raw entries, but only 2 active
+ 3.times { |i| seed_token("expired#{i}", expiry_offset_days: -1, with_session: false) }
+ 2.times { |i| seed_token("active#{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 '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)
+ 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')