Skip to content

Commit b74d163

Browse files
committed
test: add ttl precedence and timestamps opt-in coverage
1 parent 46b699e commit b74d163

4 files changed

Lines changed: 197 additions & 17 deletions

File tree

src/lib/MongoStore.spec.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createStoreHelper,
88
makeData,
99
makeDataNoCookie,
10+
makeCookie,
1011
} from '../test/testHelper.js'
1112

1213
let { store, storePromise } = createStoreHelper()
@@ -233,6 +234,48 @@ test.serial('set with no stringify', async (t) => {
233234
t.is(await storePromise.length(), 0)
234235
})
235236

237+
test.serial(
238+
'ttl uses cookie.maxAge before cookie.expires and ttl fallback',
239+
async (t) => {
240+
// Choose distinct magnitudes so ordering is unambiguous: 2s < 30s < 90s
241+
const defaultTtl = 30_000
242+
;({ store, storePromise } = createStoreHelper({ ttl: defaultTtl / 1000 }))
243+
const cookieMaxAge = makeCookie()
244+
const sid = 'ttl-precedence'
245+
cookieMaxAge.maxAge = 2_000
246+
const sessionData = { foo: 'ttl', cookie: cookieMaxAge }
247+
248+
// @ts-ignore
249+
await storePromise.set(sid, sessionData)
250+
const collection = await store.collectionP
251+
const doc = await collection.findOne({ _id: sid })
252+
253+
// separate cookie with only expires set to test precedence
254+
const cookieExpires = makeCookie()
255+
cookieExpires.maxAge = undefined
256+
cookieExpires.expires = new Date(Date.now() + 90_000)
257+
const sid2 = 'ttl-precedence-expires'
258+
// @ts-ignore
259+
await storePromise.set(sid2, { foo: 'ttl2', cookie: cookieExpires })
260+
const doc2 = await collection.findOne({ _id: sid2 })
261+
262+
// remove both to test ttl fallback
263+
const sid3 = 'ttl-precedence-ttl'
264+
// @ts-ignore
265+
await storePromise.set(sid3, { foo: 'ttl3' })
266+
const doc3 = await collection.findOne({ _id: sid3 })
267+
268+
const expMs = doc?.expires?.getTime() ?? 0
269+
const expMs2 = doc2?.expires?.getTime() ?? 0
270+
const expMs3 = doc3?.expires?.getTime() ?? 0
271+
272+
t.true(expMs > 0 && expMs2 > 0 && expMs3 > 0)
273+
// ordering: maxAge (2s) < ttl fallback (30s) < cookie.expires (90s)
274+
t.true(expMs < expMs3)
275+
t.true(expMs3 < expMs2)
276+
}
277+
)
278+
236279
test.serial('clear preserves TTL index and is idempotent', async (t) => {
237280
;({ store, storePromise } = createStoreHelper({ autoRemove: 'native' }))
238281
const collection = await store.collectionP
@@ -548,6 +591,33 @@ test.serial('touch ops with touchAfter with touch', async (t) => {
548591
}
549592
})
550593

594+
test.serial(
595+
'touchAfter throttle keeps updatedAt unchanged until threshold when timestamps on',
596+
async (t) => {
597+
;({ store, storePromise } = createStoreHelper({
598+
touchAfter: 1,
599+
timestamps: true,
600+
}))
601+
const sid = 'touchAfter-timestamps'
602+
// @ts-ignore
603+
await storePromise.set(sid, makeDataNoCookie())
604+
const collection = await store.collectionP
605+
const doc = await collection.findOne({ _id: sid })
606+
const initialUpdated = doc?.updatedAt?.getTime()
607+
608+
const sessionWithMeta = await storePromise.get(sid)
609+
await storePromise.touch(sid, sessionWithMeta as SessionData)
610+
const docNoUpdate = await collection.findOne({ _id: sid })
611+
t.is(docNoUpdate?.updatedAt?.getTime(), initialUpdated)
612+
613+
await new Promise((resolve) => setTimeout(resolve, 1100))
614+
const sessionWithMetaAfterWait = await storePromise.get(sid)
615+
await storePromise.touch(sid, sessionWithMetaAfterWait as SessionData)
616+
const docUpdated = await collection.findOne({ _id: sid })
617+
t.truthy((docUpdated?.updatedAt?.getTime() ?? 0) > (initialUpdated ?? 0))
618+
}
619+
)
620+
551621
test.serial('basic operation flow with crypto', async (t) => {
552622
;({ store, storePromise } = createStoreHelper({
553623
crypto: { secret: 'secret' },
@@ -567,6 +637,90 @@ test.serial('basic operation flow with crypto', async (t) => {
567637
t.is(sessions?.length, 1)
568638
})
569639

640+
test.serial('crypto with stringify=false roundtrips raw objects', async (t) => {
641+
;({ store, storePromise } = createStoreHelper({
642+
crypto: { secret: 'secret' },
643+
stringify: false,
644+
collectionName: 'crypto-no-stringify',
645+
}))
646+
const sid = 'crypto-no-stringify'
647+
const payload = makeDataNoCookie()
648+
// @ts-ignore
649+
await storePromise.set(sid, payload)
650+
const session = await storePromise.get(sid)
651+
t.deepEqual(session, payload)
652+
})
653+
654+
test.serial(
655+
'transformId stores and retrieves using transformed key',
656+
async (t) => {
657+
const transformId = (sid: string) => `t-${sid}`
658+
;({ store, storePromise } = createStoreHelper({ transformId }))
659+
const sid = 'transform-id'
660+
await storePromise.set(sid, makeData())
661+
const collection = await store.collectionP
662+
const doc = await collection.findOne({ _id: transformId(sid) })
663+
t.truthy(doc)
664+
const session = await storePromise.get(sid)
665+
t.truthy(session)
666+
}
667+
)
668+
669+
test.serial('writeOperationOptions forwarded to updateOne', async (t) => {
670+
const calls: any[] = []
671+
const fakeCollection = {
672+
createIndex: () => Promise.resolve(),
673+
updateOne: (...args: any[]) => {
674+
calls.push(args)
675+
return Promise.resolve({ upsertedCount: 1 })
676+
},
677+
}
678+
const fakeClient = {
679+
db: () => ({ collection: () => fakeCollection }),
680+
close: () => Promise.resolve(),
681+
}
682+
683+
const writeConcern = { w: 0 as const }
684+
const localStore = MongoStore.create({
685+
clientPromise: Promise.resolve(fakeClient as unknown as MongoClient),
686+
writeOperationOptions: writeConcern,
687+
collectionName: 'wopts',
688+
dbName: 'wopts-db',
689+
})
690+
await new Promise<void>((resolve, reject) =>
691+
localStore.set('wopts', makeData(), (err) =>
692+
err ? reject(err) : resolve()
693+
)
694+
)
695+
t.true(calls.length > 0)
696+
const opts = calls[0]?.[2]
697+
t.deepEqual(opts?.writeConcern, writeConcern)
698+
await localStore.close()
699+
})
700+
701+
test.serial('custom serializer error surfaces from set()', async (t) => {
702+
const boom = new Error('serialize-fail')
703+
;({ store, storePromise } = createStoreHelper({
704+
serialize: () => {
705+
throw boom
706+
},
707+
}))
708+
const sid = 'serializer-error'
709+
await t.throwsAsync(() => storePromise.set(sid, makeData()), {
710+
message: boom.message,
711+
})
712+
})
713+
714+
test.serial('corrupted JSON payload bubbles error on get', async (t) => {
715+
;({ store, storePromise } = createStoreHelper())
716+
const collection = await store.collectionP
717+
await collection.insertOne({
718+
_id: 'corrupt-json',
719+
session: '{bad json',
720+
})
721+
await t.throwsAsync(() => storePromise.get('corrupt-json'))
722+
})
723+
570724
test.serial('with touch after and get non-exist session', async (t) => {
571725
;({ store, storePromise } = createStoreHelper({
572726
touchAfter: 10,

src/lib/MongoStore.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,18 @@ function computeTransformFunctions<T extends session.SessionData>(
143143
}
144144
}
145145

146+
function computeExpires(
147+
session: session.SessionData | undefined,
148+
fallbackTtlSeconds: number
149+
): Date {
150+
const cookie = session?.cookie as session.Cookie | undefined
151+
if (cookie?.expires) {
152+
return new Date(cookie.expires)
153+
}
154+
const now = Date.now()
155+
return new Date(now + fallbackTtlSeconds * 1000)
156+
}
157+
146158
export default class MongoStore<
147159
T extends session.SessionData = session.SessionData,
148160
> extends session.Store {
@@ -379,18 +391,7 @@ export default class MongoStore<
379391
session: this.transformFunctions.serialize(session),
380392
}
381393
// Expire handling
382-
if (session?.cookie?.expires) {
383-
s.expires = new Date(session.cookie.expires)
384-
} else {
385-
// If there's no expiration date specified, it is
386-
// browser-session cookie or there is no cookie at all,
387-
// as per the connect docs.
388-
//
389-
// So we set the expiration to two-weeks from now
390-
// - as is common practice in the industry (e.g Django) -
391-
// or the default specified in the options.
392-
s.expires = new Date(Date.now() + this.options.ttl * 1000)
393-
}
394+
s.expires = computeExpires(session, this.options.ttl)
394395
// Last modify handling
395396
if (this.options.touchAfter > 0) {
396397
s.lastModified = new Date()
@@ -457,11 +458,7 @@ export default class MongoStore<
457458
updateFields.lastModified = currentDate
458459
}
459460

460-
if (session?.cookie?.expires) {
461-
updateFields.expires = new Date(session.cookie.expires)
462-
} else {
463-
updateFields.expires = new Date(Date.now() + this.options.ttl * 1000)
464-
}
461+
updateFields.expires = computeExpires(session, this.options.ttl)
465462
const collection = await this.collectionP
466463
const updateQuery: Record<string, unknown> = { $set: updateFields }
467464
if (this.options.timestamps) {

src/test/integration.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare module 'express-session' {
1414
type AgentWithCleanup = {
1515
agent: ReturnType<typeof request.agent>
1616
cleanup: () => Promise<void>
17+
store: MongoStore
1718
}
1819

1920
function createSupertestAgent(
@@ -42,6 +43,7 @@ function createSupertestAgent(
4243
const agent = request.agent(app)
4344
return {
4445
agent,
46+
store,
4547
cleanup: async () => {
4648
await store.close()
4749
},
@@ -91,3 +93,29 @@ test.serial('simple case with touch after', async (t) => {
9193
await cleanup()
9294
}
9395
})
96+
97+
test.serial(
98+
'timestamps option adds createdAt/updatedAt in integration flow',
99+
async (t) => {
100+
const { agent, cleanup, store } = createSupertestAgentWithDefault(
101+
{ resave: false, saveUninitialized: false, rolling: true },
102+
{ timestamps: true, collectionName: 'integration-timestamps' }
103+
)
104+
105+
try {
106+
await agent.get('/').expect(200)
107+
const collection = await store.collectionP
108+
const doc = await collection.findOne({})
109+
t.truthy(doc?.createdAt)
110+
t.truthy(doc?.updatedAt)
111+
112+
const firstUpdated = doc?.updatedAt?.getTime()
113+
await new Promise((resolve) => setTimeout(resolve, 20))
114+
await agent.get('/ping').expect(200)
115+
const doc2 = await collection.findOne({ _id: doc?._id })
116+
t.truthy((doc2?.updatedAt?.getTime() ?? 0) >= (firstUpdated ?? 0))
117+
} finally {
118+
await cleanup()
119+
}
120+
}
121+
)

src/test/testHelper.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const createStoreHelper = (opt: Partial<ConnectMongoOptions> = {}) => {
4545
mongoOptions: {},
4646
dbName: 'testDb',
4747
collectionName: 'test-collection',
48+
autoRemove: 'disabled',
4849
...opt,
4950
})
5051

0 commit comments

Comments
 (0)