Skip to content

Commit 8ecfd88

Browse files
committed
fix: add 5.1→current upgrade compat test
1 parent b039309 commit 8ecfd88

File tree

3 files changed

+224
-0
lines changed

3 files changed

+224
-0
lines changed

docs/PLANS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
mongodb@x in a temporary workspace, waits for Mongo 7.x containers to report ready, and always runs docker compose down in a finally step.
4646
- Critical behaviors lack automated coverage: the unit/integration suites don't cover crypto, autoRemove, touchAfter, or transformId at all—only the happy-path AVA specs exist (src/lib/MongoStore.spec.ts:1-154, src/test/integration.spec.ts:1-72). Add targeted tests using mongodb-memory-server to keep CI fast and
4747
remove the hard-coded mongodb://root:example@127.0.0.1 dependency.
48+
- [started 2025-11-24] Add live-Mongo upgrade compat test (5.1.0 ➜ current) covering crypto/non-crypto sessions, rolling/touchAfter with cookie.maxAge, TTL index stability (autoRemove: 'native'), and client ownership; expose via `yarn test:compat` (manual run).
4849
- Publishing still relies on humans running yarn build && yarn test && npm publish; there's no prepublishOnly hook or release workflow (README.md:318-336). Wire up standard-version + GitHub Actions to cut releases, publish to npm, upload coverage (since you already call codecov), and tag automatically.
4950

5051
- Docs & Community

