@@ -13,6 +13,7 @@ import type { InternalSessionRow } from '../types'
1313const SESSION_LEN = 60 * 60 * 1000
1414const TICK_MS = 15_000
1515const ADMITS_PER_TICK = 1
16+ const GRACE_MS = 30 * 60 * 1000
1617
1718function makeDeps ( overrides : Partial < SessionDeps > = { } ) : SessionDeps & {
1819 rows : Map < string , InternalSessionRow >
@@ -38,6 +39,7 @@ function makeDeps(overrides: Partial<SessionDeps> = {}): SessionDeps & {
3839 isWaitingRoomEnabled : ( ) => true ,
3940 getAdmissionTickMs : ( ) => TICK_MS ,
4041 getMaxAdmitsPerTick : ( ) => ADMITS_PER_TICK ,
42+ getSessionGraceMs : ( ) => GRACE_MS ,
4143 now : ( ) => currentNow ,
4244 getSessionRow : async ( userId ) => rows . get ( userId ) ?? null ,
4345 endSession : async ( userId ) => {
@@ -250,12 +252,30 @@ describe('checkSessionAdmissible', () => {
250252 expect ( result . code ) . toBe ( 'session_superseded' )
251253 } )
252254
253- test ( 'active but expires_at in the past → session_expired' , async ( ) => {
255+ test ( 'active inside grace window → ok with reason=draining' , async ( ) => {
256+ await requestSession ( { userId : 'u1' , deps } )
257+ const row = deps . rows . get ( 'u1' ) !
258+ row . status = 'active'
259+ row . admitted_at = new Date ( deps . _now ( ) . getTime ( ) - SESSION_LEN - 60_000 )
260+ // 1 minute past expiry, well within the 30-minute grace window
261+ row . expires_at = new Date ( deps . _now ( ) . getTime ( ) - 60_000 )
262+
263+ const result = await checkSessionAdmissible ( {
264+ userId : 'u1' ,
265+ claimedInstanceId : row . active_instance_id ,
266+ deps,
267+ } )
268+ expect ( result . ok ) . toBe ( true )
269+ if ( ! result . ok || result . reason !== 'draining' ) throw new Error ( 'unreachable' )
270+ expect ( result . gracePeriodRemainingMs ) . toBe ( GRACE_MS - 60_000 )
271+ } )
272+
273+ test ( 'active past the grace window → session_expired' , async ( ) => {
254274 await requestSession ( { userId : 'u1' , deps } )
255275 const row = deps . rows . get ( 'u1' ) !
256276 row . status = 'active'
257277 row . admitted_at = new Date ( deps . _now ( ) . getTime ( ) - 2 * SESSION_LEN )
258- row . expires_at = new Date ( deps . _now ( ) . getTime ( ) - 1 )
278+ row . expires_at = new Date ( deps . _now ( ) . getTime ( ) - GRACE_MS - 1 )
259279
260280 const result = await checkSessionAdmissible ( {
261281 userId : 'u1' ,
@@ -265,6 +285,22 @@ describe('checkSessionAdmissible', () => {
265285 if ( result . ok ) throw new Error ( 'unreachable' )
266286 expect ( result . code ) . toBe ( 'session_expired' )
267287 } )
288+
289+ test ( 'draining + wrong instance id still rejects with session_superseded' , async ( ) => {
290+ await requestSession ( { userId : 'u1' , deps } )
291+ const row = deps . rows . get ( 'u1' ) !
292+ row . status = 'active'
293+ row . admitted_at = new Date ( deps . _now ( ) . getTime ( ) - SESSION_LEN - 60_000 )
294+ row . expires_at = new Date ( deps . _now ( ) . getTime ( ) - 60_000 )
295+
296+ const result = await checkSessionAdmissible ( {
297+ userId : 'u1' ,
298+ claimedInstanceId : 'stale-token' ,
299+ deps,
300+ } )
301+ if ( result . ok ) throw new Error ( 'unreachable' )
302+ expect ( result . code ) . toBe ( 'session_superseded' )
303+ } )
268304} )
269305
270306describe ( 'endUserSession' , ( ) => {
0 commit comments