diff --git a/.travis.yml b/.travis.yml index 0c63626..d8cc0f4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,5 +4,6 @@ node_js: - '4' - '6' - '7' + - '8' script: 'npm run test-travis' after_script: 'npm install coveralls@2 && cat ./coverage/lcov.info | coveralls' diff --git a/History.md b/History.md index b33ced1..0368766 100644 --- a/History.md +++ b/History.md @@ -1,4 +1,63 @@ +4.8.1 / 2018-12-18 +================== + +**features** + * [[`d792770`](http://github.com/koajs/session/commit/d7927708848a0e39a0652643ed3b6426ceb4952c)] - feat: allow init multi session middleware (#160) (killa <>) + +4.8.0 / 2018-01-17 +================== + +**features** + * [[`ca6b329`](http://github.com/koajs/session/commit/ca6b32906678b3cf6168c4afac250b2e68fd17c8)] - feat: support opts.renew (#111) (Yiyu He <>) + +**fixes** + * [[`5c2fc72`](http://github.com/koajs/session/commit/5c2fc72cab47450da2c9ae8fedba8657d0c264ee)] - fix: ensure store expired after cookie (dead-horse <>) + +4.7.1 / 2018-01-11 +================== + +**fixes** + * [[`5683102`](http://github.com/koajs/session/commit/5683102687c48ba11521d8b249126de13e7c0d8b)] - fix: emit event in next tick (dead-horse <>) + +4.7.0 / 2018-01-09 +================== + +**features** + * [[`f8b1822`](http://github.com/koajs/session/commit/f8b18228c90a6a0d7b8d91c560b9675bafd5a552)] - feat: emit event expose ctx (dead-horse <>) + +4.6.0 / 2018-01-09 +================== + +**features** + * [[`c8298a3`](http://github.com/koajs/session/commit/c8298a3f60aabc3067f0a2db0409df05f1ed71a1)] - feat: emit events when session invalid (#109) (Yiyu He <>) + +4.5.0 / 2017-08-04 +================== + +**features** + * [[`b537dea`](http://github.com/koajs/session/commit/b537deaeb2db352bdcab3be2c8b7578760fc69da)] - feat: support options.prefix for external store (#92) (Yiyu He <>) + +4.4.0 / 2017-07-03 +================== + + * feat: opts.genid (#86) + +4.3.0 / 2017-06-17 +================== + + * feat: support rolling (#82) + +4.2.0 / 2017-06-15 +================== + + * feat: support options.ContextStore (#80) + +4.1.0 / 2017-06-01 +================== + + * Create capability to create cookies that expire when browser is close… (#77) + 4.0.1 / 2017-03-01 ================== diff --git a/Readme.md b/Readme.md index da7e8ae..aed40f2 100644 --- a/Readme.md +++ b/Readme.md @@ -26,7 +26,9 @@ [download-image]: https://img.shields.io/npm/dm/koa-session.svg?style=flat-square [download-url]: https://npmjs.org/package/koa-session - Simple cookie-based session middleware for Koa. + Simple session middleware for Koa. Defaults to cookie-based sessions and supports external stores. + + *Requires Node 7.6 or greater for async/await support* ## Installation @@ -47,11 +49,17 @@ app.keys = ['some secret hurr']; var CONFIG = { key: 'koa:sess', /** (string) cookie key (default is koa:sess) */ - maxAge: 86400000, /** (number) maxAge in ms (default is 1 days) */ + /** (number || 'session') maxAge in ms (default is 1 days) */ + /** '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: 86400000, overwrite: true, /** (boolean) can overwrite or not (default true) */ httpOnly: true, /** (boolean) httpOnly or not (default true) */ signed: true, /** (boolean) signed or not (default true) */ + rolling: false, /** (boolean) 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) */ + renew: false, /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/ }; + app.use(session(CONFIG, app)); // or if you prefer all default config, just use => app.use(session(app)); @@ -108,13 +116,27 @@ app.use(convert(session(app))); You can store the session content in external stores(redis, mongodb or other DBs) by pass `options.store` with three methods(need to be generator function or async function): - - `get(key)`: get session object by key - - `set(key, sess, maxAge)`: set session object for key, with a `maxAge` (in ms) + - `get(key, maxAge, { rolling })`: get session object by key + - `set(key, sess, maxAge, { rolling, changed })`: set session object for key, with a `maxAge` (in ms) - `destroy(key)`: destroy session for key Once you passed `options.store`, session is strong dependent on your external store, you can't access session if your external store is down. **Use external session stores only if necessary, avoid use session as a cache, keep session lean and stored by cookie!** + The way of generating external session id is controlled by the `options.genid`, which defaults to `Date.now() + '-' + uid.sync(24)`. + + If you want to add prefix for all external session id, you can use `options.prefix`, it will not work if `options.genid` present. + + If your session store requires data or utilities from context, `opts.ContextStore` is alse supported. `ContextStore` must be a class which claims three instance methods demonstrated above. `new ContextStore(ctx)` will be executed on every request. + +### Events + +`koa-session` will emit event on `app` when session expired or invalid: + +- `session:missed`: can't get session value from external store. +- `session:invalid`: session value is invalid. +- `session:expired`: session value is expired. + ### Session#isNew Returns __true__ if the session is new. diff --git a/index.js b/index.js index 53ca820..4ba0783 100644 --- a/index.js +++ b/index.js @@ -4,6 +4,8 @@ const debug = require('debug')('koa-session'); const ContextSession = require('./lib/context'); const util = require('./lib/util'); const assert = require('assert'); +const uid = require('uid-safe'); +const is = require('is-type-of'); const CONTEXT_SESSION = Symbol('context#contextSession'); const _CONTEXT_SESSION = Symbol('context#_contextSession'); @@ -78,10 +80,24 @@ function formatOpts(opts) { opts.decode = util.decode; } - if (opts.store) { - assert(typeof opts.store.get === 'function', 'store.get must be function'); - assert(typeof opts.store.set === 'function', 'store.set must be function'); - assert(typeof opts.store.destroy === 'function', 'store.destroy must be function'); + 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 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}${Date.now()}-${uid.sync(24)}`; + else opts.genid = () => `${Date.now()}-${uid.sync(24)}`; } return opts; @@ -97,6 +113,9 @@ function formatOpts(opts) { */ function extendContext(context, opts) { + if (context.hasOwnProperty(CONTEXT_SESSION)) { + return; + } Object.defineProperties(context, { [CONTEXT_SESSION]: { get() { diff --git a/lib/context.js b/lib/context.js index 61650b0..a6a2790 100644 --- a/lib/context.js +++ b/lib/context.js @@ -2,7 +2,6 @@ const debug = require('debug')('koa-session:context'); const Session = require('./session'); -const uid = require('uid-safe'); const util = require('./util'); const ONE_DAY = 24 * 60 * 60 * 1000; @@ -15,8 +14,9 @@ class ContextSession { constructor(ctx, opts) { this.ctx = ctx; + this.app = ctx.app; this.opts = Object.assign({}, opts); - this.store = this.opts.store; + this.store = this.opts.ContextStore ? new this.opts.ContextStore(ctx) : this.opts.store; } /** @@ -79,8 +79,8 @@ class ContextSession { return; } - const json = yield this.store.get(externalKey); - if (!this.valid(json)) { + const json = yield this.store.get(externalKey, opts.maxAge, { rolling: opts.rolling }); + if (!this.valid(json, externalKey)) { // create a new `externalKey` this.create(); return; @@ -145,28 +145,46 @@ class ContextSession { /** * verify session(expired or ) - * @param {Object} json session object + * @param {Object} value session object + * @param {Object} key session externalKey(optional) * @return {Boolean} valid * @api private */ - valid(json) { - if (!json) return false; + valid(value, key) { + const ctx = this.ctx; + if (!value) { + this.emit('missed', { key, value, ctx }); + return false; + } - if (!json._expire || json._expire < Date.now()) { + if (value._expire && value._expire < Date.now()) { debug('expired session'); + this.emit('expired', { key, value, ctx }); return false; } const valid = this.opts.valid; - if (typeof valid === 'function' && !valid(this.ctx, json)) { + if (typeof valid === 'function' && !valid(ctx, value)) { // valid session value fail, ignore this session debug('invalid session'); + this.emit('invalid', { key, value, ctx }); return false; } return true; } + /** + * @param {String} event event name + * @param {Object} data event data + * @api private + */ + emit(event, data) { + setImmediate(() => { + this.app.emit(`session:${event}`, data); + }); + } + /** * create a new session and attach to ctx.sess * @@ -177,7 +195,7 @@ class ContextSession { create(val, externalKey) { debug('create session with val: %j externalKey: %s', val, externalKey); - if (this.opts.store) this.externalKey = externalKey || uid.sync(24); + if (this.store) this.externalKey = externalKey || this.opts.genid(); this.session = new Session(this.ctx, val); } @@ -189,7 +207,6 @@ class ContextSession { * commit() { const session = this.session; - const prevHash = this.prevHash; const opts = this.opts; const ctx = this.ctx; @@ -202,20 +219,45 @@ class ContextSession { return; } - // force save session when `session._requireSave` set - if (!session._requireSave) { - const json = session.toJSON(); - // do nothing if new and not populated - if (!prevHash && !Object.keys(json).length) return; - // do nothing if not changed - if (prevHash === util.hash(json)) return; - } + const reason = this._shouldSaveSession(); + debug('should save session: %s', reason); + if (!reason) return; if (typeof opts.beforeSave === 'function') { debug('before save'); opts.beforeSave(ctx, session); } - yield this.save(); + const changed = reason === 'changed'; + yield this.save(changed); + } + + _shouldSaveSession() { + const prevHash = this.prevHash; + const session = this.session; + + // force save session when `session._requireSave` set + if (session._requireSave) return 'force'; + + // do nothing if new and not populated + const json = session.toJSON(); + if (!prevHash && !Object.keys(json).length) return ''; + + // save if session changed + const changed = prevHash !== util.hash(json); + if (changed) return 'changed'; + + // save if opts.rolling set + 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'; + } + + return ''; } /** @@ -238,22 +280,34 @@ class ContextSession { * @api private */ - * save() { + * save(changed) { const opts = this.opts; const key = opts.key; const externalKey = this.externalKey; - - const maxAge = opts.maxAge || ONE_DAY; - let json = this.session.toJSON(); // set expire for check - json._expire = maxAge + Date.now(); - json._maxAge = maxAge; + 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; + } else { + // set expire for check + json._expire = maxAge + Date.now(); + json._maxAge = maxAge; + } // save to external store if (externalKey) { debug('save %j to external key %s', json, externalKey); - yield this.store.set(externalKey, json, maxAge); + if (typeof maxAge === 'number') { + // ensure store expired after cookie + maxAge += 10000; + } + yield this.store.set(externalKey, json, maxAge, { + changed, + rolling: opts.rolling, + }); this.ctx.cookies.set(key, externalKey, opts); return; } diff --git a/lib/util.js b/lib/util.js index 2bdd7b8..759197c 100644 --- a/lib/util.js +++ b/lib/util.js @@ -13,7 +13,7 @@ module.exports = { */ decode(string) { - const body = new Buffer(string, 'base64').toString('utf8'); + const body = Buffer.from(string, 'base64').toString('utf8'); const json = JSON.parse(body); return json; }, @@ -28,7 +28,7 @@ module.exports = { encode(body) { body = JSON.stringify(body); - return new Buffer(body).toString('base64'); + return Buffer.from(body).toString('base64'); }, hash(sess) { diff --git a/package.json b/package.json index 9691b3c..17fb1fa 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "koa-session", "description": "Koa cookie session middleware", "repository": "koajs/session", - "version": "4.0.1", + "version": "4.8.1", "keywords": [ "koa", "middleware", @@ -19,7 +19,8 @@ "istanbul": "0", "koa": "1", "mm": "^2.1.0", - "mocha": "3 ", + "mocha": "3", + "mz-modules": "^2.0.0", "should": "8", "supertest": "2" }, @@ -27,11 +28,16 @@ "dependencies": { "crc": "^3.4.4", "debug": "^2.2.0", + "is-type-of": "^1.0.0", + "pedding": "^1.1.0", "uid-safe": "^2.1.3" }, "engines": { "node": ">=4" }, + "publishConfig": { + "tag": "latest-4" + }, "scripts": { "test": "npm run lint && NODE_ENV=test mocha --require should --reporter spec test/*.test.js", "test-cov": "NODE_ENV=test node ./node_modules/.bin/istanbul cover ./node_modules/.bin/_mocha -- --require should test/*.test.js", diff --git a/test/context_store.js b/test/context_store.js new file mode 100644 index 0000000..fb53af6 --- /dev/null +++ b/test/context_store.js @@ -0,0 +1,25 @@ +'use strict'; + +// this is a stupid nonsense example just to test + +const sessions = {}; + +class ContextStore { + constructor(ctx) { + this.ctx = ctx; + } + + * get(key) { + return sessions[key]; + } + + * set(key, value) { + sessions[key] = value; + } + + * destroy(key) { + sessions[key] = undefined; + } +} + +module.exports = ContextStore; diff --git a/test/contextstore.test.js b/test/contextstore.test.js new file mode 100644 index 0000000..6a8d383 --- /dev/null +++ b/test/contextstore.test.js @@ -0,0 +1,644 @@ +'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'); + +describe('Koa Session External Context Store', () => { + let cookie; + + describe('when the session contains a ;', () => { + it('should still work', done => { + const app = App(); + + app.use(function* () { + if (this.method === 'POST') { + this.session.string = ';'; + this.status = 204; + } else { + this.body = this.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(function* () { + this.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(function* () { + this.session; + this.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(function* () { + this.session.message = 'hello'; + this.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(function* () { + this.body = this.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(function* () { + this.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(function* () { + this.session.message.should.equal('hello'); + this.body = 'aklsdjflasdjf'; + }); + + request(app.listen()) + .get('/') + .set('Cookie', cookie) + .expect(200, done); + }); + + it('should not Set-Cookie', done => { + const app = App(); + + app.use(function* () { + this.session.message.should.equal('hello'); + this.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(function* () { + this.session.money = '$$$'; + this.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(function* () { + this.session = null; + this.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(function* () { + this.session = {}; + this.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(function* () { + this.session = { message: 'hello' }; + this.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(function* () { + this.session = 'asdf'; + }); + + request(app.listen()) + .get('/') + .expect(500, done); + }); + }); + }); + + describe('session', () => { + describe('.inspect()', () => { + it('should return session content', done => { + const app = App(); + + app.use(function* () { + this.session.foo = 'bar'; + this.body = this.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(function* () { + this.session.foo = 'bar'; + this.body = String(this.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(function* () { + this.session.foo = 'bar'; + this.body = String(this.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(function* () { + this.session.save(); + this.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 = koa(); + + app.keys = [ 'a', 'b' ]; + + app.use(function* (next) { + try { + yield next; + } catch (err) { + this.status = err.status; + this.body = err.message; + } + }); + + app.use(session({ ContextStore }, app)); + + app.use(function* (next) { + this.session.name = 'funny'; + yield next; + }); + + app.use(function* () { + this.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(function* () { + if (this.method === 'POST') { + this.session.message = 'hi'; + this.body = 200; + return; + } + this.body = this.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(function* () { + if (this.method === 'POST') { + this.session.message = 'hi'; + this.body = 200; + return; + } + this.body = this.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(function* () { + if (this.method === 'POST') { + this.session.message = 'hi'; + this.body = 200; + return; + } + this.body = this.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(function* () { + if (this.method === 'POST') { + this.session.message = 'hi'; + this.status = 200; + return; + } + + this.body = this.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(function* () { + this.body = this.session.maxAge; + }); + + request(app.listen()) + .get('/') + .expect('100', done); + }); + }); + + describe('ctx.session.maxAge=', () => { + it('should set sessionOptions.maxAge', done => { + const app = App(); + + app.use(function* () { + this.session.foo = 'bar'; + this.session.maxAge = 100; + this.body = this.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(function* () { + this.session.maxAge = 100; + this.body = this.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(function* () { + this.session = { maxAge: 100 }; + this.body = this.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(function* () { + this.body = String(this.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 = 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(function* () { + if (!this.session.foo) { + this.session.foo = Date.now() + '|uid:' + this.cookies.get('uid'); + } + + this.body = { + foo: this.session.foo, + uid: this.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('should be mocked', done => { + const app = App(); + + app.use(function* () { + this.body = this.session; + }); + + mm(app.context, 'session', { + foo: 'bar', + }); + + request(app.listen()) + .get('/') + .expect({ + foo: 'bar', + }) + .expect(200, done); + }); + }); + +}); + +function App(options) { + const app = koa(); + app.keys = [ 'a', 'b' ]; + options = options || {}; + options.ContextStore = ContextStore; + options.genid = () => { + return Date.now() + '_suffix'; + }; + app.use(session(options, app)); + return app; +} diff --git a/test/cookie.test.js b/test/cookie.test.js index 56cfcf2..5bdec4d 100644 --- a/test/cookie.test.js +++ b/test/cookie.test.js @@ -1,5 +1,6 @@ 'use strict'; +const assert = require('assert'); const koa = require('koa'); const request = require('supertest'); const should = require('should'); @@ -755,6 +756,58 @@ describe('Koa Session Cookie', () => { }); }); }); + + 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) { diff --git a/test/store.test.js b/test/store.test.js index 1e54227..13c788d 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -6,6 +6,9 @@ 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'); describe('Koa Session External Store', () => { let cookie; @@ -356,6 +359,60 @@ describe('Koa Session External Store', () => { }); 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(function* () { + if (this.method === 'POST') { + this.session.message = 'hi'; + this.body = 200; + return; + } + this.body = this.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(function* () { + if (this.method === 'POST') { + this.session.message = 'hi'; + this.body = 200; + return; + } + this.body = this.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 }); @@ -388,6 +445,7 @@ describe('Koa Session External Store', () => { describe('and expired', () => { it('should expire the sess', done => { + done = pedding(done, 2); const app = App({ maxAge: 100 }); app.use(function* () { @@ -399,6 +457,12 @@ describe('Koa Session External Store', () => { this.body = this.session.message || ''; }); + app.on('session:expired', args => { + assert(args.key.match(/^\d+-/)); + assert(args.value); + assert(args.ctx); + done(); + }); const server = app.listen(); @@ -481,12 +545,19 @@ describe('Koa Session External Store', () => { describe('when store return empty', () => { it('should create new Session', done => { + done = pedding(done, 2); const app = App({ signed: false }); app.use(function* () { this.body = String(this.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') @@ -497,6 +568,7 @@ describe('Koa Session External Store', () => { describe('when valid and beforeSave set', () => { it('should ignore session when uid changed', done => { + done = pedding(done, 2); const app = koa(); app.keys = [ 'a', 'b' ]; @@ -519,6 +591,12 @@ describe('Koa Session External Store', () => { uid: this.cookies.get('uid'), }; }); + app.on('session:invalid', args => { + assert(args.key); + assert(args.value); + assert(args.ctx); + done(); + }); request(app.callback()) .get('/') @@ -574,6 +652,124 @@ describe('Koa Session External Store', () => { }); }); + describe('when rolling set to true', () => { + let app; + before(() => { + app = App({ rolling: true }); + + app.use(function* () { + 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('when prefix present', () => { + it('should still work', done => { + const app = App({ prefix: 'sess:' }); + + app.use(function* () { + if (this.method === 'POST') { + this.session.string = ';'; + this.status = 204; + } else { + this.body = this.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(function* () { + 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 near expire and not change', function* () { + let res = yield request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + res.headers['set-cookie'].should.have.length(2); + const cookie = res.headers['set-cookie'].join(';'); + yield sleep(1200); + res = yield 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', function* () { + let res = yield request(app.callback()) + .get('/set') + .expect({ foo: 'bar' }); + + res.headers['set-cookie'].should.have.length(2); + const cookie = res.headers['set-cookie'].join(';'); + yield sleep(500); + res = yield request(app.callback()) + .get('/') + .set('cookie', cookie) + .expect({ foo: 'bar' }); + should.not.exist(res.headers['set-cookie']); + }); + }); }); function App(options) {