11import { describe , it , expect , beforeEach } from 'vitest'
22import Database from 'better-sqlite3'
33import { initSchema , getUserByUsername } from '../src/db.js'
4- import { loginRateLimited , registerLoginFail , loginOk , registerUser , handleLogin , ensureBootstrapAdmin , changeOwnPassword } from '../src/auth.js'
4+ import { loginRateLimited , registerLoginFail , loginOk , registerUser , handleLogin , ensureBootstrapAdmin , changeOwnPassword , requireAuth } from '../src/auth.js'
55
66function mockContext ( ip , cookie ) {
77 const headers = new Headers ( )
@@ -13,6 +13,7 @@ function mockContext(ip, cookie) {
1313 raw : { headers } ,
1414 } ,
1515 header : ( ) => { } ,
16+ set : ( ) => { } ,
1617 res : { headers : { append : ( ) => { } } } ,
1718 env : { } ,
1819 }
@@ -112,3 +113,55 @@ describe('rate-limit de login (SQLite)', () => {
112113 expect ( loginRateLimited ( db , b ) ) . toBe ( false )
113114 } )
114115} )
116+
117+ describe ( 'expiración deslizante de sesiones (#130)' , ( ) => {
118+ const TTL_MS = 30 * 24 * 3600 * 1000
119+
120+ async function loginCapturingCookie ( username , password ) {
121+ const c = mockContext ( '1.1.1.1' )
122+ const captured = [ ]
123+ c . header = ( k , v ) => captured . push ( [ k , v ] )
124+ const res = await handleLogin ( db , c , { username, password } )
125+ const setCookie = captured . find ( ( [ k ] ) => k === 'Set-Cookie' ) ?. [ 1 ] || ''
126+ const cookie = setCookie . split ( ';' ) [ 0 ] // helios_session=<id>.<sig>
127+ return { res, cookie }
128+ }
129+
130+ it ( 'sesión por debajo de la mitad del TTL: renueva expires_at y re-emite cookie' , async ( ) => {
131+ await registerUser ( db , 'slide' , 'secreto123' )
132+ const { cookie } = await loginCapturingCookie ( 'slide' , 'secreto123' )
133+ expect ( cookie ) . toMatch ( / ^ h e l i o s _ s e s s i o n = .+ \. .+ / )
134+ const sessionId = cookie . split ( '=' ) [ 1 ] . split ( '.' ) [ 0 ]
135+ const soon = Date . now ( ) + 10 * 24 * 3600 * 1000 // 10 días < 15 (mitad del TTL)
136+ db . prepare ( 'UPDATE sessions SET expires_at = ? WHERE id = ?' ) . run ( soon , sessionId )
137+
138+ const c2 = mockContext ( '1.1.1.1' , cookie )
139+ const captured = [ ]
140+ c2 . header = ( k , v ) => captured . push ( [ k , v ] )
141+ let nextCalled = false
142+ await requireAuth ( db ) ( c2 , async ( ) => { nextCalled = true } )
143+ expect ( nextCalled ) . toBe ( true )
144+ const renewed = captured . find ( ( [ k ] ) => k === 'Set-Cookie' ) ?. [ 1 ] || ''
145+ expect ( renewed ) . toMatch ( / ^ h e l i o s _ s e s s i o n = .+ \. .+ / )
146+ expect ( renewed ) . toMatch ( / M a x - A g e = 2 5 9 2 0 0 0 / )
147+ const after = db . prepare ( 'SELECT expires_at FROM sessions WHERE id = ?' ) . get ( sessionId ) . expires_at
148+ expect ( after ) . toBeGreaterThan ( Date . now ( ) + 25 * 24 * 3600 * 1000 )
149+ } )
150+
151+ it ( 'sesión por encima de la mitad del TTL: no renueva ni re-emite cookie' , async ( ) => {
152+ await registerUser ( db , 'fresh' , 'secreto123' )
153+ const { cookie } = await loginCapturingCookie ( 'fresh' , 'secreto123' )
154+ const sessionId = cookie . split ( '=' ) [ 1 ] . split ( '.' ) [ 0 ]
155+ const fresh = Date . now ( ) + 20 * 24 * 3600 * 1000 // 20 días > 15
156+ db . prepare ( 'UPDATE sessions SET expires_at = ? WHERE id = ?' ) . run ( fresh , sessionId )
157+
158+ const c2 = mockContext ( '1.1.1.1' , cookie )
159+ const captured = [ ]
160+ c2 . header = ( k , v ) => captured . push ( [ k , v ] )
161+ let nextCalled = false
162+ await requireAuth ( db ) ( c2 , async ( ) => { nextCalled = true } )
163+ expect ( nextCalled ) . toBe ( true )
164+ expect ( captured . find ( ( [ k ] ) => k === 'Set-Cookie' ) ) . toBeUndefined ( )
165+ expect ( db . prepare ( 'SELECT expires_at FROM sessions WHERE id = ?' ) . get ( sessionId ) . expires_at ) . toBe ( fresh )
166+ } )
167+ } )
0 commit comments