From 575864c6ae7504da15e72e8112a99757f3eee188 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Sun, 19 Jan 2025 15:31:25 +0800 Subject: [PATCH 1/2] feat: support cjs and esm both by tshy (#228) BREAKING CHANGE: drop Node.js < 18.19.0 support --- .eslintrc | 8 +- .github/workflows/nodejs.yml | 32 +- .github/workflows/release.yml | 13 + .gitignore | 3 + History.md => CHANGELOG.md | 0 Readme.md | 80 +- example.cjs | 20 + example.js | 18 - index.js | 151 --- lib/session.js | 163 --- lib/util.js | 39 - package.json | 83 +- lib/context.js => src/context.ts | 212 ++-- src/index.ts | 305 ++++++ src/session.ts | 139 +++ src/util.ts | 26 + test/context_store.js | 25 - test/context_store.ts | 22 + test/contextstore.test.js | 758 -------------- test/contextstore.test.ts | 677 +++++++++++++ test/cookie.test.js | 948 ------------------ test/cookie.test.ts | 900 +++++++++++++++++ test/externalkey.test.js | 59 -- test/externalkey.test.ts | 60 ++ test/genid_bench.js | 45 - test/store.js | 17 - test/store.test.js | 861 ---------------- test/store.test.ts | 800 +++++++++++++++ test/store.ts | 15 + ...ith_ctx.test.js => store_with_ctx.test.ts} | 65 +- test/{store_with_ctx.js => store_with_ctx.ts} | 12 +- tsconfig.json | 10 + 32 files changed, 3258 insertions(+), 3308 deletions(-) create mode 100644 .github/workflows/release.yml rename History.md => CHANGELOG.md (100%) create mode 100644 example.cjs delete mode 100644 example.js delete mode 100644 index.js delete mode 100644 lib/session.js delete mode 100644 lib/util.js rename lib/context.js => src/context.ts (56%) create mode 100644 src/index.ts create mode 100644 src/session.ts create mode 100644 src/util.ts delete mode 100644 test/context_store.js create mode 100644 test/context_store.ts delete mode 100644 test/contextstore.test.js create mode 100644 test/contextstore.test.ts delete mode 100644 test/cookie.test.js create mode 100644 test/cookie.test.ts delete mode 100644 test/externalkey.test.js create mode 100644 test/externalkey.test.ts delete mode 100644 test/genid_bench.js delete mode 100644 test/store.js delete mode 100644 test/store.test.js create mode 100644 test/store.test.ts create mode 100644 test/store.ts rename test/{store_with_ctx.test.js => store_with_ctx.test.ts} (59%) rename test/{store_with_ctx.js => store_with_ctx.ts} (55%) create mode 100644 tsconfig.json diff --git a/.eslintrc b/.eslintrc index 7364c13..9bcdb46 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,6 +1,6 @@ { - "extends": "eslint-config-egg", - "parserOptions": { - "ecmaVersion": 2017 - } + "extends": [ + "eslint-config-egg/typescript", + "eslint-config-egg/lib/rules/enforce-node-prefix" + ] } diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index a46f3e4..c70132d 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,27 +1,17 @@ -name: Node.js CI +name: CI on: push: - branch: master + branches: [ master ] pull_request: - branch: master + branches: [ master ] jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [8, 10, 12, 14, 16, 18] - - steps: - - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm run lint - - run: npm run ci - - run: npx codecov + Job: + name: Node.js + uses: node-modules/github-actions/.github/workflows/node-test.yml@master + with: + os: 'ubuntu-latest, macos-latest, windows-latest' + version: '18.19.0, 18, 20, 22' + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..035a626 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,13 @@ +name: Release + +on: + push: + branches: [ master ] + +jobs: + release: + name: Node.js + uses: koajs/github-actions/.github/workflows/node-release.yml@master + secrets: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} diff --git a/.gitignore b/.gitignore index 659fedb..388e6c8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules coverage *.log +.tshy* +.eslintcache +dist diff --git a/History.md b/CHANGELOG.md similarity index 100% rename from History.md rename to CHANGELOG.md diff --git a/Readme.md b/Readme.md index 03275e1..34ee099 100644 --- a/Readme.md +++ b/Readme.md @@ -2,34 +2,45 @@ [![NPM version][npm-image]][npm-url] [![Node.js CI](https://github.com/koajs/session/actions/workflows/nodejs.yml/badge.svg)](https://github.com/koajs/session/actions/workflows/nodejs.yml) +[![Test coverage][codecov-image]][codecov-url] +[![Known Vulnerabilities][snyk-image]][snyk-url] [![npm download][download-image]][download-url] +[![Node.js Version](https://img.shields.io/node/v/koajs/session.svg?style=flat)](https://nodejs.org/en/download/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://makeapullrequest.com) [npm-image]: https://img.shields.io/npm/v/koa-session.svg?style=flat-square [npm-url]: https://npmjs.org/package/koa-session +[codecov-image]: https://codecov.io/gh/koajs/session/branch/master/graph/badge.svg +[codecov-url]: https://codecov.io/gh/koajs/session +[snyk-image]: https://snyk.io/test/npm/koa-session/badge.svg?style=flat-square +[snyk-url]: https://snyk.io/test/npm/koa-session [download-image]: https://img.shields.io/npm/dm/koa-session.svg?style=flat-square [download-url]: https://npmjs.org/package/koa-session - Simple session middleware for Koa. Defaults to cookie-based sessions and supports external stores. - - *Requires Node 8.0.0 or greater for async/await support* +Simple session middleware for Koa. Defaults to cookie-based sessions and supports external stores. ## Installation -```js -$ npm install koa-session +```bash +npm install koa-session ``` ## Notice -6.x changed the default cookie key from `koa:sess` to `koa.sess` to ensure `set-cookie` value valid with HTTP spec.[see issue](https://github.com/koajs/session/issues/28). If you want to be compatible with the previous version, you can manually set `config.key` to `koa:sess`. +7.x has a breaking change: drop Node.js < 18.19.0 support. And it support CommonJS and ESM both. + +6.x changed the default cookie key from `koa:sess` to `koa.sess` to ensure `set-cookie` value valid with HTTP spec. +[See issue](https://github.com/koajs/session/issues/28). +If you want to be compatible with the previous version, you can manually set `config.key` to `koa:sess`. ## Example - View counter example: +View counter example: ```js -const session = require('koa-session'); -const Koa = require('koa'); +import Koa from 'koa'; +import session from 'koa-session'; + const app = new Koa(); app.keys = ['some secret hurr']; @@ -70,38 +81,35 @@ console.log('listening on port 3000'); ### Options - The cookie name is controlled by the `key` option, which defaults - to "koa.sess". All other options are passed to `ctx.cookies.get()` and - `ctx.cookies.set()` allowing you to control security, domain, path, - and signing among other settings. +The cookie name is controlled by the `key` option, which defaults +to "koa.sess". All other options are passed to `ctx.cookies.get()` and +`ctx.cookies.set()` allowing you to control security, domain, path, +and signing among other settings. #### Custom `encode/decode` Support - Use `options.encode` and `options.decode` to customize your own encode/decode methods. +Use `options.encode` and `options.decode` to customize your own encode/decode methods. ### Hooks - - `valid()`: valid session value before use it - - `beforeSave()`: hook before save session +- `valid()`: valid session value before use it +- `beforeSave()`: hook before save session ### External Session Stores - The session is stored in a cookie by default, but it has some disadvantages: - - - Session is stored on client side unencrypted - - [Browser cookies always have length limits](http://browsercookielimits.squawky.net/) +The session is stored in a cookie by default, but it has some disadvantages: +- Session is stored on client side unencrypted +- [Browser cookies always have length limits](http://browsercookielimits.squawky.net/) You can store the session content in external stores (Redis, MongoDB or other DBs) by passing `options.store` with three methods (these need to be async functions): - - `get(key, maxAge, { rolling, ctx })`: get session object by key - - `set(key, sess, maxAge, { rolling, changed, ctx })`: set session object for key, with a `maxAge` (in ms) - - `destroy(key, {ctx})`: destroy session for key - +- `get(key, maxAge, { rolling, ctx })`: get session object by key +- `set(key, sess, maxAge, { rolling, changed, ctx })`: set session object for key, with a `maxAge` (in ms) +- `destroy(key, {ctx})`: destroy session for key Once you pass `options.store`, session storage is dependent on your external store -- you can't access the session if your external store is down. **Use external session stores only if necessary, avoid using session as a cache, keep the session lean, and store it in a cookie if possible!** - The way of generating external session id is controlled by the `options.genid(ctx)`, which defaults to `uuid.v4()`. If you want to add prefix for all external session id, you can use `options.prefix`, it will not work if `options.genid(ctx)` present. @@ -125,7 +133,7 @@ External key is used the cookie by default, but you can use `options.externalKey ### Session#isNew - Returns __true__ if the session is new. +Returns **true** if the session is new. ```js if (this.session.isNew) { @@ -137,27 +145,27 @@ if (this.session.isNew) { ### Session#maxAge - Get cookie's maxAge. +Get cookie's maxAge. ### Session#maxAge= - Set cookie's maxAge. +Set cookie's maxAge. ### Session#externalKey - Get session external key, only exist when external session store present. +Get session external key, only exist when external session store present. ### Session#save() - Save this session no matter whether it is populated. +Save this session no matter whether it is populated. ### Session#manuallyCommit() - Session headers are auto committed by default. Use this if `autoCommit` is set to `false`. +Session headers are auto committed by default. Use this if `autoCommit` is set to `false`. ### Destroying a session - To destroy a session simply set it to `null`: +To destroy a session simply set it to `null`: ```js this.session = null; @@ -165,4 +173,10 @@ this.session = null; ## License - MIT +[MIT](LICENSE) + +## Contributors + +[![Contributors](https://contrib.rocks/image?repo=koajs/session)](https://github.com/koajs/session/graphs/contributors) + +Made with [contributors-img](https://contrib.rocks). diff --git a/example.cjs b/example.cjs new file mode 100644 index 0000000..f238ff5 --- /dev/null +++ b/example.cjs @@ -0,0 +1,20 @@ + +const Koa = require('koa'); +const { createSession } = require('./'); + +const app = new Koa(); + +app.keys = [ 'some secret hurr' ]; + +app.use(createSession(app)); + +app.use(async (ctx, next) => { + if (ctx.path === '/favicon.ico') return next(); + + let n = ctx.session.views || 0; + ctx.session.views = ++n; + ctx.body = n + ' views'; +}); + +app.listen(3000); +console.log('listening on port http://localhost:3000'); diff --git a/example.js b/example.js deleted file mode 100644 index ea47cce..0000000 --- a/example.js +++ /dev/null @@ -1,18 +0,0 @@ - -var session = require('./'); -var Koa = require('koa'); -var app = new Koa(); - -app.keys = ['some secret hurr']; - -app.use(session(app)); - -app.use(function* (next){ - if ('/favicon.ico' == this.path) return; - var n = this.session.views || 0; - this.session.views = ++n; - this.body = n + ' views'; -}); - -app.listen(3000); -console.log('listening on port 3000'); diff --git a/index.js b/index.js deleted file mode 100644 index 4d149aa..0000000 --- a/index.js +++ /dev/null @@ -1,151 +0,0 @@ -'use strict'; - -const debug = require('debug')('koa-session'); -const ContextSession = require('./lib/context'); -const util = require('./lib/util'); -const assert = require('assert'); -const uuid = require('uuid'); -const is = require('is-type-of'); - -const CONTEXT_SESSION = Symbol('context#contextSession'); -const _CONTEXT_SESSION = Symbol('context#_contextSession'); - -/** - * Initialize session middleware with `opts`: - * - * - `key` session cookie name ["koa.sess"] - * - all other options are passed as cookie options - * - * @param {Object} [opts] - * @param {Application} app, koa application instance - * @api public - */ - -module.exports = function(opts, app) { - // session(app[, opts]) - if (opts && typeof opts.use === 'function') { - [ app, opts ] = [ opts, app ]; - } - // app required - if (!app || typeof app.use !== 'function') { - throw new TypeError('app instance required: `session(opts, app)`'); - } - - opts = formatOpts(opts); - extendContext(app.context, opts); - - return async function session(ctx, next) { - const sess = ctx[CONTEXT_SESSION]; - if (sess.store) await sess.initFromExternal(); - try { - await next(); - } catch (err) { - throw err; - } finally { - if (opts.autoCommit) { - await sess.commit(); - } - } - }; -}; - -/** - * format and check session options - * @param {Object} opts session options - * @return {Object} new session options - * - * @api private - */ - -function formatOpts(opts) { - opts = opts || {}; - // key - opts.key = opts.key || 'koa.sess'; - - // back-compat maxage - if (!('maxAge' in opts)) opts.maxAge = opts.maxage; - - // defaults - if (opts.overwrite == null) opts.overwrite = true; - if (opts.httpOnly == null) opts.httpOnly = true; - // delete null sameSite config - if (opts.sameSite == null) delete opts.sameSite; - if (opts.signed == null) opts.signed = true; - if (opts.autoCommit == null) opts.autoCommit = true; - - debug('session options %j', opts); - - // setup encoding/decoding - if (typeof opts.encode !== 'function') { - opts.encode = util.encode; - } - if (typeof opts.decode !== 'function') { - opts.decode = util.decode; - } - - const store = opts.store; - if (store) { - assert(is.function(store.get), 'store.get must be function'); - assert(is.function(store.set), 'store.set must be function'); - assert(is.function(store.destroy), 'store.destroy must be function'); - } - - const externalKey = opts.externalKey; - if (externalKey) { - assert(is.function(externalKey.get), 'externalKey.get must be function'); - assert(is.function(externalKey.set), 'externalKey.set must be function'); - } - - const ContextStore = opts.ContextStore; - if (ContextStore) { - assert(is.class(ContextStore), 'ContextStore must be a class'); - assert(is.function(ContextStore.prototype.get), 'ContextStore.prototype.get must be function'); - assert(is.function(ContextStore.prototype.set), 'ContextStore.prototype.set must be function'); - assert(is.function(ContextStore.prototype.destroy), 'ContextStore.prototype.destroy must be function'); - } - - if (!opts.genid) { - if (opts.prefix) opts.genid = () => `${opts.prefix}${uuid.v4()}`; - else opts.genid = uuid.v4; - } - - return opts; -} - -/** - * extend context prototype, add session properties - * - * @param {Object} context koa's context prototype - * @param {Object} opts session options - * - * @api private - */ - -function extendContext(context, opts) { - if (context.hasOwnProperty(CONTEXT_SESSION)) { - return; - } - Object.defineProperties(context, { - [CONTEXT_SESSION]: { - get() { - if (this[_CONTEXT_SESSION]) return this[_CONTEXT_SESSION]; - this[_CONTEXT_SESSION] = new ContextSession(this, opts); - return this[_CONTEXT_SESSION]; - }, - }, - session: { - get() { - return this[CONTEXT_SESSION].get(); - }, - set(val) { - this[CONTEXT_SESSION].set(val); - }, - configurable: true, - }, - sessionOptions: { - get() { - return this[CONTEXT_SESSION].opts; - }, - }, - }); -} diff --git a/lib/session.js b/lib/session.js deleted file mode 100644 index 202acc1..0000000 --- a/lib/session.js +++ /dev/null @@ -1,163 +0,0 @@ -'use strict'; - -/** - * Session model. - */ - -const inspect = Symbol.for('nodejs.util.inspect.custom'); - -class Session { - /** - * Session constructor - * @param {Context} ctx - * @param {Object} obj - * @api private - */ - - constructor(sessionContext, obj, externalKey) { - this._sessCtx = sessionContext; - this._ctx = sessionContext.ctx; - this._externalKey = externalKey; - if (!obj) { - this.isNew = true; - } else { - for (const k in obj) { - // restore maxAge from store - if (k === '_maxAge') this._ctx.sessionOptions.maxAge = obj._maxAge; - else if (k === '_session') this._ctx.sessionOptions.maxAge = 'session'; - else this[k] = obj[k]; - } - } - } - - /** - * JSON representation of the session. - * - * @return {Object} - * @api public - */ - - toJSON() { - const obj = {}; - - Object.keys(this).forEach(key => { - if (key === 'isNew') return; - if (key[0] === '_') return; - obj[key] = this[key]; - }); - - return obj; - } - - /** - * - * alias to `toJSON` - * @api public - */ - - [inspect]() { - return this.toJSON(); - } - - /** - * Return how many values there are in the session object. - * Used to see if it's "populated". - * - * @return {Number} - * @api public - */ - - get length() { - return Object.keys(this.toJSON()).length; - } - - /** - * populated flag, which is just a boolean alias of .length. - * - * @return {Boolean} - * @api public - */ - - get populated() { - return !!this.length; - } - - /** - * get session maxAge - * - * @return {Number} - * @api public - */ - - get maxAge() { - return this._ctx.sessionOptions.maxAge; - } - - /** - * set session maxAge - * - * @param {Number} - * @api public - */ - - set maxAge(val) { - this._ctx.sessionOptions.maxAge = val; - // maxAge changed, must save to cookie and store - this._requireSave = true; - } - - /** - * get session external key - * only exist if opts.store present - */ - get externalKey() { - return this._externalKey; - } - - /** - * save this session no matter whether it is populated - * - * @param {Function} callback the optional function to call after saving the session - * @api public - */ - - save(callback) { - return this.commit({ save: true }, callback); - } - - /** - * regenerate this session - * - * @param {Function} callback the optional function to call after regenerating the session - * @api public - */ - - regenerate(callback) { - return this.commit({ regenerate: true }, callback); - } - - /** - * commit this session's headers if autoCommit is set to false - * - * @api public - */ - - manuallyCommit() { - return this.commit(); - } - - commit(options, callback) { - if (typeof options === 'function') { - callback = options; - options = {}; - } - const promise = this._sessCtx.commit(options); - if (callback) { - promise.then(() => callback(), callback); - } else { - return promise; - } - } -} - -module.exports = Session; diff --git a/lib/util.js b/lib/util.js deleted file mode 100644 index b3a1a28..0000000 --- a/lib/util.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict'; - -const crc = require('crc').crc32; - -module.exports = { - - /** - * Decode the base64 cookie value to an object. - * - * @param {String} string - * @return {Object} - * @api private - */ - - decode(string) { - const body = Buffer.from(string, 'base64').toString('utf8'); - const json = JSON.parse(body); - return json; - }, - - /** - * Encode an object into a base64-encoded JSON string. - * - * @param {Object} body - * @return {String} - * @api private - */ - - encode(body) { - body = JSON.stringify(body); - return Buffer.from(body).toString('base64'); - }, - - hash(sess) { - return crc(JSON.stringify(sess)); - }, - - CookieDateEpoch: 'Thu, 01 Jan 1970 00:00:00 GMT', -}; diff --git a/package.json b/package.json index a61dc55..725d6c3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,10 @@ { "name": "koa-session", "description": "Koa cookie session middleware with external store support", - "repository": "koajs/session", + "repository": { + "type": "git", + "url": "git@github.com:koajs/session.git" + }, "version": "6.4.0", "keywords": [ "koa", @@ -9,38 +12,70 @@ "session", "cookie" ], - "files": [ - "index.js", - "lib" - ], "devDependencies": { - "benchmark": "^2.1.4", - "eslint": "3", - "eslint-config-egg": "3", - "istanbul": "0", + "@arethetypeswrong/cli": "^0.17.1", + "@eggjs/bin": "7", + "@eggjs/supertest": "8", + "@eggjs/tsconfig": "1", + "@types/crc": "^3.8.3", + "@types/koa": "^2.15.0", + "@types/mocha": "10", + "@types/node": "22", + "eslint": "8", + "eslint-config-egg": "14", "koa": "2", - "mm": "^2.1.0", - "mocha": "^5.2.0", - "mz-modules": "^2.0.0", - "pedding": "^1.1.0", - "should": "8", - "supertest": "^3.3.0", - "uid-safe": "^2.1.3" + "mm": "4", + "rimraf": "6", + "tshy": "3", + "tshy-after": "1", + "typescript": "5" }, "license": "MIT", "dependencies": { "crc": "^3.8.0", "debug": "^4.3.3", - "is-type-of": "^1.2.1", - "uuid": "^8.3.2" + "is-type-of": "^2.2.0", + "uuid": "^8.3.2", + "zod": "^3.24.1" }, "engines": { - "node": ">=8.0.0" + "node": ">= 18.19.0" }, "scripts": { - "test": "npm run lint && NODE_ENV=test mocha --exit --require should --reporter spec test/*.test.js", - "test-cov": "NODE_ENV=test node ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --exit --require should test/*.test.js", - "ci": "npm run lint && NODE_ENV=test node ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha --report lcovonly -- --exit --require should test/*.test.js", - "lint": "eslint lib test index.js" - } + "lint": "eslint --cache src test --ext .ts", + "pretest": "npm run clean && npm run lint -- --fix", + "test": "egg-bin test", + "preci": "npm run clean && npm run lint", + "ci": "egg-bin cov", + "postci": "npm run prepublishOnly && npm run clean", + "clean": "rimraf dist", + "prepublishOnly": "tshy && tshy-after && attw --pack" + }, + "type": "module", + "tshy": { + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + } + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/commonjs/index.d.ts", + "default": "./dist/commonjs/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "src" + ], + "types": "./dist/commonjs/index.d.ts", + "main": "./dist/commonjs/index.js", + "module": "./dist/esm/index.js" } diff --git a/lib/context.js b/src/context.ts similarity index 56% rename from lib/context.js rename to src/context.ts index d3efbec..c31a751 100644 --- a/lib/context.js +++ b/src/context.ts @@ -1,52 +1,52 @@ -'use strict'; +import { debuglog } from 'node:util'; +import { Session } from './session.js'; +import util from './util.js'; +import type { SessionOptions } from './index.js'; -const debug = require('debug')('koa-session:context'); -const Session = require('./session'); -const util = require('./util'); +const debug = debuglog('koa-session:context'); const COOKIE_EXP_DATE = new Date(util.CookieDateEpoch); const ONE_DAY = 24 * 60 * 60 * 1000; -class ContextSession { +export class ContextSession { + ctx: any; + app: any; + opts: SessionOptions; + store: SessionOptions['store']; + session: Session | false; + externalKey?: string; + prevHash?: number; + /** * context session constructor - * @api public */ - - constructor(ctx, opts) { + constructor(ctx: any, opts: SessionOptions) { this.ctx = ctx; this.app = ctx.app; - this.opts = Object.assign({}, opts); + this.opts = { ...opts }; this.store = this.opts.ContextStore ? new this.opts.ContextStore(ctx) : this.opts.store; } /** * internal logic of `ctx.session` * @return {Session} session object - * - * @api public */ - - get() { - const session = this.session; + get(): Session | null { // already retrieved - if (session) return session; + if (this.session) return this.session; // unset - if (session === false) return null; + if (this.session === false) return null; // create an empty session or init from cookie this.store ? this.create() : this.initFromCookie(); - return this.session; + return this.session as Session; } /** * internal logic of `ctx.session=` * @param {Object} val session object - * - * @api public */ - - set(val) { + set(val: Record | null) { if (val === null) { this.session = false; return; @@ -63,7 +63,7 @@ class ContextSession { * init session from external store * will be called in the front of session middleware * - * @api public + * @public */ async initFromExternal() { @@ -87,23 +87,23 @@ class ContextSession { return; } - const json = await this.store.get(externalKey, opts.maxAge, { ctx, rolling: opts.rolling }); - if (!this.valid(json, externalKey)) { + const sessionData = await this.store!.get(externalKey, opts.maxAge as number, { ctx, rolling: opts.rolling }); + if (!this.valid(sessionData, externalKey)) { + debug('invalid session data, create a new session'); // create a new `externalKey` this.create(); return; } // create with original `externalKey` - this.create(json, externalKey); - this.prevHash = util.hash(this.session.toJSON()); + this.create(sessionData, externalKey); + this.prevHash = util.hash((this.session as Session).toJSON()); } /** * init session from cookie - * @api private + * @private */ - initFromCookie() { debug('init from cookie'); const ctx = this.ctx; @@ -115,68 +115,67 @@ class ContextSession { return; } - let json; - debug('parse %s', cookie); + let sessionData: Record; + debug('parse cookie: %j', cookie); try { - json = opts.decode(cookie); - } catch (err) { + sessionData = opts.decode(cookie); + } catch (err: unknown) { // backwards compatibility: // create a new session if parsing fails. - // new Buffer(string, 'base64') does not seem to crash + // `Buffer.from(string, 'base64')` does not seem to crash // when `string` is not base64-encoded. // but `JSON.parse(string)` will crash. debug('decode %j error: %s', cookie, err); - if (!(err instanceof SyntaxError)) { + if (err instanceof Error && !(err instanceof SyntaxError)) { // clean this cookie to ensure next request won't throw again ctx.cookies.set(opts.key, '', opts); - // ctx.onerror will unset all headers, and set those specified in err - err.headers = { + // `ctx.onerror` will unset all headers, and set those specified in err + Reflect.set(err, 'headers', { 'set-cookie': ctx.response.get('set-cookie'), - }; + }); throw err; } this.create(); return; } - debug('parsed %j', json); - - if (!this.valid(json)) { + debug('parsed session data: %j', sessionData); + if (!this.valid(sessionData)) { + // create a new session if the session data is invalid this.create(); + debug('invalid session data, create a new session'); return; } // support access `ctx.session` before session middleware - this.create(json); - this.prevHash = util.hash(this.session.toJSON()); + this.create(sessionData); + this.prevHash = util.hash((this.session as Session).toJSON()); } /** - * verify session(expired or ) - * @param {Object} value session object - * @param {Object} key session externalKey(optional) - * @return {Boolean} valid - * @api private + * verify session(expired or custom verification) + * @param {Object} sessionData session data + * @param {Object} [key] session externalKey(optional) + * @private */ - - valid(value, key) { + protected valid(sessionData: Record, key?: string) { const ctx = this.ctx; - if (!value) { - this.emit('missed', { key, value, ctx }); + if (!sessionData) { + this.emit('missed', { key, value: sessionData, ctx }); return false; } - if (value._expire && value._expire < Date.now()) { + if (typeof sessionData._expire === 'number' && sessionData._expire < Date.now()) { debug('expired session'); - this.emit('expired', { key, value, ctx }); + this.emit('expired', { key, value: sessionData, ctx }); return false; } const valid = this.opts.valid; - if (typeof valid === 'function' && !valid(ctx, value)) { + if (typeof valid === 'function' && !valid(ctx, sessionData)) { // valid session value fail, ignore this session debug('invalid session'); - this.emit('invalid', { key, value, ctx }); + this.emit('invalid', { key, value: sessionData, ctx }); return false; } return true; @@ -185,9 +184,9 @@ class ContextSession { /** * @param {String} event event name * @param {Object} data event data - * @api private + * @private */ - emit(event, data) { + emit(event: string, data: unknown) { setImmediate(() => { this.app.emit(`session:${event}`, data); }); @@ -196,45 +195,49 @@ class ContextSession { /** * create a new session and attach to ctx.sess * - * @param {Object} [val] session data + * @param {Object} [sessionData] session data * @param {String} [externalKey] session external key - * @api private */ - - create(val, externalKey) { - debug('create session with val: %j externalKey: %s', val, externalKey); - if (this.store) this.externalKey = externalKey || this.opts.genid && this.opts.genid(this.ctx); - this.session = new Session(this, val, this.externalKey); + protected create(sessionData?: Record, externalKey?: string) { + debug('create session with data: %j, externalKey: %s', sessionData, externalKey); + if (this.store) { + this.externalKey = externalKey ?? this.opts.genid?.(this.ctx); + } + this.session = new Session(this, sessionData, this.externalKey); } /** * Commit the session changes or removal. - * - * @api public */ - async commit({ save = false, regenerate = false } = {}) { const session = this.session; const opts = this.opts; const ctx = this.ctx; // not accessed - if (undefined === session) return; + if (session === undefined) { + return; + } // removed if (session === false) { await this.remove(); return; } + if (regenerate) { await this.remove(); - if (this.store) this.externalKey = opts.genid && opts.genid(ctx); + if (this.store) { + this.externalKey = opts.genid?.(ctx); + } } // force save session when `session._requireSave` set const reason = save || regenerate || session._requireSave ? 'force' : this._shouldSaveSession(); - debug('should save session: %s', reason); - if (!reason) return; + debug('should save session: %j', reason); + if (!reason) { + return; + } if (typeof opts.beforeSave === 'function') { debug('before save'); @@ -246,81 +249,90 @@ class ContextSession { _shouldSaveSession() { const prevHash = this.prevHash; - const session = this.session; + const session = this.session as Session; // do nothing if new and not populated - const json = session.toJSON(); - if (!prevHash && !Object.keys(json).length) return ''; + const sessionData = session.toJSON(); + if (!prevHash && !Object.keys(sessionData).length) { + return ''; + } // save if session changed - const changed = prevHash !== util.hash(json); - if (changed) return 'changed'; + const changed = prevHash !== util.hash(sessionData); + if (changed) { + return 'changed'; + } // save if opts.rolling set - if (this.opts.rolling) return 'rolling'; + if (this.opts.rolling) { + return 'rolling'; + } // save if opts.renew and session will expired if (this.opts.renew) { const expire = session._expire; const maxAge = session.maxAge; // renew when session will expired in maxAge / 2 - if (expire && maxAge && expire - Date.now() < maxAge / 2) return 'renew'; + if (expire && maxAge && expire - Date.now() < maxAge / 2) { + return 'renew'; + } } + // don't save return ''; } /** * remove session - * @api private + * @private */ - async remove() { // Override the default options so that we can properly expire the session cookies - const opts = Object.assign({}, this.opts, { + const opts = { + ...this.opts, expires: COOKIE_EXP_DATE, maxAge: false, - }); - + }; const ctx = this.ctx; const key = opts.key; const externalKey = this.externalKey; - if (externalKey) await this.store.destroy(externalKey, { ctx }); + if (externalKey) { + await this.store!.destroy(externalKey, { ctx }); + } ctx.cookies.set(key, '', opts); } /** * save session - * @api private + * @private */ - - async save(changed) { + async save(changed: boolean) { const opts = this.opts; const key = opts.key; const externalKey = this.externalKey; - let json = this.session.toJSON(); + const sessionData = (this.session as Session).toJSON(); // set expire for check let maxAge = opts.maxAge ? opts.maxAge : ONE_DAY; if (maxAge === 'session') { // do not set _expire in json if maxAge is set to 'session' // also delete maxAge from options opts.maxAge = undefined; - json._session = true; + sessionData._session = true; } else { // set expire for check - json._expire = maxAge + Date.now(); - json._maxAge = maxAge; + sessionData._expire = maxAge + Date.now(); + sessionData._maxAge = maxAge; } // save to external store if (externalKey) { - debug('save %j to external key %s', json, externalKey); + debug('save %j to external key %s', sessionData, externalKey); if (typeof maxAge === 'number') { // ensure store expired after cookie maxAge += 10000; } - await this.store.set(externalKey, json, maxAge, { + await this.store!.set(externalKey, sessionData, maxAge as number, { changed, ctx: this.ctx, rolling: opts.rolling, @@ -333,13 +345,11 @@ class ContextSession { return; } - // save to cookie - debug('save %j to cookie', json); - json = opts.encode(json); - debug('save %s', json); - - this.ctx.cookies.set(key, json, opts); + // save to cookie with base64 encode string + debug('save session data %j to cookie', sessionData); + const base64String = opts.encode(sessionData); + debug('save session data json base64 format: %s to cookie key: %s with options: %j', + base64String, key, opts); + this.ctx.cookies.set(key, base64String, opts); } } - -module.exports = ContextSession; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0652ffe --- /dev/null +++ b/src/index.ts @@ -0,0 +1,305 @@ +import assert from 'node:assert'; +import { debuglog } from 'node:util'; +import { randomUUID } from 'node:crypto'; +import { isClass } from 'is-type-of'; +import z from 'zod'; +import { ContextSession } from './context.js'; +import util from './util.js'; + +const debug = debuglog('koa-session'); + +const GET_CONTEXT_SESSION = Symbol('get contextSession'); +const CONTEXT_SESSION_INSTANCE = Symbol('contextSession instance'); + +const SessionOptionsSchema = z.object({ + /** + * cookie key + * Default is `koa.sess` + */ + key: z.string().default('koa.sess'), + /** + * maxAge in ms + * Default is `86400000`, one day + * If set to 'session' will result in a cookie that expires when session/browser is closed + * + * Warning: If a session cookie is stolen, this cookie will never expire + */ + maxAge: z.union([ z.number(), z.literal('session') ]).optional(), + /** + * automatically commit headers + * Default is `true` + */ + autoCommit: z.boolean().default(true), + /** + * cookie value can overwrite or not + * Default is `true` + */ + overwrite: z.boolean().default(true), + /** + * httpOnly or not + * Default is `true` + */ + httpOnly: z.boolean().default(true), + /** + * signed or not + * Default is `true` + */ + signed: z.boolean().default(true), + /** + * Force a session identifier cookie to be set on every response. + * The expiration is reset to the original `maxAge`, resetting the expiration countdown. + * Default is `false` + */ + rolling: z.boolean().default(false), + /** + * renew session when session is nearly expired, so we can always keep user logged in. + * Default is `false` + */ + renew: z.boolean().default(false), + /** + * secure cookie + * Default is `undefined`, will be set to `true` if the connection is over HTTPS, otherwise `false`. + */ + secure: z.boolean().optional(), + /** + * session cookie sameSite options + * Default is `undefined`, meaning don't set it + */ + sameSite: z.string().optional(), + /** + * External key is used the cookie by default, + * but you can use `options.externalKey` to customize your own external key methods. + */ + externalKey: z.object({ + /** + * get the external key + * `(ctx) => string` + */ + get: z.function() + .args(z.any()) + .returns(z.string()), + /** + * set the external key + * `(ctx, key) => void` + */ + set: z.function() + .args(z.any(), z.string()) + .returns(z.void()), + }).optional(), + /** + * session storage is dependent on your external store + */ + store: z.object({ + /** + * get session data by key + * `(key, maxAge, { rolling, ctx }) => sessionData | Promise` + */ + get: z.function() + .args(z.string(), z.number(), z.object({ rolling: z.boolean(), ctx: z.any() })) + .returns(z.promise(z.any())), + /** + * set session data for key, with a `maxAge` (in ms) + * `(key, sess, maxAge, { rolling, changed, ctx }) => void | Promise` + */ + set: z.function() + .args(z.string(), z.any(), z.number(), z.object({ rolling: z.boolean(), changed: z.boolean(), ctx: z.any() })) + .returns(z.promise(z.void())), + /** + * destroy session data for key + * `(key, { ctx })=> void | Promise` + */ + destroy: z.function() + .args(z.string(), z.object({ ctx: z.any() })) + .returns(z.promise(z.void())), + }).optional(), + /** + * If your session store requires data or utilities from context, `opts.ContextStore` is also supported. + * `ContextStore` must be a class which claims three instance methods demonstrated above. + * `new ContextStore(ctx)` will be executed on every request. + */ + ContextStore: z.any().optional(), + encode: z.function() + .args(z.any()) + .returns(z.string()) + .optional() + .default(() => util.encode), + decode: z.function() + .args(z.string()) + .returns(z.any()) + .default(() => util.decode), + /** + * If you want to generate a new session id, you can use `genid` option to customize it. + * Default is a function that uses `randomUUID()`. + * `(ctx) => string` + */ + genid: z.function() + .args(z.any()) + .returns(z.string()) + .optional(), + /** + * If you want to prefix the session id, you can use `prefix` option to customize it. + * It will not work if `options.genid(ctx)` present. + */ + prefix: z.string().optional(), + /** + * valid session value before use it + * `(ctx, sessionData) => boolean` + */ + valid: z.function() + .args(z.any(), z.any()) + .returns(z.any()) + .optional(), + /** + * hook before save session + * `(ctx, sessionModel) => void` + */ + beforeSave: z.function() + .args(z.any(), z.any()) + .returns(z.void()) + .optional(), +}); + +const DEFAULT_SESSION_OPTIONS = SessionOptionsSchema.parse({}); + +export type SessionOptions = z.infer; +export type CreateSessionOptions = Partial; + +type Middleware = (ctx: any, next: any) => Promise; + +/** + * Initialize session middleware with `opts`: + * + * - `key` session cookie name ["koa.sess"] + * - all other options are passed as cookie options + * + * @param {Object} [opts] session options + * @param {Application} app koa application instance + * @public + */ +export function createSession(opts: CreateSessionOptions, app: any): Middleware; +export function createSession(app: any, opts?: CreateSessionOptions): Middleware; +export function createSession(opts: CreateSessionOptions | any, app: any): Middleware { + // session(app[, opts]) + if (opts && 'use' in opts && typeof opts.use === 'function') { + [ app, opts ] = [ opts, app ]; + } + // app required + if (typeof app?.use !== 'function') { + throw new TypeError('app instance required: `session(opts, app)`'); + } + + // back-compat maxage + if (opts && !('maxAge' in opts) && 'maxage' in opts) { + Reflect.set(opts, 'maxAge', Reflect.get(opts, 'maxage')); + if (process.env.NODE_ENV !== 'production') { + console.warn('DeprecationWarning: `maxage` option has been renamed to `maxAge`'); + } + } + let options = { + ...DEFAULT_SESSION_OPTIONS, + ...opts, + }; + SessionOptionsSchema.parse(options); + options = formatOptions(options); + extendContext(app.context, options); + + return async function session(ctx: any, next: any) { + const sess = ctx[GET_CONTEXT_SESSION]; + if (sess.store) { + await sess.initFromExternal(); + } + try { + await next(); + } catch (err) { + throw err; + } finally { + if (options.autoCommit) { + await sess.commit(); + } + } + }; +} + +// Usage: `import session from 'koa-session'` +export default createSession; + +/** + * format and check session options + */ +function formatOptions(opts: SessionOptions) { + // defaults + if (opts.overwrite == null) opts.overwrite = true; + if (opts.httpOnly == null) opts.httpOnly = true; + // delete null sameSite config + if (opts.sameSite == null) delete opts.sameSite; + if (opts.signed == null) opts.signed = true; + if (opts.autoCommit == null) opts.autoCommit = true; + + debug('session options %j', opts); + const store = opts.store; + if (store) { + assert(typeof store.get === 'function', 'store.get must be function'); + assert(typeof store.set === 'function', 'store.set must be function'); + assert(typeof store.destroy === 'function', 'store.destroy must be function'); + } + + const externalKey = opts.externalKey; + if (externalKey) { + assert(typeof externalKey.get === 'function', 'externalKey.get must be function'); + assert(typeof externalKey.set === 'function', 'externalKey.set must be function'); + } + + const ContextStore = opts.ContextStore; + if (ContextStore) { + assert(isClass(ContextStore), 'ContextStore must be a class'); + assert(typeof ContextStore.prototype.get === 'function', 'ContextStore.prototype.get must be function'); + assert(typeof ContextStore.prototype.set === 'function', 'ContextStore.prototype.set must be function'); + assert(typeof ContextStore.prototype.destroy === 'function', 'ContextStore.prototype.destroy must be function'); + } + + if (!opts.genid) { + if (opts.prefix) { + opts.genid = () => `${opts.prefix}${randomUUID()}`; + } else { + opts.genid = () => randomUUID(); + } + } + return opts; +} + +/** + * extend context prototype, add session properties + * + * @param {Object} context koa's context prototype + * @param {Object} opts session options + */ +function extendContext(context: object, opts: SessionOptions) { + if (context.hasOwnProperty(GET_CONTEXT_SESSION)) { + return; + } + Object.defineProperties(context, { + [GET_CONTEXT_SESSION]: { + get() { + if (this[CONTEXT_SESSION_INSTANCE]) { + return this[CONTEXT_SESSION_INSTANCE]; + } + this[CONTEXT_SESSION_INSTANCE] = new ContextSession(this, opts); + return this[CONTEXT_SESSION_INSTANCE]; + }, + }, + session: { + get() { + return this[GET_CONTEXT_SESSION].get(); + }, + set(val) { + this[GET_CONTEXT_SESSION].set(val); + }, + configurable: true, + }, + sessionOptions: { + get() { + return this[GET_CONTEXT_SESSION].opts; + }, + }, + }); +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..024b553 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,139 @@ +import { inspect } from 'node:util'; +import type { ContextSession } from './context.js'; + +type Callback = (err?: Error) => void; + +/** + * Session model + */ +export class Session { + #sessCtx: ContextSession; + #ctx: any; + #externalKey?: string; + isNew = false; + _requireSave = false; + // session expire time, will be set from sessionData + _expire?: number; + + constructor(sessionContext: ContextSession, sessionData?: Record, externalKey?: string) { + this.#sessCtx = sessionContext; + this.#ctx = sessionContext.ctx; + this.#externalKey = externalKey; + if (!sessionData) { + this.isNew = true; + } else { + for (const k in sessionData) { + // restore maxAge from store + if (k === '_maxAge') { + this.#ctx.sessionOptions.maxAge = sessionData._maxAge; + } else if (k === '_session') { + // set maxAge to 'session' if it's a session lifetime + this.#ctx.sessionOptions.maxAge = 'session'; + } else { + Reflect.set(this, k, sessionData[k]); + } + } + } + } + + /** + * JSON representation of the session. + */ + toJSON() { + const obj: Record = {}; + for (const key in this) { + if (key === 'isNew') continue; + // skip private stuff + if (key[0] === '_') continue; + const value = this[key]; + // skip functions + if (typeof value === 'function') continue; + obj[key] = value; + } + return obj; + } + + /** + * alias to `toJSON` + */ + [inspect.custom]() { + return this.toJSON(); + } + + /** + * Return how many values there are in the session object. + * Used to see if it's "populated". + */ + get length() { + return Object.keys(this.toJSON()).length; + } + + /** + * populated flag, which is just a boolean alias of .length. + */ + get populated() { + return !!this.length; + } + + /** + * get session maxAge + */ + get maxAge(): number { + return this.#ctx.sessionOptions.maxAge; + } + + /** + * set session maxAge + */ + set maxAge(val: number) { + this.#ctx.sessionOptions.maxAge = val; + // maxAge changed, must save to cookie and store + this._requireSave = true; + } + + /** + * get session external key + * only exist if opts.store present + */ + get externalKey() { + return this.#externalKey; + } + + /** + * save this session no matter whether it is populated + * + * @param {Function} [callback] the optional function to call after saving the session + */ + save(callback?: Callback) { + return this.commit({ save: true }, callback); + } + + /** + * regenerate this session + * + * @param {Function} [callback] the optional function to call after regenerating the session + */ + regenerate(callback?: Callback) { + return this.commit({ regenerate: true }, callback); + } + + /** + * commit this session's headers if autoCommit is set to false + */ + manuallyCommit() { + return this.commit(); + } + + commit(options?: any, callback?: Callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + const promise = this.#sessCtx.commit(options); + if (callback) { + promise.then(() => callback(), callback); + } else { + return promise; + } + } +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..39f0806 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,26 @@ +import crc from 'crc'; + +export default { + /** + * Decode the base64 cookie value to an object + * @private + */ + decode(base64String: string): Record { + const body = Buffer.from(base64String, 'base64').toString('utf8'); + const json = JSON.parse(body); + return json; + }, + + /** + * Encode an object into a base64-encoded JSON string + */ + encode(data: Record) { + return Buffer.from(JSON.stringify(data)).toString('base64'); + }, + + hash(data: Record) { + return crc.crc32(JSON.stringify(data)); + }, + + CookieDateEpoch: 'Thu, 01 Jan 1970 00:00:00 GMT', +}; diff --git a/test/context_store.js b/test/context_store.js deleted file mode 100644 index 8a053eb..0000000 --- a/test/context_store.js +++ /dev/null @@ -1,25 +0,0 @@ -'use strict'; - -// this is a stupid nonsense example just to test - -const sessions = {}; - -class ContextStore { - constructor(ctx) { - this.ctx = ctx; - } - - async get(key) { - return sessions[key]; - } - - async set(key, value) { - sessions[key] = value; - } - - async destroy(key) { - sessions[key] = undefined; - } -} - -module.exports = ContextStore; diff --git a/test/context_store.ts b/test/context_store.ts new file mode 100644 index 0000000..fb15761 --- /dev/null +++ b/test/context_store.ts @@ -0,0 +1,22 @@ +// this is a stupid nonsense example just to test + +const sessions: Record = {}; + +export default class ContextStore { + ctx: any; + constructor(ctx: any) { + this.ctx = ctx; + } + + async get(key: string) { + return sessions[key]; + } + + async set(key: string, value: unknown) { + sessions[key] = value; + } + + async destroy(key: string) { + sessions[key] = undefined; + } +} diff --git a/test/contextstore.test.js b/test/contextstore.test.js deleted file mode 100644 index b31fada..0000000 --- a/test/contextstore.test.js +++ /dev/null @@ -1,758 +0,0 @@ -'use strict'; - -const Koa = require('koa'); -const request = require('supertest'); -const should = require('should'); -const mm = require('mm'); -const session = require('..'); -const ContextStore = require('./context_store'); - -const inspect = Symbol.for('nodejs.util.inspect.custom'); - -describe('Koa Session External Context Store', () => { - let cookie; - - describe('when the session contains a ;', () => { - it('should still work', done => { - const app = App(); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.string = ';'; - ctx.status = 204; - } else { - ctx.body = ctx.session.string; - } - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect(204, (err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie']; - request(server) - .get('/') - .set('Cookie', cookie.join(';')) - .expect(';', done); - }); - }); - }); - - describe('new session', () => { - describe('when not accessed', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = 'greetings'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed and not populated', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session; - ctx.body = 'greetings'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when populated', () => { - it('should Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message = 'hello'; - ctx.body = ''; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, (err, res) => { - if (err) return done(err); - cookie = res.header['set-cookie'].join(';'); - cookie.indexOf('_suffix').should.greaterThan(1); - done(); - }); - }); - - it('should pass sid to middleware', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message = 'hello'; - ctx.state.sid.indexOf('_suffix').should.greaterThan(1); - ctx.body = ''; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, (err, res) => { - if (err) return done(err); - cookie = res.header['set-cookie'].join(';'); - cookie.indexOf('_suffix').should.greaterThan(1); - done(); - }); - }); - - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - }); - - describe('saved session', () => { - describe('when not accessed', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed but not changed', () => { - it('should be the same session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message.should.equal('hello'); - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, done); - }); - - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message.should.equal('hello'); - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed and changed', () => { - it('should Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.money = '$$$'; - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - }); - - describe('when session is', () => { - describe('null', () => { - it('should expire the session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = null; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - - describe('an empty object', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = {}; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('an object', () => { - it('should create a session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = { message: 'hello' }; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - - describe('anything else', () => { - it('should throw', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect(500, done); - }); - }); - }); - - describe('session', () => { - describe('.inspect()', () => { - it('should return session content', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = ctx.session[inspect](); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect({ foo: 'bar' }) - .expect(200, done); - }); - }); - - describe('.length', () => { - it('should return session length', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = String(ctx.session.length); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('1') - .expect(200, done); - }); - }); - - describe('.populated', () => { - it('should return session populated', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = String(ctx.session.populated); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('true') - .expect(200, done); - }); - }); - - describe('.save()', () => { - it('should save session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.save(); - ctx.body = 'hello'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('hello') - .expect(200, done); - }); - }); - }); - - describe('when an error is thrown downstream and caught upstream', () => { - it('should still save the session', done => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - - app.use(async function(ctx, next) { - try { - await next(); - } catch (err) { - ctx.status = err.status; - ctx.body = err.message; - } - }); - - app.use(session({ ContextStore }, app)); - - app.use(async function(ctx, next) { - ctx.session.name = 'funny'; - await next(); - }); - - app.use(async function(ctx) { - ctx.throw(401); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(401, done); - }); - }); - - describe('when autoCommit is present', () => { - describe('and set to false', () => { - it('should not set headers if manuallyCommit() isn\'t called', done => { - const app = App({ autoCommit: false }); - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - const server = app.listen(); - - request(server) - .post('/') - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie']; - should.not.exist(cookie); - }) - .expect(200, done); - }); - it('should set headers if manuallyCommit() is called', done => { - const app = App({ autoCommit: false }); - app.use(async function(ctx, next) { - if (ctx.method === 'POST') { - ctx.session.message = 'dummy'; - } - await next(); - }); - app.use(async function(ctx) { - ctx.body = 200; - await ctx.session.manuallyCommit(); - }); - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end(err => { - if (err) return done(err); - }) - .expect(200, done); - }); - }); - }); - - describe('when maxAge present', () => { - describe('and set to be a session cookie', () => { - it('should not expire the session', done => { - const app = App({ maxAge: 'session' }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - cookie.should.not.containEql('expires='); - request(server) - .get('/') - .set('cookie', cookie) - .expect('hi', done); - }); - }); - it('should not expire the session after multiple session changes', done => { - const app = App({ maxAge: 'session' }); - - app.use(async function(ctx) { - ctx.session.count = (ctx.session.count || 0) + 1; - ctx.body = `hi ${ctx.session.count}`; - }); - const server = app.listen(); - - request(server) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect('hi 1') - .end((err, res) => { - if (err) return done(err); - let cookie = res.headers['set-cookie'].join(';'); - cookie.should.not.containEql('expires='); - - request(server) - .get('/') - .set('cookie', cookie) - .expect('Set-Cookie', /koa\.sess/) - .expect('hi 2') - .end((err, res) => { - if (err) return done(err); - cookie = res.headers['set-cookie'].join(';'); - cookie.should.not.containEql('expires='); - - request(server) - .get('/') - .set('cookie', cookie) - .expect('Set-Cookie', /koa\.sess/) - .expect('hi 3') - .end((err, res) => { - if (err) return done(err); - cookie = res.headers['set-cookie'].join(';'); - cookie.should.not.containEql('expires='); - - done(); - }); - }); - }); - }); - it('should use the default maxAge when improper string given', done => { - const app = App({ maxAge: 'not the right string' }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - cookie.should.containEql('expires='); - request(server) - .get('/') - .set('cookie', cookie) - .expect('hi', done); - }); - }); - }); - describe('and not expire', () => { - it('should not expire the session', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - - request(server) - .get('/') - .set('cookie', cookie) - .expect('hi', done); - }); - }); - }); - - describe('and expired', () => { - it('should expire the sess', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.status = 200; - return; - } - - ctx.body = ctx.session.message || ''; - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - - setTimeout(() => { - request(server) - .get('/') - .set('cookie', cookie) - .expect('', done); - }, 200); - }); - }); - }); - }); - - describe('ctx.session.maxAge', () => { - it('should return opt.maxAge', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - ctx.body = ctx.session.maxAge; - }); - - request(app.listen()) - .get('/') - .expect('100', done); - }); - }); - - describe('ctx.session.maxAge=', () => { - it('should set sessionOptions.maxAge', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.session.maxAge = 100; - ctx.body = ctx.session.foo; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - - it('should save even session not change', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.maxAge = 100; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - - it('should save when create session only with maxAge', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = { maxAge: 100 }; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - }); - - describe('when store return empty', () => { - it('should create new Session', done => { - const app = App({ signed: false }); - - app.use(async function(ctx) { - ctx.body = String(ctx.session.isNew); - }); - - request(app.listen()) - .get('/') - .set('cookie', 'koa.sess=invalid-key') - .expect('true') - .expect(200, done); - }); - }); - - describe('when valid and beforeSave set', () => { - it('should ignore session when uid changed', done => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - app.use(session({ - valid(ctx, sess) { - return ctx.cookies.get('uid') === sess.uid; - }, - beforeSave(ctx, sess) { - sess.uid = ctx.cookies.get('uid'); - }, - ContextStore, - }, app)); - app.use(async function(ctx) { - if (!ctx.session.foo) { - ctx.session.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); - } - - ctx.body = { - foo: ctx.session.foo, - uid: ctx.cookies.get('uid'), - }; - }); - - request(app.callback()) - .get('/') - .set('Cookie', 'uid=123') - .expect(200, (err, res) => { - should.not.exist(err); - const data = res.body; - const cookies = res.headers['set-cookie'].join(';'); - cookies.should.containEql('koa.sess='); - - request(app.callback()) - .get('/') - .set('Cookie', cookies + ';uid=123') - .expect(200) - .expect(data, err => { - should.not.exist(err); - - // should ignore uid:123 session and create a new session for uid:456 - request(app.callback()) - .get('/') - .set('Cookie', cookies + ';uid=456') - .expect(200, (err, res) => { - should.not.exist(err); - res.body.uid.should.equal('456'); - res.body.foo.should.not.equal(data.foo); - done(); - }); - }); - }); - }); - }); - - describe('ctx.session', () => { - after(mm.restore); - - it('can be mocked', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = ctx.session; - }); - - mm(app.context, 'session', { - foo: 'bar', - }); - - request(app.listen()) - .get('/') - .expect({ - foo: 'bar', - }) - .expect(200, done); - }); - }); -}); - -function App(options) { - const app = new Koa(); - app.keys = [ 'a', 'b' ]; - options = options || {}; - options.ContextStore = ContextStore; - options.genid = ctx => { - const sid = Date.now() + '_suffix'; - ctx.state.sid = sid; - return sid; - }; - app.use(session(options, app)); - return app; -} diff --git a/test/contextstore.test.ts b/test/contextstore.test.ts new file mode 100644 index 0000000..e77f295 --- /dev/null +++ b/test/contextstore.test.ts @@ -0,0 +1,677 @@ +import { strict as assert } from 'node:assert'; +import { scheduler } from 'node:timers/promises'; +import Koa from 'koa'; +import { request } from '@eggjs/supertest'; +import { mm } from 'mm'; +import session, { type CreateSessionOptions } from '../src/index.js'; +import ContextStore from './context_store.js'; + +const inspect = Symbol.for('nodejs.util.inspect.custom'); + +function App(options: CreateSessionOptions = {}) { + const app = new Koa(); + app.keys = [ 'a', 'b' ]; + options.ContextStore = ContextStore; + options.genid = ctx => { + const sid = Date.now() + '_suffix'; + ctx.state.sid = sid; + return sid; + }; + app.use(session(options, app)); + return app; +} + +describe('Koa Session External Context Store', () => { + let cookie: string; + + describe('when the session contains a ;', () => { + it('should still work', async () => { + const app = App(); + + app.use(async function(ctx) { + if (ctx.method === 'POST') { + ctx.session.string = ';'; + ctx.status = 204; + } else { + ctx.body = ctx.session.string; + } + }); + + const res = await request(app.callback()) + .post('/') + .expect(204); + const cookie = res.get('Set-Cookie')!; + assert(cookie, 'should have set cookie'); + await request(app.callback()) + .get('/') + .set('Cookie', cookie.join(';')) + .expect(';'); + }); + }); + + describe('new session', () => { + describe('when not accessed', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.body = 'greetings'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); + }); + }); + + describe('when accessed and not populated', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session; + ctx.body = 'greetings'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); + }); + }); + + describe('when populated', () => { + it('should Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.message = 'hello'; + ctx.body = ''; + }); + + const res = await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + cookie = res.get('Set-Cookie')!.join(';'); + assert.match(cookie, /\d+_suffix/); + }); + + it('should pass sid to middleware', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.message = 'hello'; + assert.match(ctx.state.sid, /\d+_suffix/); + ctx.body = ''; + }); + + const res = await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + + cookie = res.get('Set-Cookie')!.join(';'); + assert.match(cookie, /\d+_suffix/); + }); + + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.body = ctx.session; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); + }); + }); + }); + + describe('saved session', () => { + describe('when not accessed', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.body = 'aklsdjflasdjf'; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); + }); + }); + + describe('when accessed but not changed', () => { + it('should be the same session', async () => { + const app = App(); + + app.use(async function(ctx) { + assert.equal(ctx.session.message, 'hello'); + ctx.body = 'aklsdjflasdjf'; + }); + + await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + }); + + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + assert.equal(ctx.session.message, 'hello'); + ctx.body = 'aklsdjflasdjf'; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); + }); + }); + + describe('when accessed and changed', () => { + it('should Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.money = '$$$'; + ctx.body = 'aklsdjflasdjf'; + }); + + await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + }); + + describe('when session is', () => { + describe('null', () => { + it('should expire the session', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session = null; + ctx.body = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/); + }); + }); + + describe('an empty object', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session = {}; + ctx.body = 'asdf'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); + }); + }); + + describe('an object', () => { + it('should create a session', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session = { message: 'hello' }; + ctx.body = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + + describe('anything else', () => { + it('should throw', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect(500); + }); + }); + }); + + describe('session', () => { + describe('.inspect()', () => { + it('should return session content', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.foo = 'bar'; + ctx.body = ctx.session[inspect](); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect({ foo: 'bar' }) + .expect(200); + }); + }); + + describe('.length', () => { + it('should return session length', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.foo = 'bar'; + ctx.body = String(ctx.session.length); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('1') + .expect(200); + }); + }); + + describe('.populated', () => { + it('should return session populated', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.foo = 'bar'; + ctx.body = String(ctx.session.populated); + }); + + await request(app.listen()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('true') + .expect(200); + }); + }); + + describe('.save()', () => { + it('should save session', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.save(); + ctx.body = 'hello'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('hello') + .expect(200); + }); + }); + }); + + describe('when an error is thrown downstream and caught upstream', () => { + it('should still save the session', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + + app.use(async function(ctx, next) { + try { + await next(); + } catch (err: any) { + ctx.status = err.status; + ctx.body = err.message; + } + }); + + app.use(session({ ContextStore }, app)); + + app.use(async function(ctx, next) { + ctx.session.name = 'funny'; + await next(); + }); + + app.use(async function(ctx) { + ctx.throw(401); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(401); + }); + }); + + describe('when autoCommit is present', () => { + describe('and set to false', () => { + it('should not set headers if manuallyCommit() isn\'t called', async () => { + const app = App({ autoCommit: false }); + + app.use(async function(ctx) { + if (ctx.method === 'POST') { + ctx.session.message = 'hi'; + ctx.body = 200; + return; + } + ctx.body = ctx.session.message; + }); + + const res = await request(app.callback()) + .post('/') + .expect(200); + assert.equal(res.get('Set-Cookie'), undefined, 'should not have set cookie'); + }); + + it('should set headers if manuallyCommit() is called', async () => { + const app = App({ autoCommit: false }); + app.use(async function(ctx, next) { + if (ctx.method === 'POST') { + ctx.session.message = 'dummy'; + } + await next(); + }); + app.use(async function(ctx) { + ctx.body = 200; + await ctx.session.manuallyCommit(); + }); + + await request(app.callback()) + .post('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + }); + + describe('when maxAge present', () => { + describe('and set to be a session cookie', () => { + it('should not expire the session', async () => { + const app = App({ maxAge: 'session' }); + + app.use(async function(ctx) { + if (ctx.method === 'POST') { + ctx.session.message = 'hi'; + ctx.body = 200; + return; + } + ctx.body = ctx.session.message; + }); + + const res = await request(app.callback()) + .post('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + + const cookie = res.get('Set-Cookie')!.join(';'); + assert.doesNotMatch(cookie, /expires=/); + await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect('hi'); + }); + + it('should not expire the session after multiple session changes', async () => { + const app = App({ maxAge: 'session' }); + + app.use(async function(ctx) { + ctx.session.count = (ctx.session.count || 0) + 1; + ctx.body = `hi ${ctx.session.count}`; + }); + + let res = await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect('hi 1') + .expect(200); + let cookie = res.get('Set-Cookie')!.join(';'); + assert.doesNotMatch(cookie, /expires=/); + + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect('Set-Cookie', /koa\.sess/) + .expect('hi 2') + .expect(200); + cookie = res.get('Set-Cookie')!.join(';'); + assert.doesNotMatch(cookie, /expires=/); + + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect('Set-Cookie', /koa\.sess/) + .expect('hi 3') + .expect(200); + cookie = res.get('Set-Cookie')!.join(';'); + assert.doesNotMatch(cookie, /expires=/); + }); + + it('should throw error when the maxAge improper string given', () => { + assert.throws(() => { + App({ maxAge: 'not the right string' } as any); + }, /Invalid input/); + }); + }); + + describe('and not expire', () => { + it('should not expire the session', async () => { + const app = App({ maxAge: 100 }); + + app.use(async function(ctx) { + if (ctx.method === 'POST') { + ctx.session.message = 'hi'; + ctx.body = 200; + return; + } + ctx.body = ctx.session.message; + }); + + const res = await request(app.callback()) + .post('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + const cookie = res.get('Set-Cookie')!.join(';'); + await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect('hi'); + }); + }); + + describe('and expired', () => { + it('should expire the sess', async () => { + const app = App({ maxAge: 100 }); + + app.use(async function(ctx) { + if (ctx.method === 'POST') { + ctx.session.message = 'hi'; + ctx.status = 200; + return; + } + + ctx.body = ctx.session.message || ''; + }); + + const res = await request(app.callback()) + .post('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + const cookie = res.get('Set-Cookie')!.join(';'); + await scheduler.wait(200); + await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect(''); + }); + }); + }); + + describe('ctx.session.maxAge', () => { + it('should return opt.maxAge', async () => { + const app = App({ maxAge: 100 }); + + app.use(async function(ctx) { + ctx.body = ctx.session.maxAge; + }); + + await request(app.callback()) + .get('/') + .expect('100'); + }); + }); + + describe('ctx.session.maxAge=', () => { + it('should set sessionOptions.maxAge', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.foo = 'bar'; + ctx.session.maxAge = 100; + ctx.body = ctx.session.foo; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + + it('should save even session not change', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session.maxAge = 100; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + + it('should save when create session only with maxAge', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.session = { maxAge: 100 }; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + }); + + describe('when store return empty', () => { + it('should create new Session', async () => { + const app = App({ signed: false }); + + app.use(async function(ctx) { + ctx.body = String(ctx.session.isNew); + }); + + await request(app.callback()) + .get('/') + .set('cookie', 'koa.sess=invalid-key') + .expect('true') + .expect(200); + }); + }); + + describe('when valid and beforeSave set', () => { + it('should ignore session when uid changed', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + app.use(session({ + valid(ctx, sess) { + return ctx.cookies.get('uid') === sess.uid; + }, + beforeSave(ctx, sess) { + sess.uid = ctx.cookies.get('uid'); + }, + ContextStore, + }, app)); + + app.use(async function(ctx) { + if (!ctx.session.foo) { + ctx.session.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); + } + + ctx.body = { + foo: ctx.session.foo, + uid: ctx.cookies.get('uid'), + }; + }); + + let res = await request(app.callback()) + .get('/') + .set('Cookie', 'uid=123') + .expect(200); + + const data = res.body; + const cookies = res.get('Set-Cookie')!.join(';'); + assert.match(cookies, /koa\.sess=/); + + res = await request(app.callback()) + .get('/') + .set('Cookie', cookies + ';uid=123') + .expect(200) + .expect(data); + + // should ignore uid:123 session and create a new session for uid:456 + res = await request(app.callback()) + .get('/') + .set('Cookie', cookies + ';uid=456') + .expect(200); + assert.equal(res.body.uid, '456'); + assert.notEqual(res.body.foo, data.foo); + }); + }); + + describe('ctx.session', () => { + after(mm.restore); + + it('can be mocked', async () => { + const app = App(); + + app.use(async function(ctx) { + ctx.body = ctx.session; + }); + + mm(app.context, 'session', { + foo: 'bar', + }); + + await request(app.callback()) + .get('/') + .expect({ + foo: 'bar', + }) + .expect(200); + }); + }); +}); diff --git a/test/cookie.test.js b/test/cookie.test.js deleted file mode 100644 index 69b0683..0000000 --- a/test/cookie.test.js +++ /dev/null @@ -1,948 +0,0 @@ -'use strict'; - -const assert = require('assert'); -const Koa = require('koa'); -const request = require('supertest'); -const should = require('should'); -const session = require('..'); - -const inspect = Symbol.for('nodejs.util.inspect.custom'); - -describe('Koa Session Cookie', () => { - let cookie; - - describe('when options.signed = true', () => { - describe('when app.keys are set', () => { - it('should work', done => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - app.use(session({}, app)); - - app.use(async function(ctx) { - ctx.session.message = 'hi'; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect(200, done); - }); - }); - - describe('when app.keys are not set', () => { - it('should throw and clean this cookie', done => { - const app = new Koa(); - - app.use(session(app)); - - app.use(async function(ctx) { - ctx.session.message = 'hi'; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect(500, done); - }); - }); - - describe('when app not set', () => { - it('should throw', () => { - const app = new Koa(); - (function() { - app.use(session()); - }).should.throw('app instance required: `session(opts, app)`'); - }); - }); - }); - - describe('when options.signed = false', () => { - describe('when app.keys are not set', () => { - it('should work', done => { - const app = new Koa(); - - app.use(session({ signed: false }, app)); - - app.use(async function(ctx) { - ctx.session.message = 'hi'; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect(200, done); - }); - }); - }); - - describe('when the session contains a ;', () => { - it('should still work', done => { - const app = App(); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.string = ';'; - ctx.status = 204; - } else { - ctx.body = ctx.session.string; - } - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect(204, (err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie']; - // samesite is not set - assert(!cookie.join(';').includes('samesite')); - request(server) - .get('/') - .set('Cookie', cookie.join(';')) - .expect(';', done); - }); - }); - }); - - describe('new session', () => { - describe('when not accessed', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = 'greetings'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed and not populated', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session; - ctx.body = 'greetings'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when populated', () => { - it('should Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message = 'hello'; - ctx.body = ''; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, (err, res) => { - if (err) return done(err); - cookie = res.header['set-cookie'].join(';'); - done(); - }); - }); - - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - }); - - describe('saved session', () => { - describe('when not accessed', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed but not changed', () => { - it('should be the same session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message.should.equal('hello'); - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, done); - }); - - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message.should.equal('hello'); - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed and changed', () => { - it('should Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.money = '$$$'; - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect('Set-Cookie', /koa\.sess/) - .expect(res => { - const cookie = res.headers['set-cookie']; - // samesite is not set - assert(!cookie.join(';').includes('samesite')); - }) - .expect(200, done); - }); - }); - }); - - describe('after session set to null with signed cookie', () => { - it('should return expired cookies', done => { - const app = App({ - signed: true, - }); - - app.use(async function(ctx) { - ctx.session.hello = {}; - ctx.session = null; - ctx.body = String(ctx.session === null); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=; path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) - .expect('Set-Cookie', /koa\.sess.sig=(.*); path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) - .expect('true') - .expect(200, done); - }); - }); - - describe('after session set to null without signed cookie', () => { - it('should return expired cookies', done => { - const app = App({ - signed: false, - }); - - app.use(async function(ctx) { - ctx.session.hello = {}; - ctx.session = null; - ctx.body = String(ctx.session === null); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=; path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) - .expect('true') - .expect(200, done); - }); - }); - - describe('when get session after set to null', () => { - it('should return null', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.hello = {}; - ctx.session = null; - ctx.body = String(ctx.session === null); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=;/) - .expect('true') - .expect(200, done); - }); - }); - - describe('when decode session', () => { - describe('SyntaxError', () => { - it('should create new session', done => { - const app = App({ signed: false }); - - app.use(async function(ctx) { - ctx.body = String(ctx.session.isNew); - }); - - request(app.listen()) - .get('/') - .set('cookie', 'koa.sess=invalid-session;') - .expect('true') - .expect(200, done); - }); - }); - - describe('Other Error', () => { - it('should throw', done => { - const app = App({ - signed: false, - decode() { - throw new Error('decode error'); - }, - }); - - app.use(async function(ctx) { - ctx.body = String(ctx.session.isNew); - }); - - request(app.listen()) - .get('/') - .set('cookie', 'koa.sess=invalid-session;') - .expect('Set-Cookie', /koa\.sess=;/) - .expect(500, done); - }); - }); - }); - - describe('when encode session error', () => { - it('should throw', done => { - const app = App({ - encode() { - throw new Error('encode error'); - }, - }); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = 'hello'; - }); - - app.once('error', (err, ctx) => { - err.message.should.equal('encode error'); - should.exists(ctx); - }); - - request(app.listen()) - .get('/') - .expect(500, done); - }); - }); - - describe('session', () => { - describe('.inspect()', () => { - it('should return session content', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = ctx.session[inspect](); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect({ foo: 'bar' }) - .expect(200, done); - }); - }); - - describe('.length', () => { - it('should return session length', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = String(ctx.session.length); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('1') - .expect(200, done); - }); - }); - - describe('.populated', () => { - it('should return session populated', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = String(ctx.session.populated); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('true') - .expect(200, done); - }); - }); - - describe('.save()', () => { - it('should save session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.save(); - ctx.body = 'hello'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('hello') - .expect(200, done); - }); - }); - }); - - describe('when session is', () => { - describe('null', () => { - it('should expire the session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = null; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - - describe('an empty object', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = {}; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('an object', () => { - it('should create a session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = { message: 'hello' }; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - - describe('anything else', () => { - it('should throw', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect(500, done); - }); - }); - }); - - describe('when an error is thrown downstream and caught upstream', () => { - it('should still save the session', done => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - - app.use(async function(ctx, next) { - try { - await next(); - } catch (err) { - ctx.status = err.status; - ctx.body = err.message; - } - }); - - app.use(session(app)); - - app.use(async function(ctx, next) { - ctx.session.name = 'funny'; - await next(); - }); - - app.use(async function(ctx) { - ctx.throw(401); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(401, done); - }); - }); - - describe('when maxAge present', () => { - describe('and not expire', () => { - it('should not expire the session', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - - request(server) - .get('/') - .set('cookie', cookie) - .expect('hi', done); - }); - }); - }); - - describe('and expired', () => { - it('should expire the sess', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.status = 200; - return; - } - - ctx.body = ctx.session.message || ''; - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - - setTimeout(() => { - request(server) - .get('/') - .set('cookie', cookie) - .expect('', done); - }, 200); - }); - }); - }); - }); - - describe('ctx.session.maxAge', () => { - it('should return opt.maxAge', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - ctx.body = ctx.session.maxAge; - }); - - request(app.listen()) - .get('/') - .expect('100', done); - }); - }); - - describe('ctx.session.maxAge=', () => { - it('should set sessionOptions.maxAge', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.session.maxAge = 100; - ctx.body = ctx.session.foo; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - - it('should save even session not change', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.maxAge = 100; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - - it('should save when create session only with maxAge', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = { maxAge: 100 }; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - }); - - describe('ctx.session.regenerate', () => { - it('should change the session key, but not content', done => { - const app = new App(); - const message = 'hi'; - app.use(async function(ctx, next) { - ctx.session = { message: 'hi' }; - await next(); - }); - - app.use(async function(ctx, next) { - const sessionKey = ctx.cookies.get('koa.sess'); - if (sessionKey) { - await ctx.session.regenerate(); - } - await next(); - }); - - app.use(async function(ctx) { - ctx.session.message.should.equal(message); - ctx.body = ''; - }); - let koaSession = null; - request(app.callback()) - .get('/') - .expect(200, (err, res) => { - should.not.exist(err); - koaSession = res.headers['set-cookie'][0]; - koaSession.should.containEql('koa.sess='); - request(app.callback()) - .get('/') - .set('Cookie', koaSession) - .expect(200, (err, res) => { - should.not.exist(err); - const cookies = res.headers['set-cookie'][0]; - cookies.should.containEql('koa.sess='); - cookies.should.not.equal(koaSession); - done(); - }); - }); - }); - }); - - describe('when get session before enter session middleware', () => { - it('should work', done => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - app.use(async function(ctx, next) { - ctx.session.foo = 'hi'; - await next(); - }); - app.use(session({}, app)); - app.use(async function(ctx) { - ctx.body = ctx.session; - }); - - request(app.callback()) - .get('/') - .expect(200, (err, res) => { - should.not.exist(err); - const cookies = res.headers['set-cookie'].join(';'); - cookies.should.containEql('koa.sess='); - - request(app.callback()) - .get('/') - .set('Cookie', cookies) - .expect(200, done); - }); - }); - }); - - describe('options.sameSite', () => { - it('should return opt.sameSite=none', done => { - const app = App({ sameSite: 'none' }); - - app.use(async function(ctx) { - ctx.session = { foo: 'bar' }; - ctx.body = ctx.session.foo; - }); - - request(app.listen()) - .get('/') - .expect(res => { - const cookie = res.headers['set-cookie'].join('|'); - assert(cookie.includes('path=/; samesite=none; httponly')); - }) - .expect('bar') - .expect(200, done); - }); - - it('should return opt.sameSite=lax', done => { - const app = App({ sameSite: 'lax' }); - - app.use(async function(ctx) { - ctx.session = { foo: 'bar' }; - ctx.body = ctx.session.foo; - }); - - request(app.listen()) - .get('/') - .expect(res => { - const cookie = res.headers['set-cookie'].join('|'); - assert(cookie.includes('path=/; samesite=lax; httponly')); - }) - .expect('bar') - .expect(200, done); - }); - }); - - describe('when valid and beforeSave set', () => { - it('should ignore session when uid changed', done => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - app.use(session({ - valid(ctx, sess) { - return ctx.cookies.get('uid') === sess.uid; - }, - beforeSave(ctx, sess) { - sess.uid = ctx.cookies.get('uid'); - }, - }, app)); - app.use(async function(ctx) { - if (!ctx.session.foo) { - ctx.session.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); - } - - ctx.body = { - foo: ctx.session.foo, - uid: ctx.cookies.get('uid'), - }; - }); - - request(app.callback()) - .get('/') - .set('Cookie', 'uid=123') - .expect(200, (err, res) => { - should.not.exist(err); - const data = res.body; - const cookies = res.headers['set-cookie'].join(';'); - cookies.should.containEql('koa.sess='); - - request(app.callback()) - .get('/') - .set('Cookie', cookies + ';uid=123') - .expect(200) - .expect(data, err => { - should.not.exist(err); - - // should ignore uid:123 session and create a new session for uid:456 - request(app.callback()) - .get('/') - .set('Cookie', cookies + ';uid=456') - .expect(200, (err, res) => { - should.not.exist(err); - res.body.uid.should.equal('456'); - res.body.foo.should.not.equal(data.foo); - done(); - }); - }); - }); - }); - }); - - describe('when options.encode and options.decode are functions', () => { - describe('they are used to encode/decode stored cookie values', () => { - it('should work', done => { - let encodeCallCount = 0; - let decodeCallCount = 0; - - function encode(data) { - ++encodeCallCount; - return JSON.stringify({ enveloped: data }); - } - function decode(data) { - ++decodeCallCount; - return JSON.parse(data).enveloped; - } - - const app = new Koa(); - app.keys = [ 'a', 'b' ]; - app.use(session({ - encode, - decode, - }, app)); - - app.use(async function(ctx) { - ctx.session.counter = (ctx.session.counter || 0) + 1; - ctx.body = ctx.session; - return; - }); - - request(app.callback()) - .get('/') - .expect(() => { encodeCallCount.should.above(0, 'encode was not called'); }) - .expect(200, (err, res) => { - should.not.exist(err); - res.body.counter.should.equal(1, 'expected body to be equal to session.counter'); - const cookies = res.headers['set-cookie'].join(';'); - request(app.callback()) - .get('/') - .set('Cookie', cookies) - .expect(() => { decodeCallCount.should.be.above(0, 'decode was not called'); }) - .expect(200, (err, res) => { - should.not.exist(err); - res.body.counter.should.equal(2); - done(); - }); - }); - }); - }); - }); - - describe('when rolling set to true', () => { - let app; - before(() => { - app = App({ rolling: true }); - - app.use(function* () { - console.log(this.path); - if (this.path === '/set') this.session = { foo: 'bar' }; - this.body = this.session; - }); - }); - - it('should not send set-cookie when session not exists', () => { - return request(app.callback()) - .get('/') - .expect({}) - .expect(res => { - should.not.exist(res.headers['set-cookie']); - }); - }); - - it('should send set-cookie when session exists and not change', done => { - request(app.callback()) - .get('/set') - .expect({ foo: 'bar' }) - .end((err, res) => { - should.not.exist(err); - res.headers['set-cookie'].should.have.length(2); - const cookie = res.headers['set-cookie'].join(';'); - request(app.callback()) - .get('/') - .set('cookie', cookie) - .expect(res => { - res.headers['set-cookie'].should.have.length(2); - }) - .expect({ foo: 'bar' }, done); - }); - }); - }); - - describe('init multi session middleware', () => { - it('should work', () => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - const s1 = session({}, app); - const s2 = session({}, app); - assert(s1); - assert(s2); - }); - }); -}); - -function App(options) { - const app = new Koa(); - app.keys = [ 'a', 'b' ]; - app.use(session(options, app)); - return app; -} diff --git a/test/cookie.test.ts b/test/cookie.test.ts new file mode 100644 index 0000000..add102e --- /dev/null +++ b/test/cookie.test.ts @@ -0,0 +1,900 @@ +import { strict as assert } from 'node:assert'; +import Koa from 'koa'; +import { request } from '@eggjs/supertest'; +import session, { type CreateSessionOptions } from '../src/index.js'; + +function App(options: CreateSessionOptions = {}) { + const app = new Koa(); + app.keys = [ 'a', 'b' ]; + app.use(session(options, app)); + return app; +} + +const inspect = Symbol.for('nodejs.util.inspect.custom'); + +describe('Koa Session Cookie', () => { + let cookie: string; + + describe('when options.signed = true', () => { + describe('when app.keys are set', () => { + it('should work', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + app.use(session({}, app)); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.message = 'hi'; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect(200); + }); + }); + + describe('when app.keys are not set', () => { + it('should throw and clean this cookie', async () => { + const app = new Koa(); + + app.use(session(app)); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.message = 'hi'; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect(500); + }); + }); + + describe('when app not set', () => { + it('should throw', () => { + const app = new Koa(); + assert.throws(() => { + app.use((session as any)()); + }, /app instance required: `session\(opts, app\)`/); + assert.throws(() => { + app.use(session({})); + }, /app instance required: `session\(opts, app\)`/); + }); + }); + }); + + describe('when options.signed = false', () => { + describe('when app.keys are not set', () => { + it('should work', async () => { + const app = new Koa(); + + app.use(session({ signed: false }, app)); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.message = 'hi'; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect(200); + }); + }); + }); + + describe('when the session contains a ;', () => { + it('should still work', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.string = ';'; + ctx.status = 204; + } else { + ctx.body = ctx.session!.string; + } + }); + + const server = app.callback(); + + const res = await request(server) + .post('/') + .expect(204); + + const cookie = res.get('Set-Cookie')!; + // samesite is not set + assert(!cookie.join(';').includes('samesite')); + await request(server) + .get('/') + .set('Cookie', cookie.join(';')) + .expect(';'); + }); + }); + + describe('new session', () => { + describe('when not accessed', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.body = 'greetings'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.header['set-cookie'], undefined); + }); + }); + + describe('when accessed and not populated', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session; + ctx.body = 'greetings'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.header['set-cookie'], undefined); + }); + }); + + describe('when populated', () => { + it('should Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.message = 'hello'; + ctx.body = ''; + }); + + const res = await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + cookie = res.get('Set-Cookie')!.join(';'); + }); + + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.body = ctx.session; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.header['set-cookie'], undefined); + }); + }); + }); + + describe('saved session', () => { + describe('when not accessed', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.body = 'aklsdjflasdjf'; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + assert.equal(res.header['set-cookie'], undefined); + }); + }); + + describe('when accessed but not changed', () => { + it('should be the same session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + assert.equal(ctx.session!.message, 'hello'); + ctx.body = 'aklsdjflasdjf'; + }); + + await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + }); + + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + assert.equal(ctx.session!.message, 'hello'); + ctx.body = 'aklsdjflasdjf'; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + assert.equal(res.header['set-cookie'], undefined); + }); + }); + + describe('when accessed and changed', () => { + it('should Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.money = '$$$'; + ctx.body = 'aklsdjflasdjf'; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + const newCookie = res.get('Set-Cookie')!; + // samesite is not set + assert(!newCookie.join(';').includes('samesite')); + }); + }); + }); + + describe('after session set to null with signed cookie', () => { + it('should return expired cookies', async () => { + const app = App({ + signed: true, + }); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.hello = {}; + ctx.session = null; + ctx.body = String(ctx.session === null); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', + /koa\.sess=; path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) + .expect('Set-Cookie', + /koa\.sess.sig=(.*); path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) + .expect('true') + .expect(200); + }); + }); + + describe('after session set to null without signed cookie', () => { + it('should return expired cookies', async () => { + const app = App({ + signed: false, + }); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.hello = {}; + ctx.session = null; + ctx.body = String(ctx.session === null); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=; path=\/; expires=Thu, 01 Jan 1970 00:00:00 GMT/) + .expect('true') + .expect(200); + }); + }); + + describe('when get session after set to null', () => { + it('should return null', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.hello = {}; + ctx.session = null; + ctx.body = String(ctx.session === null); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=;/) + .expect('true') + .expect(200); + }); + }); + + describe('when decode session', () => { + describe('SyntaxError', () => { + it('should create new session', async () => { + const app = App({ signed: false }); + + app.use(async (ctx: Koa.Context) => { + ctx.body = String(ctx.session.isNew); + }); + + await request(app.callback()) + .get('/') + .set('cookie', 'koa.sess=invalid-session;') + .expect('true') + .expect(200); + }); + }); + + describe('Other Error', () => { + it('should throw', async () => { + const app = App({ + signed: false, + decode() { + throw new Error('decode error'); + }, + }); + + app.use(async (ctx: Koa.Context) => { + ctx.body = String(ctx.session!.isNew); + }); + + await request(app.callback()) + .get('/') + .set('cookie', 'koa.sess=invalid-session;') + .expect('Set-Cookie', /koa\.sess=;/) + .expect(500); + }); + }); + }); + + describe('when encode session error', () => { + it('should throw', async () => { + const app = App({ + encode() { + throw new Error('encode error'); + }, + }); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.body = 'hello'; + }); + + app.once('error', (err, ctx) => { + assert.equal(err.message, 'encode error'); + assert(ctx); + }); + + await request(app.callback()) + .get('/') + .expect(500); + }); + }); + + describe('session', () => { + describe('.inspect()', () => { + it('should return session content', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.body = ctx.session![inspect](); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect({ foo: 'bar' }) + .expect(200); + }); + }); + + describe('.length', () => { + it('should return session length', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.body = String(ctx.session!.length); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('1') + .expect(200); + }); + }); + + describe('.populated', () => { + it('should return session populated', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.body = String(ctx.session!.populated); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('true') + .expect(200); + }); + }); + + describe('.save()', () => { + it('should save session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.save(); + ctx.body = 'hello'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('hello') + .expect(200); + }); + }); + }); + + describe('when session is', () => { + describe('null', () => { + it('should expire the session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = null; + ctx.body = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + + describe('an empty object', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = {}; + ctx.body = 'asdf'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + assert.equal(res.header['set-cookie'], undefined); + }); + }); + + describe('an object', () => { + it('should create a session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = { message: 'hello' }; + ctx.body = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + + describe('anything else', () => { + it('should throw', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect(/Internal Server Error/) + .expect(500); + }); + }); + }); + + describe('when an error is thrown downstream and caught upstream', () => { + it('should still save the session', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + try { + await next(); + } catch (err: any) { + ctx.status = err.status; + ctx.body = err.message; + } + }); + + app.use(session(app)); + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + ctx.session.name = 'funny'; + await next(); + }); + + app.use(async (ctx: Koa.Context) => { + ctx.throw(401); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(401); + }); + }); + + describe('when maxAge present', () => { + describe('and not expire', () => { + it('should not expire the session', async () => { + const app = App({ maxAge: 100 }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.message = 'hi'; + ctx.body = 200; + return; + } + ctx.body = ctx.session!.message; + }); + + const server = app.callback(); + + const res = await request(server) + .post('/') + .expect('Set-Cookie', /koa\.sess/); + + const cookie = res.get('Set-Cookie')!.join(';'); + + await request(server) + .get('/') + .set('cookie', cookie) + .expect('hi'); + }); + }); + + describe('and expired', () => { + it('should expire the sess', async () => { + const app = App({ maxAge: 100 }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.message = 'hi'; + ctx.status = 200; + return; + } + + ctx.body = ctx.session.message || ''; + }); + + const server = app.callback(); + + const res = await request(server) + .post('/') + .expect('Set-Cookie', /koa\.sess/); + + const cookie = res.get('Set-Cookie')!.join(';'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + await request(server) + .get('/') + .set('cookie', cookie) + .expect(''); + }); + }); + }); + + describe('ctx.session.maxAge', () => { + it('should return opt.maxAge', async () => { + const app = App({ maxAge: 100 }); + + app.use(async (ctx: Koa.Context) => { + ctx.body = ctx.session!.maxAge; + }); + + await request(app.callback()) + .get('/') + .expect('100'); + }); + }); + + describe('ctx.session.maxAge=', () => { + it('should set sessionOptions.maxAge', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.session!.maxAge = 100; + ctx.body = ctx.session!.foo; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + + it('should save even session not change', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.maxAge = 100; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + + it('should save when create session only with maxAge', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = { maxAge: 100 }; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + }); + + describe('ctx.session.regenerate', () => { + it('should change the session key, but not content', async () => { + const app = App(); + const message = 'hi'; + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + ctx.session = { message: 'hi' }; + await next(); + }); + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + const sessionKey = ctx.cookies.get('koa.sess'); + if (sessionKey) { + await ctx.session!.regenerate(); + } + await next(); + }); + + app.use(async (ctx: Koa.Context) => { + assert.equal(ctx.session!.message, message); + ctx.body = ''; + }); + let res = await request(app.callback()) + .get('/') + .expect(200); + + const koaSession = res.get('Set-Cookie')!.join(';'); + assert.match(koaSession, /koa\.sess=/); + res = await request(app.callback()) + .get('/') + .set('Cookie', koaSession) + .expect(200); + + const cookies = res.get('Set-Cookie')!.join(';'); + assert.match(cookies, /koa\.sess=/); + assert.notEqual(cookies, koaSession); + }); + }); + + describe('when get session before enter session middleware', () => { + it('should work', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + ctx.session!.foo = 'hi'; + await next(); + }); + app.use(session({}, app)); + app.use(async (ctx: Koa.Context) => { + ctx.body = ctx.session; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + + const cookies = res.get('Set-Cookie')!.join(';'); + assert(cookies.includes('koa.sess=')); + + await request(app.callback()) + .get('/') + .set('Cookie', cookies) + .expect(200); + }); + }); + + describe('options.sameSite', () => { + it('should return opt.sameSite=none', async () => { + const app = App({ sameSite: 'none' }); + + app.use(async (ctx: Koa.Context) => { + ctx.session = { foo: 'bar' }; + ctx.body = ctx.session.foo; + }); + + const res = await request(app.callback()) + .get('/') + .expect('bar') + .expect(200); + const cookie = res.get('Set-Cookie')!.join('|'); + assert(cookie.includes('path=/; samesite=none; httponly')); + }); + + it('should return opt.sameSite=lax', async () => { + const app = App({ sameSite: 'lax' }); + + app.use(async (ctx: Koa.Context) => { + ctx.session = { foo: 'bar' }; + ctx.body = ctx.session.foo; + }); + + const res = await request(app.callback()) + .get('/') + .expect('bar') + .expect(200); + const cookie = res.get('Set-Cookie')!.join('|'); + assert(cookie.includes('path=/; samesite=lax; httponly')); + }); + }); + + describe('when valid and beforeSave set', () => { + it('should ignore session when uid changed', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + app.use(session({ + valid(ctx, sess) { + return ctx.cookies.get('uid') === sess.uid; + }, + beforeSave(ctx, sess) { + sess.uid = ctx.cookies.get('uid'); + }, + }, app)); + app.use(async (ctx: Koa.Context) => { + if (!ctx.session!.foo) { + ctx.session!.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); + } + + ctx.body = { + foo: ctx.session!.foo, + uid: ctx.cookies.get('uid'), + }; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', 'uid=123') + .expect(200); + + const data = res.body; + const cookies = res.get('Set-Cookie')!.join(';'); + assert(cookies.includes('koa.sess=')); + + await request(app.callback()) + .get('/') + .set('Cookie', cookies + ';uid=123') + .expect(200) + .expect(data); + + // should ignore uid:123 session and create a new session for uid:456 + const res2 = await request(app.callback()) + .get('/') + .set('Cookie', cookies + ';uid=456') + .expect(200); + + assert.equal(res2.body.uid, '456'); + assert.notEqual(res2.body.foo, data.foo); + }); + }); + + describe('when options.encode and options.decode are functions', () => { + describe('they are used to encode/decode stored cookie values', () => { + it('should work', async () => { + let encodeCallCount = 0; + let decodeCallCount = 0; + + function encode(data: any) { + ++encodeCallCount; + return JSON.stringify({ enveloped: data }); + } + function decode(data: string) { + ++decodeCallCount; + return JSON.parse(data).enveloped; + } + + const app = new Koa(); + app.keys = [ 'a', 'b' ]; + app.use(session({ + encode, + decode, + }, app)); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.counter = (ctx.session!.counter || 0) + 1; + ctx.body = ctx.session; + return; + }); + + const res = await request(app.callback()) + .get('/') + .expect(() => { assert(encodeCallCount > 0, 'encode was not called'); }) + .expect(200); + + assert.equal(res.body.counter, 1, 'expected body to be equal to session.counter'); + const cookies = res.get('Set-Cookie')!.join(';'); + const res2 = await request(app.callback()) + .get('/') + .set('Cookie', cookies) + .expect(() => { assert(decodeCallCount > 0, 'decode was not called'); }) + .expect(200); + + assert.equal(res2.body.counter, 2); + }); + }); + }); + + describe('when rolling set to true', () => { + let app: Koa; + before(() => { + app = App({ rolling: true }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.path === '/set') ctx.session = { foo: 'bar' }; + ctx.body = ctx.session; + }); + }); + + it('should not send set-cookie when session not exists', async () => { + const res = await request(app.callback()) + .get('/') + .expect({}); + + assert.equal(res.headers['set-cookie'], undefined); + }); + + it('should send set-cookie when session exists and not change', async () => { + const res = await request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + assert.equal(res.get('Set-Cookie')!.length, 2); + const cookie = res.get('Set-Cookie')!.join(';'); + const res2 = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + assert.equal(res2.headers['set-cookie'].length, 2); + }); + }); + + describe('init multi session middleware', () => { + it('should work', () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + const s1 = session({}, app); + const s2 = session({}, app); + assert(s1); + assert(s2); + }); + }); +}); diff --git a/test/externalkey.test.js b/test/externalkey.test.js deleted file mode 100644 index 3dfd8c1..0000000 --- a/test/externalkey.test.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; -const Koa = require('koa'); -const request = require('supertest'); -const assert = require('assert'); -const session = require('..'); -const store = require('./store'); -const TOKEN_KEY = 'User-Token'; - -describe('Koa Session External Key', () => { - describe('when the external key set/get is invalid', () => { - it('should throw a error', () => { - try { - new App({ - externalKey: {}, - }); - } catch (err) { - assert.equal(err.message, 'externalKey.get must be function'); - } - }); - }); - describe('custom get/set external key', () => { - it('should still work', done => { - const app = App(); - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.string = ';'; - ctx.status = 204; - assert(ctx.session.externalKey); - } else { - ctx.body = ctx.session.string; - assert(ctx.session.externalKey === ctx.get(TOKEN_KEY)); - } - }); - const server = app.listen(); - request(server) - .post('/') - .expect(204, (err, res) => { - if (err) return done(err); - const token = res.get(TOKEN_KEY); - request(server) - .get('/') - .set(TOKEN_KEY, token) - .expect(';', done); - }); - }); - }); -}); -function App(options) { - const app = new Koa(); - app.keys = [ 'a', 'b' ]; - options = options || {}; - options.store = store; - options.externalKey = options.externalKey || { - get: ctx => ctx.get(TOKEN_KEY), - set: (ctx, value) => ctx.set(TOKEN_KEY, value), - }; - app.use(session(options, app)); - return app; -} diff --git a/test/externalkey.test.ts b/test/externalkey.test.ts new file mode 100644 index 0000000..ef69845 --- /dev/null +++ b/test/externalkey.test.ts @@ -0,0 +1,60 @@ +import { strict as assert } from 'node:assert'; +import Koa from 'koa'; +import { ZodError } from 'zod'; +import { request } from '@eggjs/supertest'; +import session, { type CreateSessionOptions } from '../src/index.js'; +import store from './store.js'; + +const TOKEN_KEY = 'User-Token'; + +function App(options: CreateSessionOptions = {}) { + const app = new Koa(); + app.keys = [ 'a', 'b' ]; + options.store = store; + options.externalKey = options.externalKey ?? { + get: ctx => ctx.get(TOKEN_KEY), + set: (ctx, value) => ctx.set(TOKEN_KEY, value), + }; + app.use(session(options, app)); + return app; +} + +describe('Koa Session External Key', () => { + describe('when the external key set/get is invalid', () => { + it('should throw a error', () => { + assert.throws(() => { + App({ + externalKey: {} as any, + }); + }, err => { + assert(err instanceof ZodError); + assert.match(err.message, /externalKey/); + return true; + }); + }); + }); + + describe('custom get/set external key', () => { + it('should still work', async () => { + const app = App(); + app.use(async function(ctx) { + if (ctx.method === 'POST') { + ctx.session.string = ';'; + ctx.status = 204; + assert(ctx.session.externalKey); + } else { + ctx.body = ctx.session.string; + assert.equal(ctx.session.externalKey, ctx.get(TOKEN_KEY)); + } + }); + const res = await request(app.callback()) + .post('/') + .expect(204); + const token = res.get(TOKEN_KEY)!; + await request(app.callback()) + .get('/') + .set(TOKEN_KEY as any, token) + .expect(';'); + }); + }); +}); diff --git a/test/genid_bench.js b/test/genid_bench.js deleted file mode 100644 index 8ff2900..0000000 --- a/test/genid_bench.js +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -const Benchmark = require('benchmark'); -const uuid = require('uuid'); -const uid = require('uid-safe'); - -const suite = new Benchmark.Suite(); - -const genidByUid = () => `${Date.now()}-${uid.sync(24)}`; -const genidByUuidV1 = () => uuid.v1(); -const genidByUuidV4 = () => uuid.v4(); - -console.log('genidByUid() => %s', genidByUid()); -console.log('genidByUuidV1() => %s', genidByUuidV1()); -console.log('genidByUuidV4() => %s', genidByUuidV4()); - -// add tests -suite -.add('uid()', function() { - genidByUid(); -}) -.add('genidByUuidV1()', function() { - genidByUuidV1(); -}) -.add('genidByUuidV4()', function() { - genidByUuidV4(); -}) -// add listeners -.on('cycle', function(event) { - console.log(String(event.target)); -}) -.on('complete', function() { - console.log('Fastest is ' + this.filter('fastest').map('name')); -}) -// run async -.run({ async: true }); - -// genidByUid() => 1556529339180-DRnQyEqlYjGr_Zq_42fHpdFMlBfVlAoG -// genidByUuidV1() => 5af3b830-6a5f-11e9-91fb-abc918efca3d -// genidByUuidV4() => 27088fa8-8436-4c8b-aae7-76ba316db9e3 -// -// uid() x 260,850 ops/sec ±1.50% (84 runs sampled) -// genidByUuidV1() x 1,181,483 ops/sec ±0.93% (86 runs sampled) -// genidByUuidV4() x 301,840 ops/sec ±1.40% (83 runs sampled) -// Fastest is genidByUuidV1() diff --git a/test/store.js b/test/store.js deleted file mode 100644 index e344c72..0000000 --- a/test/store.js +++ /dev/null @@ -1,17 +0,0 @@ -'use strict'; - -const sessions = {}; - -module.exports = { - async get(key) { - return sessions[key]; - }, - - async set(key, value) { - sessions[key] = value; - }, - - async destroy(key) { - sessions[key] = undefined; - }, -}; diff --git a/test/store.test.js b/test/store.test.js deleted file mode 100644 index be3a5fe..0000000 --- a/test/store.test.js +++ /dev/null @@ -1,861 +0,0 @@ -'use strict'; - -const Koa = require('koa'); -const request = require('supertest'); -const should = require('should'); -const mm = require('mm'); -const session = require('..'); -const store = require('./store'); -const pedding = require('pedding'); -const assert = require('assert'); -const sleep = require('mz-modules/sleep'); - -const inspect = Symbol.for('nodejs.util.inspect.custom'); - -describe('Koa Session External Store', () => { - let cookie; - - describe('when the session contains a ;', () => { - it('should still work', done => { - const app = App(); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.string = ';'; - ctx.status = 204; - } else { - ctx.body = ctx.session.string; - } - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect(204, (err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie']; - request(server) - .get('/') - .set('Cookie', cookie.join(';')) - .expect(';', done); - }); - }); - }); - - describe('new session', () => { - describe('when not accessed', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = 'greetings'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed and not populated', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session; - ctx.body = 'greetings'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when populated', () => { - it('should Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message = 'hello'; - ctx.body = ''; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, (err, res) => { - if (err) return done(err); - cookie = res.header['set-cookie'].join(';'); - done(); - }); - }); - - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - }); - - describe('saved session', () => { - describe('when not accessed', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed but not changed', () => { - it('should be the same session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message.should.equal('hello'); - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, done); - }); - - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.message.should.equal('hello'); - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('when accessed and changed', () => { - it('should Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.money = '$$$'; - ctx.body = 'aklsdjflasdjf'; - }); - - request(app.listen()) - .get('/') - .set('Cookie', cookie) - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - }); - - describe('when session is', () => { - describe('null', () => { - it('should expire the session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = null; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - - describe('an empty object', () => { - it('should not Set-Cookie', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = {}; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect(200, (err, res) => { - if (err) return done(err); - res.header.should.not.have.property('set-cookie'); - done(); - }); - }); - }); - - describe('an object', () => { - it('should create a session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = { message: 'hello' }; - ctx.body = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(200, done); - }); - }); - - describe('anything else', () => { - it('should throw', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = 'asdf'; - }); - - request(app.listen()) - .get('/') - .expect(500, done); - }); - }); - }); - - describe('session', () => { - describe('.inspect()', () => { - it('should return session content', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = ctx.session[inspect](); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect({ foo: 'bar' }) - .expect(200, done); - }); - }); - - describe('.length', () => { - it('should return session length', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = String(ctx.session.length); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('1') - .expect(200, done); - }); - }); - - describe('.populated', () => { - it('should return session populated', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.body = String(ctx.session.populated); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('true') - .expect(200, done); - }); - }); - - describe('.save()', () => { - it('should save session', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.save(); - ctx.body = 'hello'; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess=.+;/) - .expect('hello') - .expect(200, done); - }); - }); - }); - - describe('when an error is thrown downstream and caught upstream', () => { - it('should still save the session', done => { - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - - app.use(async function(ctx, next) { - try { - await next(); - } catch (err) { - ctx.status = err.status; - ctx.body = err.message; - } - }); - - app.use(session({ store }, app)); - - app.use(async function(ctx, next) { - ctx.session.name = 'funny'; - await next(); - }); - - app.use(async function(ctx) { - ctx.throw(401); - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /koa\.sess/) - .expect(401, done); - }); - }); - - describe('when maxAge present', () => { - describe('and set to be a session cookie', () => { - it('should not expire the session', done => { - const app = App({ maxAge: 'session' }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - cookie.should.not.containEql('expires='); - request(server) - .get('/') - .set('cookie', cookie) - .expect('hi', done); - }); - }); - it('should use the default maxAge when improper string given', done => { - const app = App({ maxAge: 'not the right string' }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - cookie.should.containEql('expires='); - request(server) - .get('/') - .set('cookie', cookie) - .expect('hi', done); - }); - }); - }); - describe('and not expire', () => { - it('should not expire the session', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.body = 200; - return; - } - ctx.body = ctx.session.message; - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - - request(server) - .get('/') - .set('cookie', cookie) - .expect('hi', done); - }); - }); - }); - - describe('and expired', () => { - it('should expire the sess', done => { - done = pedding(done, 2); - const app = App({ maxAge: 100 }); - app.on('session:expired', args => { - assert(args.key.match(/^\w+-/)); - assert(args.value); - assert(args.ctx); - done(); - }); - app.use(async function(ctx) { - if (ctx.method === 'POST') { - ctx.session.message = 'hi'; - ctx.status = 200; - return; - } - - ctx.body = ctx.session.message || ''; - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect('Set-Cookie', /koa\.sess/) - .end((err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie'].join(';'); - - setTimeout(() => { - request(server) - .get('/') - .set('cookie', cookie) - .expect('', done); - }, 200); - }); - }); - }); - }); - - describe('ctx.session.maxAge', () => { - it('should return opt.maxAge', done => { - const app = App({ maxAge: 100 }); - - app.use(async function(ctx) { - ctx.body = ctx.session.maxAge; - }); - - request(app.listen()) - .get('/') - .expect('100', done); - }); - }); - - describe('ctx.session.maxAge=', () => { - it('should set sessionOptions.maxAge', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.foo = 'bar'; - ctx.session.maxAge = 100; - ctx.body = ctx.session.foo; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - - it('should save even session not change', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session.maxAge = 100; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - - it('should save when create session only with maxAge', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.session = { maxAge: 100 }; - ctx.body = ctx.session; - }); - - request(app.listen()) - .get('/') - .expect('Set-Cookie', /expires=/) - .expect(200, done); - }); - }); - - describe('ctx.session.regenerate', () => { - it('should change the session key, but not content', done => { - const app = new App(); - const message = 'hi'; - app.use(async function(ctx, next) { - ctx.session = { message: 'hi' }; - await next(); - }); - - app.use(async function(ctx, next) { - const sessionKey = ctx.cookies.get('koa.sess'); - if (sessionKey) { - await ctx.session.regenerate(); - } - await next(); - }); - - app.use(async function(ctx) { - ctx.session.message.should.equal(message); - ctx.body = ''; - }); - let koaSession = null; - request(app.callback()) - .get('/') - .expect(200, (err, res) => { - should.not.exist(err); - koaSession = res.headers['set-cookie'][0]; - koaSession.should.containEql('koa.sess='); - request(app.callback()) - .get('/') - .set('Cookie', koaSession) - .expect(200, (err, res) => { - should.not.exist(err); - const cookies = res.headers['set-cookie'][0]; - cookies.should.containEql('koa.sess='); - cookies.should.not.equal(koaSession); - done(); - }); - }); - }); - }); - - describe('when store return empty', () => { - it('should create new Session', done => { - done = pedding(done, 2); - const app = App({ signed: false }); - - app.use(async function(ctx) { - ctx.body = String(ctx.session.isNew); - }); - - app.on('session:missed', args => { - assert(args.key === 'invalid-key'); - assert(args.ctx); - done(); - }); - - request(app.listen()) - .get('/') - .set('cookie', 'koa.sess=invalid-key') - .expect('true') - .expect(200, done); - }); - }); - - describe('when valid and beforeSave set', () => { - it('should ignore session when uid changed', done => { - done = pedding(done, 2); - const app = new Koa(); - - app.keys = [ 'a', 'b' ]; - app.use(session({ - valid(ctx, sess) { - return ctx.cookies.get('uid') === sess.uid; - }, - beforeSave(ctx, sess) { - sess.uid = ctx.cookies.get('uid'); - }, - store, - }, app)); - app.use(async function(ctx) { - if (!ctx.session.foo) { - ctx.session.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); - } - - ctx.body = { - foo: ctx.session.foo, - uid: ctx.cookies.get('uid'), - }; - }); - app.on('session:invalid', args => { - assert(args.key); - assert(args.value); - assert(args.ctx); - done(); - }); - - request(app.callback()) - .get('/') - .set('Cookie', 'uid=123') - .expect(200, (err, res) => { - should.not.exist(err); - const data = res.body; - const cookies = res.headers['set-cookie'].join(';'); - cookies.should.containEql('koa.sess='); - - request(app.callback()) - .get('/') - .set('Cookie', cookies + ';uid=123') - .expect(200) - .expect(data, err => { - should.not.exist(err); - - // should ignore uid:123 session and create a new session for uid:456 - request(app.callback()) - .get('/') - .set('Cookie', cookies + ';uid=456') - .expect(200, (err, res) => { - should.not.exist(err); - res.body.uid.should.equal('456'); - res.body.foo.should.not.equal(data.foo); - done(); - }); - }); - }); - }); - }); - - describe('ctx.session', () => { - after(mm.restore); - - it('can be mocked', done => { - const app = App(); - - app.use(async function(ctx) { - ctx.body = ctx.session; - }); - - mm(app.context, 'session', { - foo: 'bar', - }); - - request(app.listen()) - .get('/') - .expect({ - foo: 'bar', - }) - .expect(200, done); - }); - }); - - describe('when rolling set to true', () => { - let app; - before(() => { - app = App({ rolling: true }); - - app.use(async ctx => { - if (ctx.path === '/set') ctx.session = { foo: 'bar' }; - ctx.body = ctx.session; - }); - }); - - it('should not send set-cookie when session not exists', () => { - return request(app.callback()) - .get('/') - .expect({}) - .expect(res => { - should.not.exist(res.headers['set-cookie']); - }); - }); - - it('should send set-cookie when session exists and not change', done => { - request(app.callback()) - .get('/set') - .expect({ foo: 'bar' }) - .end((err, res) => { - should.not.exist(err); - res.headers['set-cookie'].should.have.length(2); - const cookie = res.headers['set-cookie'].join(';'); - request(app.callback()) - .get('/') - .set('cookie', cookie) - .expect(res => { - res.headers['set-cookie'].should.have.length(2); - }) - .expect({ foo: 'bar' }, done); - }); - }); - }); - - describe('when prefix present', () => { - it('should still work', done => { - const app = App({ prefix: 'sess:' }); - - app.use(async ctx => { - if (ctx.method === 'POST') { - ctx.session.string = ';'; - ctx.status = 204; - } else { - ctx.body = ctx.session.string; - } - }); - - const server = app.listen(); - - request(server) - .post('/') - .expect(204, (err, res) => { - if (err) return done(err); - const cookie = res.headers['set-cookie']; - cookie.join().should.match(/koa\.sess=sess:/); - request(server) - .get('/') - .set('Cookie', cookie.join(';')) - .expect(';', done); - }); - }); - }); - - describe('when renew set to true', () => { - let app; - before(() => { - app = App({ renew: true, maxAge: 2000 }); - - app.use(async ctx => { - if (ctx.path === '/set') ctx.session = { foo: 'bar' }; - ctx.body = ctx.session; - }); - }); - - it('should not send set-cookie when session not exists', () => { - return request(app.callback()) - .get('/') - .expect({}) - .expect(res => { - should.not.exist(res.headers['set-cookie']); - }); - }); - - it('should send set-cookie when session near expire and not change', async () => { - let res = await request(app.callback()) - .get('/set') - .expect({ foo: 'bar' }); - - res.headers['set-cookie'].should.have.length(2); - const cookie = res.headers['set-cookie'].join(';'); - await sleep(1200); - res = await request(app.callback()) - .get('/') - .set('cookie', cookie) - .expect({ foo: 'bar' }); - res.headers['set-cookie'].should.have.length(2); - }); - - it('should not send set-cookie when session not near expire and not change', async () => { - let res = await request(app.callback()) - .get('/set') - .expect({ foo: 'bar' }); - - res.headers['set-cookie'].should.have.length(2); - const cookie = res.headers['set-cookie'].join(';'); - await sleep(500); - res = await request(app.callback()) - .get('/') - .set('cookie', cookie) - .expect({ foo: 'bar' }); - should.not.exist(res.headers['set-cookie']); - }); - }); - - describe('when get session before middleware', () => { - it('should return empty session', async () => { - const app = new Koa(); - app.keys = [ 'a', 'b' ]; - const options = {}; - options.store = store; - app.use(async (ctx, next) => { - // will not take effect - ctx.session.should.be.ok(); - ctx.session.foo = '123'; - await next(); - }); - app.use(session(options, app)); - app.use(async ctx => { - if (ctx.path === '/set') ctx.session = { foo: 'bar' }; - ctx.body = ctx.session; - }); - - let res = await request(app.callback()) - .get('/') - .expect({}); - - res = await request(app.callback()) - .get('/set') - .expect({ foo: 'bar' }); - - res.headers['set-cookie'].should.have.length(2); - const cookie = res.headers['set-cookie'].join(';'); - await sleep(1200); - res = await request(app.callback()) - .get('/') - .set('cookie', cookie) - .expect({ foo: 'bar' }); - }); - }); -}); - -function App(options) { - const app = new Koa(); - app.keys = [ 'a', 'b' ]; - options = options || {}; - options.store = store; - app.use(session(options, app)); - return app; -} diff --git a/test/store.test.ts b/test/store.test.ts new file mode 100644 index 0000000..39ba1b8 --- /dev/null +++ b/test/store.test.ts @@ -0,0 +1,800 @@ +import { strict as assert } from 'node:assert'; +import { scheduler } from 'node:timers/promises'; +import Koa from 'koa'; +import { request } from '@eggjs/supertest'; +import { mm } from 'mm'; +import session, { type CreateSessionOptions } from '../src/index.js'; +import store from './store.js'; + +const inspect = Symbol.for('nodejs.util.inspect.custom'); + +function App(options: CreateSessionOptions = {}) { + const app = new Koa(); + app.keys = [ 'a', 'b' ]; + options.store = store; + app.use(session(options, app)); + return app; +} + +describe('Koa Session External Store', () => { + let cookie: string; + + describe('when the session contains a ;', () => { + it('should still work', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.string = ';'; + ctx.status = 204; + } else { + ctx.body = ctx.session!.string; + } + }); + + const server = app.callback(); + const res = await request(server) + .post('/') + .expect(204); + + const cookie = res.get('Set-Cookie')!; + await request(server) + .get('/') + .set('Cookie', cookie.join(';')) + .expect(';'); + }); + }); + + describe('new session', () => { + describe('when not accessed', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.body = 'greetings'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + + assert(!res.headers['set-cookie']); + }); + }); + + describe('when accessed and not populated', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session; + ctx.body = 'greetings'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + + assert(!res.headers['set-cookie']); + }); + }); + + describe('when populated', () => { + it('should Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.message = 'hello'; + ctx.body = ''; + }); + + const res = await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + + cookie = res.get('Set-Cookie')!.join(';'); + }); + + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.body = ctx.session; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + + assert(!res.headers['set-cookie']); + }); + }); + }); + + describe('saved session', () => { + describe('when not accessed', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.body = 'aklsdjflasdjf'; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + + assert(!res.headers['set-cookie']); + }); + }); + + describe('when accessed but not changed', () => { + it('should be the same session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + assert.equal(ctx.session!.message, 'hello'); + ctx.body = 'aklsdjflasdjf'; + }); + + await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + }); + + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + assert.equal(ctx.session!.message, 'hello'); + ctx.body = 'aklsdjflasdjf'; + }); + + const res = await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect(200); + + assert(!res.headers['set-cookie']); + }); + }); + + describe('when accessed and changed', () => { + it('should Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.money = '$$$'; + ctx.body = 'aklsdjflasdjf'; + }); + + await request(app.callback()) + .get('/') + .set('Cookie', cookie) + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + }); + + describe('when session is', () => { + describe('null', () => { + it('should expire the session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = null; + ctx.body = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + + describe('an empty object', () => { + it('should not Set-Cookie', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = {}; + ctx.body = 'asdf'; + }); + + const res = await request(app.callback()) + .get('/') + .expect(200); + + assert(!res.headers['set-cookie']); + }); + }); + + describe('an object', () => { + it('should create a session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = { message: 'hello' }; + ctx.body = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(200); + }); + }); + + describe('anything else', () => { + it('should throw', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = 'asdf'; + }); + + await request(app.callback()) + .get('/') + .expect(500); + }); + }); + }); + + describe('session', () => { + describe('.inspect()', () => { + it('should return session content', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.body = ctx.session![inspect](); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect({ foo: 'bar' }) + .expect(200); + }); + }); + + describe('.length', () => { + it('should return session length', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.body = String(ctx.session!.length); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('1') + .expect(200); + }); + }); + + describe('.populated', () => { + it('should return session populated', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.body = String(ctx.session!.populated); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('true') + .expect(200); + }); + }); + + describe('.save()', () => { + it('should save session', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.save(); + ctx.body = 'hello'; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess=.+;/) + .expect('hello') + .expect(200); + }); + }); + }); + + describe('when an error is thrown downstream and caught upstream', () => { + it('should still save the session', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + try { + await next(); + } catch (err: any) { + ctx.status = err.status; + ctx.body = err.message; + } + }); + + app.use(session({ store }, app)); + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + ctx.session!.name = 'funny'; + await next(); + }); + + app.use(async (ctx: Koa.Context) => { + ctx.throw(401); + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /koa\.sess/) + .expect(401); + }); + }); + + describe('when maxAge present', () => { + describe('and set to be a session cookie', () => { + it('should not expire the session', async () => { + const app = App({ maxAge: 'session' }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.message = 'hi'; + ctx.body = 200; + return; + } + ctx.body = ctx.session!.message; + }); + + const server = app.callback(); + const res = await request(server) + .post('/') + .expect('Set-Cookie', /koa\.sess/); + + const cookie = res.get('Set-Cookie')!.join(';'); + assert(!cookie.includes('expires=')); + + await request(server) + .get('/') + .set('cookie', cookie) + .expect('hi'); + }); + }); + + describe('and not expire', () => { + it('should not expire the session', async () => { + const app = App({ maxAge: 100 }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.message = 'hi'; + ctx.body = 200; + return; + } + ctx.body = ctx.session!.message; + }); + + const server = app.callback(); + const res = await request(server) + .post('/') + .expect('Set-Cookie', /koa\.sess/); + + const cookie = res.get('Set-Cookie')!.join(';'); + + await request(server) + .get('/') + .set('cookie', cookie) + .expect('hi'); + }); + }); + + describe('and expired', () => { + it('should expire the sess', async () => { + const app = App({ maxAge: 100 }); + app.on('session:expired', args => { + assert(args.key.match(/^\w+-/)); + assert(args.value); + assert(args.ctx); + }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.message = 'hi'; + ctx.status = 200; + return; + } + + ctx.body = ctx.session!.message || ''; + }); + + const server = app.callback(); + const res = await request(server) + .post('/') + .expect('Set-Cookie', /koa\.sess/); + + const cookie = res.get('Set-Cookie')!.join(';'); + + await new Promise(resolve => setTimeout(resolve, 200)); + + await request(server) + .get('/') + .set('cookie', cookie) + .expect(''); + }); + }); + }); + + describe('ctx.session.maxAge', () => { + it('should return opt.maxAge', async () => { + const app = App({ maxAge: 100 }); + + app.use(async (ctx: Koa.Context) => { + ctx.body = ctx.session!.maxAge; + }); + + await request(app.callback()) + .get('/') + .expect('100'); + }); + }); + + describe('ctx.session.maxAge=', () => { + it('should set sessionOptions.maxAge', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.foo = 'bar'; + ctx.session!.maxAge = 100; + ctx.body = ctx.session!.foo; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + + it('should save even session not change', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session!.maxAge = 100; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + + it('should save when create session only with maxAge', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.session = { maxAge: 100 }; + ctx.body = ctx.session; + }); + + await request(app.callback()) + .get('/') + .expect('Set-Cookie', /expires=/) + .expect(200); + }); + }); + + describe('ctx.session.regenerate', () => { + it('should change the session key, but not content', async () => { + const app = App(); + const message = 'hi'; + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + ctx.session = { message: 'hi' }; + await next(); + }); + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + const sessionKey = ctx.cookies.get('koa.sess'); + if (sessionKey) { + await ctx.session!.regenerate(); + } + await next(); + }); + + app.use(async (ctx: Koa.Context) => { + assert.equal(ctx.session!.message, message); + ctx.body = ''; + }); + + let res = await request(app.callback()) + .get('/') + .expect(200); + + const koaSession = res.get('Set-Cookie')!.join(';'); + assert.match(koaSession, /koa\.sess=/); + res = await request(app.callback()) + .get('/') + .set('Cookie', koaSession) + .expect(200); + + const cookies = res.get('Set-Cookie')!.join(';'); + assert.match(cookies, /koa\.sess=/); + assert.notEqual(cookies, koaSession); + }); + }); + + describe('when store return empty', () => { + it('should create new Session', async () => { + const app = App({ signed: false }); + + app.use(async (ctx: Koa.Context) => { + ctx.body = String(ctx.session!.isNew); + }); + + app.on('session:missed', args => { + assert.equal(args.key, 'invalid-key'); + assert(args.ctx); + }); + + await request(app.callback()) + .get('/') + .set('cookie', 'koa.sess=invalid-key') + .expect('true') + .expect(200); + }); + }); + + describe('when valid and beforeSave set', () => { + it('should ignore session when uid changed', async () => { + const app = new Koa(); + + app.keys = [ 'a', 'b' ]; + app.use(session({ + valid(ctx, sess) { + return ctx.cookies.get('uid') === sess.uid; + }, + beforeSave(ctx, sess) { + sess.uid = ctx.cookies.get('uid'); + }, + store, + }, app)); + + app.use(async (ctx: Koa.Context) => { + if (!ctx.session!.foo) { + ctx.session!.foo = Date.now() + '|uid:' + ctx.cookies.get('uid'); + } + + ctx.body = { + foo: ctx.session!.foo, + uid: ctx.cookies.get('uid'), + }; + }); + + app.on('session:invalid', args => { + assert(args.key); + assert(args.value); + assert(args.ctx); + }); + + let res = await request(app.callback()) + .get('/') + .set('Cookie', 'uid=123') + .expect(200); + + const data = res.body; + const cookies = res.get('Set-Cookie')!.join(';'); + assert(cookies.includes('koa.sess=')); + + res = await request(app.callback()) + .get('/') + .set('Cookie', cookies + ';uid=123') + .expect(200); + + assert.deepEqual(res.body, data); + + res = await request(app.callback()) + .get('/') + .set('Cookie', cookies + ';uid=456') + .expect(200); + + assert.equal(res.body.uid, '456'); + assert.notEqual(res.body.foo, data.foo); + }); + }); + + describe('ctx.session', () => { + after(mm.restore); + + it('can be mocked', async () => { + const app = App(); + + app.use(async (ctx: Koa.Context) => { + ctx.body = ctx.session; + }); + + mm(app.context, 'session', { + foo: 'bar', + }); + + await request(app.callback()) + .get('/') + .expect({ + foo: 'bar', + }) + .expect(200); + }); + }); + + describe('when rolling set to true', () => { + let app: Koa; + before(() => { + app = App({ rolling: true }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.path === '/set') ctx.session = { foo: 'bar' }; + ctx.body = ctx.session; + }); + }); + + it('should not send set-cookie when session not exists', async () => { + const res = await request(app.callback()) + .get('/') + .expect({}); + + assert(!res.headers['set-cookie']); + }); + + it('should send set-cookie when session exists and not change', async () => { + let res = await request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + assert.equal(res.headers['set-cookie'].length, 2); + const cookie = res.get('Set-Cookie')!.join(';'); + + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + + assert.equal(res.headers['set-cookie'].length, 2); + }); + }); + + describe('when prefix present', () => { + it('should still work', async () => { + const app = App({ prefix: 'sess:' }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.method === 'POST') { + ctx.session!.string = ';'; + ctx.status = 204; + } else { + ctx.body = ctx.session!.string; + } + }); + + const server = app.callback(); + const res = await request(server) + .post('/') + .expect(204); + + const cookie = res.get('Set-Cookie')!; + assert(cookie.join().includes('koa.sess=sess:')); + + await request(server) + .get('/') + .set('Cookie', cookie.join(';')) + .expect(';'); + }); + }); + + describe('when renew set to true', () => { + let app: Koa; + before(() => { + app = App({ renew: true, maxAge: 2000 }); + + app.use(async (ctx: Koa.Context) => { + if (ctx.path === '/set') ctx.session = { foo: 'bar' }; + ctx.body = ctx.session; + }); + }); + + it('should not send set-cookie when session not exists', async () => { + const res = await request(app.callback()) + .get('/') + .expect({}); + + assert(!res.headers['set-cookie']); + }); + + it('should send set-cookie when session near expire and not change', async () => { + let res = await request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + assert.equal(res.get('Set-Cookie')!.length, 2); + const cookie = res.get('Set-Cookie')!.join(';'); + + await scheduler.wait(1200); + + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + + assert.equal(res.headers['set-cookie'].length, 2); + }); + + it('should not send set-cookie when session not near expire and not change', async () => { + let res = await request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + assert.equal(res.headers['set-cookie'].length, 2); + const cookie = res.get('Set-Cookie')!.join(';'); + + await scheduler.wait(500); + + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + + assert(!res.headers['set-cookie']); + }); + }); + + describe('when get session before middleware', () => { + it('should return empty session', async () => { + const app = new Koa(); + app.keys = [ 'a', 'b' ]; + const options = { store }; + + app.use(async (ctx: Koa.Context, next: Koa.Next) => { + assert(ctx.session); + ctx.session.foo = '123'; + await next(); + }); + + app.use(session(options, app)); + + app.use(async (ctx: Koa.Context) => { + if (ctx.path === '/set') ctx.session = { foo: 'bar' }; + ctx.body = ctx.session; + }); + + let res = await request(app.callback()) + .get('/') + .expect({}); + + res = await request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + const cookie = res.get('Set-Cookie')!.join(';'); + await scheduler.wait(1200); + + res = await request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + }); + }); +}); diff --git a/test/store.ts b/test/store.ts new file mode 100644 index 0000000..43ccf80 --- /dev/null +++ b/test/store.ts @@ -0,0 +1,15 @@ +const sessions: Record = {}; + +export default { + async get(key: string) { + return sessions[key]; + }, + + async set(key: string, value: unknown) { + sessions[key] = value; + }, + + async destroy(key: string) { + sessions[key] = undefined; + }, +}; diff --git a/test/store_with_ctx.test.js b/test/store_with_ctx.test.ts similarity index 59% rename from test/store_with_ctx.test.js rename to test/store_with_ctx.test.ts index 21fe43a..e7331a5 100644 --- a/test/store_with_ctx.test.js +++ b/test/store_with_ctx.test.ts @@ -1,40 +1,49 @@ -'use strict'; +import { strict as assert } from 'node:assert'; +import Koa from 'koa'; +import { request } from '@eggjs/supertest'; +import session, { type CreateSessionOptions } from '../src/index.js'; +import store from './store_with_ctx.js'; -const Koa = require('koa'); -const request = require('supertest'); -const session = require('..'); -const store = require('./store_with_ctx'); +function App(options: CreateSessionOptions = {}) { + const app = new Koa(); + app.keys = [ 'a', 'b' ]; + options.store = store; + app.use(async (ctx, next) => { + await next(); + ctx.body = ctx.state.test === undefined ? 'undefined' : ctx.state.test; + }); + + app.use(session(options, app)); + return app; +} -describe('Koa Session External Store methods can acceess Koa context', () => { - let cookie; +describe('Koa Session External Store methods can access Koa context', () => { + let cookie: string; describe('new session', () => { describe('when not accessed', () => { it('should not set ctx.state.test variable', async () => { const app = App(); - request(app.callback()) + await request(app.callback()) .get('/') .expect('undefined'); }); }); describe('when populated', () => { - it('should set ctx.state.test variable', done => { + it('should set ctx.state.test variable', async () => { const app = App(); app.use(async ctx => { if (ctx.path === '/set') ctx.session = { foo: 'bar' }; }); - request(app.listen()) - .get('/set') - .expect(200, (err, res) => { - if (err) return done(err); - cookie = res.header['set-cookie'].join(';'); - res.text.should.equal('set'); - done(); - }); + const res = await request(app.callback()) + .get('/set') + .expect(200); + cookie = res.get('Set-Cookie')!.join(';'); + assert.equal(res.text, 'set'); }); }); @@ -42,7 +51,7 @@ describe('Koa Session External Store methods can acceess Koa context', () => { it('should access ctx.state.test variable', async () => { const app = App(); - request(app.callback()) + await request(app.callback()) .get('/') .set('Cookie', cookie) .expect('get'); @@ -54,10 +63,12 @@ describe('Koa Session External Store methods can acceess Koa context', () => { const app = App(); app.use(async ctx => { - if (ctx.path === '/destroy') ctx.session = null; + if (ctx.path === '/destroy') { + ctx.session = null; + } }); - request(app.callback()) + await request(app.callback()) .get('/destroy') .set('Cookie', cookie) .expect('destroyed'); @@ -65,17 +76,3 @@ describe('Koa Session External Store methods can acceess Koa context', () => { }); }); }); - -function App(options) { - const app = new Koa(); - app.keys = [ 'a', 'b' ]; - options = options || {}; - options.store = store; - app.use(async (ctx, next) => { - await next(); - ctx.body = ctx.state.test === undefined ? 'undefined' : ctx.state.test; - }); - - app.use(session(options, app)); - return app; -} diff --git a/test/store_with_ctx.js b/test/store_with_ctx.ts similarity index 55% rename from test/store_with_ctx.js rename to test/store_with_ctx.ts index 2ebd234..7e50c7b 100644 --- a/test/store_with_ctx.js +++ b/test/store_with_ctx.ts @@ -1,21 +1,19 @@ -'use strict'; +const sessions: Record = {}; -const sessions = {}; - -module.exports = { - async get(key, maxAge, options) { +export default { + async get(key: string, _maxAge: number, options: any) { // check access to options.ctx options.ctx.state.test = 'get'; return sessions[key]; }, - async set(key, sess, maxAge, options) { + async set(key: string, sess: Record, _maxAge: number, options: any) { // check access to options.ctx options.ctx.state.test = 'set'; sessions[key] = sess; }, - async destroy(key, options) { + async destroy(key: string, options: any) { // check access to options.ctx options.ctx.state.test = 'destroyed'; sessions[key] = undefined; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ff41b73 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@eggjs/tsconfig", + "compilerOptions": { + "strict": true, + "noImplicitAny": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext" + } +} From 6d068ffcb7a0976a8da6d70f32624575f9061393 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 19 Jan 2025 07:32:03 +0000 Subject: [PATCH 2/2] Release 7.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] ## [7.0.0](https://github.com/koajs/session/compare/v6.4.0...v7.0.0) (2025-01-19) ### ⚠ BREAKING CHANGES * drop Node.js < 18.19.0 support ### Features * support cjs and esm both by tshy ([#228](https://github.com/koajs/session/issues/228)) ([575864c](https://github.com/koajs/session/commit/575864c6ae7504da15e72e8112a99757f3eee188)) --- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e86323..f2a9a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# Changelog + +## [7.0.0](https://github.com/koajs/session/compare/v6.4.0...v7.0.0) (2025-01-19) + + +### ⚠ BREAKING CHANGES + +* drop Node.js < 18.19.0 support + +### Features + +* support cjs and esm both by tshy ([#228](https://github.com/koajs/session/issues/228)) ([575864c](https://github.com/koajs/session/commit/575864c6ae7504da15e72e8112a99757f3eee188)) 6.4.0 / 2023-02-04 ================== diff --git a/package.json b/package.json index 725d6c3..eea068b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "git", "url": "git@github.com:koajs/session.git" }, - "version": "6.4.0", + "version": "7.0.0", "keywords": [ "koa", "middleware",