src/test/upgrade-compat.spec.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import test, { type ExecutionContext } from 'ava'
2+
import express from 'express'
3+
import session from 'express-session'
4+
import request from 'supertest'
5+
import { execFileSync } from 'node:child_process'
6+
import { mkdtempSync, rmSync, existsSync, symlinkSync } from 'node:fs'
7+
import { MongoClient, Collection } from 'mongodb'
8+
import { createRequire } from 'node:module'
9+
import { tmpdir } from 'node:os'
10+
import { dirname, join } from 'node:path'
11+
import { fileURLToPath } from 'node:url'
12+
13+
const SESSION_SECRET = 'upgrade-session-secret'
14+
const CRYPTO_SECRET = 'upgrade-crypto-secret'
15+
const COOKIE_MAX_AGE_MS = 5 * 60 * 1000
16+
const MONGO_URL =
17+
process.env.MONGO_URL ?? 'mongodb://root:[email protected]:27017'
18+
const FIXTURE_TGZ = join(
19+
dirname(fileURLToPath(import.meta.url)),
20+
'..',
21+
'..',
22+
'test-fixtures',
23+
'connect-mongo-5.1.0.tgz'
24+
)
25+
const PROJECT_NODE_MODULES = join(
26+
dirname(fileURLToPath(import.meta.url)),
27+
'..',
28+
'..',
29+
'node_modules'
30+
)
31+
32+
type StoreCtor = {
33+
create: (opts: any) => any
34+
}
35+
36+
const getTTLIndex = async (collection: Collection) => {
37+
const indexes = await collection.listIndexes().toArray()
38+
return indexes.find((idx) => idx.name === 'expires_1')
39+
}
40+
41+
const ensureFixturePresent = (t: ExecutionContext) => {
42+
if (!existsSync(FIXTURE_TGZ)) {
43+
t.fail(
44+
`Missing ${FIXTURE_TGZ}. Run "npm pack [email protected] --pack-destination test-fixtures --cache ./.npm-cache".`
45+
)
46+
}
47+
}
48+
49+
const unpackOldPackage = () => {
50+
const tmpDir = mkdtempSync(join(tmpdir(), 'connect-mongo-5.1.0-'))
51+
// Node core lacks tar extraction; rely on the system tar available in dev envs.
52+
execFileSync('tar', ['-xzf', FIXTURE_TGZ, '-C', tmpDir])
53+
const packageRoot = join(tmpDir, 'package')
54+
const linkedNodeModules = join(packageRoot, 'node_modules')
55+
if (!existsSync(linkedNodeModules) && existsSync(PROJECT_NODE_MODULES)) {
56+
symlinkSync(PROJECT_NODE_MODULES, linkedNodeModules, 'dir')
57+
}
58+
return {
59+
packageRoot,
60+
cleanup: () => rmSync(tmpDir, { recursive: true, force: true }),
61+
}
62+
}
63+
64+
const loadOldStore = (): { ctor: StoreCtor; cleanup: () => void } => {
65+
const { packageRoot, cleanup } = unpackOldPackage()
66+
const requireFromPkg = createRequire(join(packageRoot, 'package.json'))
67+
const mod = requireFromPkg(packageRoot)
68+
return { ctor: (mod?.default ?? mod) as StoreCtor, cleanup }
69+
}
70+
71+
const buildApp = (Store: StoreCtor, storeOpts: Record<string, unknown>) => {
72+
const app = express()
73+
const store = Store.create({
74+
autoRemove: 'native',
75+
stringify: false,
76+
...storeOpts,
77+
})
78+
app.use(
79+
session({
80+
secret: SESSION_SECRET,
81+
resave: false,
82+
saveUninitialized: false,
83+
rolling: true,
84+
cookie: { maxAge: COOKIE_MAX_AGE_MS },
85+
store,
86+
})
87+
)
88+
app.get('/write', (req, res) => {
89+
req.session.views = (req.session.views ?? 0) + 1
90+
req.session.payload = { nested: 'value' }
91+
res.status(200).json({ views: req.session.views })
92+
})
93+
app.get('/touch', (req, res) => {
94+
req.session.views = (req.session.views ?? 0) + 1
95+
res.status(200).json({ views: req.session.views })
96+
})
97+
app.get('/ping', (req, res) => {
98+
res.status(200).json({ views: req.session?.views ?? null })
99+
})
100+
return { app, store }
101+
}
102+
103+
const seedOldSession = async (
104+
t: ExecutionContext,
105+
collection: Collection,
106+
store: any,
107+
app: express.Express
108+
) => {
109+
await (store.collectionP as Promise<Collection>)
110+
const firstRes = await request(app).get('/write').expect(200)
111+
const cookie = firstRes.headers['set-cookie']?.[0]
112+
t.truthy(cookie, 'old store should issue session cookie')
113+
const trimmedCookie = cookie?.split(';')[0] ?? ''
114+
const secondRes = await request(app)
115+
.get('/write')
116+
.set('Cookie', trimmedCookie)
117+
.expect(200)
118+
t.is(secondRes.body.views, 2)
119+
const ttlIndex = await getTTLIndex(collection)
120+
const doc = await collection.findOne({})
121+
return { cookie: trimmedCookie, ttlIndex, doc }
122+
}
123+
124+
const runUpgradeScenario = async (t: ExecutionContext, crypto: boolean) => {
125+
ensureFixturePresent(t)
126+
const client = await MongoClient.connect(MONGO_URL).catch((err: unknown) => {
127+
t.log(`Mongo unavailable at ${MONGO_URL}: ${String(err)}`)
128+
return null
129+
})
130+
if (!client) return
131+
132+
const dbName = `compat-upgrade-${crypto ? 'crypto' : 'plain'}-${Date.now()}`
133+
const collectionName = `sessions-${crypto ? 'crypto' : 'plain'}`
134+
const db = client.db(dbName)
135+
await db.dropDatabase().catch(() => undefined)
136+
const collection = db.collection(collectionName)
137+
138+
const { ctor: OldStore, cleanup: cleanupPkg } = loadOldStore()
139+
let oldStore: any | undefined
140+
let newStore: any | undefined
141+
142+
try {
143+
const { app: oldApp, store } = buildApp(OldStore, {
144+
mongoUrl: MONGO_URL,
145+
dbName,
146+
collectionName,
147+
touchAfter: 1,
148+
crypto: crypto ? { secret: CRYPTO_SECRET } : undefined,
149+
})
150+
oldStore = store
151+
const {
152+
cookie,
153+
ttlIndex: ttlBefore,
154+
doc: docBefore,
155+
} = await seedOldSession(t, collection, oldStore, oldApp)
156+
t.truthy(ttlBefore, 'TTL index should exist before upgrade')
157+
t.truthy(docBefore?.expires, 'session should have an expires value')
158+
159+
await oldStore.close()
160+
161+
const { app: newApp, store: upgradedStore } = buildApp(
162+
(await import('../lib/MongoStore.js')).default,
163+
{
164+
client,
165+
dbName,
166+
collectionName,
167+
touchAfter: 1,
168+
crypto: crypto ? { secret: CRYPTO_SECRET } : undefined,
169+
}
170+
)
171+
newStore = upgradedStore
172+
173+
const ping = await request(newApp)
174+
.get('/ping')
175+
.set('Cookie', cookie)
176+
.expect(200)
177+
t.is(ping.body.views, 2, 'upgrade should read existing session')
178+
179+
const touch = await request(newApp)
180+
.get('/touch')
181+
.set('Cookie', cookie)
182+
.expect(200)
183+
t.true(touch.body.views >= 3, 'upgrade should be able to update session')
184+
185+
const docAfter = await collection.findOne({})
186+
t.truthy(docAfter?.expires)
187+
if (docAfter?.expires) {
188+
const delta = Math.abs(
189+
docAfter.expires.getTime() - (Date.now() + COOKIE_MAX_AGE_MS)
190+
)
191+
t.true(
192+
delta < 10_000,
193+
'expires should respect cookie.maxAge after upgrade'
194+
)
195+
}
196+
197+
const ttlAfter = await getTTLIndex(collection)
198+
t.truthy(ttlAfter, 'TTL index should persist after upgrade')
199+
t.is(ttlBefore?.expireAfterSeconds, ttlAfter?.expireAfterSeconds)
200+
t.deepEqual(ttlBefore?.key, ttlAfter?.key)
201+
202+
await newStore.close()
203+
await t.throwsAsync(async () => db.command({ ping: 1 }))
204+
} finally {
205+
if (newStore) {
206+
await newStore.close().catch(() => undefined)
207+
}
208+
if (oldStore) {
209+
await oldStore.close().catch(() => undefined)
210+
}
211+
await db.dropDatabase().catch(() => undefined)
212+
await client.close().catch(() => undefined)
213+
cleanupPkg()
214+
}
215+
}
216+
217+
test.serial('upgrade from 5.1.0 preserves non-crypto sessions', async (t) => {
218+
await runUpgradeScenario(t, false)
219+
})
220+
221+
test.serial('upgrade from 5.1.0 preserves crypto sessions', async (t) => {
222+
await runUpgradeScenario(t, true)
223+
})
221 KB
Binary file not shown.

0 commit comments

Comments
 (0)