diff --git a/core/server/errorHandling.js b/core/server/errorHandling.js index 6c5a52c8df..dc9bf4e8d8 100644 --- a/core/server/errorHandling.js +++ b/core/server/errorHandling.js @@ -9,7 +9,9 @@ var _ = require('underscore'), // Paths for views defaultErrorTemplatePath = path.resolve(configPaths().adminViews, 'user-error.hbs'), userErrorTemplatePath = path.resolve(configPaths().themePath, 'error.hbs'), - userErrorTemplateExists; + userErrorTemplateExists, + + ONE_HOUR_S = 60 * 60; /** * Basic error handling helpers @@ -182,6 +184,8 @@ errors = { error404: function (req, res, next) { var message = res.isAdmin && req.session.user ? "No Ghost Found" : "Page Not Found"; + // 404 errors should be briefly cached + res.set({'Cache-Control': 'public, max-age=' + ONE_HOUR_S}); if (req.method === 'GET') { this.renderErrorPage(404, message, req, res, next); } else { @@ -190,6 +194,13 @@ errors = { }, error500: function (err, req, res, next) { + // 500 errors should never be cached + res.set({'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'}); + + if (err.status === 404) { + return this.error404(req, res, next); + } + if (req.method === 'GET') { if (!err || !(err instanceof Error)) { next(); diff --git a/core/server/helpers/index.js b/core/server/helpers/index.js index 47ffdd3694..6587dc00b1 100644 --- a/core/server/helpers/index.js +++ b/core/server/helpers/index.js @@ -1,20 +1,22 @@ -var _ = require('underscore'), - moment = require('moment'), - downsize = require('downsize'), - path = require('path'), - when = require('when'), +var downsize = require('downsize'), hbs = require('express-hbs'), + moment = require('moment'), + path = require('path'), polyglot = require('node-polyglot').instance, - template = require('./template'), - errors = require('../errorHandling'), - models = require('../models'), - filters = require('../filters'), - packageInfo = require('../../../package.json'), - version = packageInfo.version, - scriptTemplate = _.template(''), - isProduction = process.env.NODE_ENV === 'production', + _ = require('underscore'), + when = require('when'), + api = require('../api'), config = require('../config'), + errors = require('../errorHandling'), + filters = require('../filters'), + models = require('../models'), + template = require('./template'), + + assetTemplate = _.template('<%= source %>?v=<%= version %>'), + scriptTemplate = _.template(''), + isProduction = process.env.NODE_ENV === 'production', + coreHelpers = {}, registerHelpers; @@ -128,7 +130,6 @@ coreHelpers.url = function (options) { // *Usage example:* // `{{asset "css/screen.css"}}` // `{{asset "css/screen.css" ghost="true"}}` -// // Returns the path to the specified asset. The ghost // flag outputs the asset path for the Ghost admin coreHelpers.asset = function (context, options) { @@ -137,7 +138,7 @@ coreHelpers.asset = function (context, options) { output += config.paths().subdir + '/'; - if (!context.match(/^shared/)) { + if (!context.match(/^favicon\.ico$/) && !context.match(/^shared/)) { if (isAdmin) { output += 'ghost/'; } else { @@ -146,6 +147,14 @@ coreHelpers.asset = function (context, options) { } output += context; + + if (!context.match(/^favicon\.ico$/)) { + output = assetTemplate({ + source: output, + version: coreHelpers.assetHash + }); + } + return new hbs.handlebars.SafeString(output); }; @@ -284,8 +293,8 @@ coreHelpers.ghostScriptTags = function () { scriptFiles = _.map(scriptFiles, function (fileName) { return scriptTemplate({ - source: config.paths().subdir + '/built/scripts/' + fileName, - version: version + source: config.paths().subdir + '/ghost/scripts/' + fileName, + version: coreHelpers.assetHash }); }); @@ -379,7 +388,7 @@ coreHelpers.ghost_foot = function (options) { foot.push(scriptTemplate({ source: config.paths().subdir + '/shared/vendor/jquery/jquery.js', - version: this.version + version: coreHelpers.assetHash })); return filters.doFilter('ghost_foot', foot).then(function (foot) { @@ -593,11 +602,14 @@ function registerAsyncAdminHelper(name, fn) { -registerHelpers = function (adminHbs) { +registerHelpers = function (adminHbs, assetHash) { // Expose hbs instance for admin coreHelpers.adminHbs = adminHbs; + // Store hash for assets + coreHelpers.assetHash = assetHash; + // Register theme helpers registerThemeHelper('asset', coreHelpers.asset); diff --git a/core/server/index.js b/core/server/index.js index ab8a9bb8c9..a215c3dd54 100644 --- a/core/server/index.js +++ b/core/server/index.js @@ -3,30 +3,31 @@ // modules to ensure config gets right setting. // Module dependencies -var config = require('./config'), - express = require('express'), - when = require('when'), - _ = require('underscore'), - semver = require('semver'), - fs = require('fs'), - errors = require('./errorHandling'), - plugins = require('./plugins'), - path = require('path'), - Polyglot = require('node-polyglot'), - mailer = require('./mail'), - helpers = require('./helpers'), - middleware = require('./middleware'), - routes = require('./routes'), - packageInfo = require('../../package.json'), - models = require('./models'), - permissions = require('./permissions'), - uuid = require('node-uuid'), - api = require('./api'), - hbs = require('express-hbs'), +var crypto = require('crypto'), + express = require('express'), + hbs = require('express-hbs'), + fs = require('fs'), + uuid = require('node-uuid'), + path = require('path'), + Polyglot = require('node-polyglot'), + semver = require('semver'), + _ = require('underscore'), + when = require('when'), + + api = require('./api'), + config = require('./config'), + errors = require('./errorHandling'), + helpers = require('./helpers'), + mailer = require('./mail'), + middleware = require('./middleware'), + models = require('./models'), + permissions = require('./permissions'), + plugins = require('./plugins'), + routes = require('./routes'), + packageInfo = require('../../package.json'), + // Variables - setup, - init, dbHash; // If we're in development mode, require "when/console/monitor" @@ -79,6 +80,9 @@ function initDbHashAndFirstRun() { // Finally it starts the http server. function setup(server) { + // create a hash for cache busting assets + var assetHash = (crypto.createHash('md5').update(packageInfo.version + Date().now).digest('hex')).substring(0, 10); + // Set up Polygot instance on the require module Polyglot.instance = new Polyglot(); @@ -112,6 +116,7 @@ function setup(server) { var adminHbs = hbs.create(); // ##Configuration + server.set('version hash', assetHash); // return the correct mime type for woff filess express['static'].mime.define({'application/font-woff': ['woff']}); @@ -124,7 +129,7 @@ function setup(server) { server.set('admin view engine', adminHbs.express3({partialsDir: config.paths().adminViews + 'partials'})); // Load helpers - helpers.loadCoreHelpers(adminHbs); + helpers.loadCoreHelpers(adminHbs, assetHash); // ## Middleware middleware(server, dbHash); diff --git a/core/server/middleware/index.js b/core/server/middleware/index.js index 6ee8860e7d..920c4d2e85 100644 --- a/core/server/middleware/index.js +++ b/core/server/middleware/index.js @@ -18,7 +18,11 @@ var middleware = require('./middleware'), BSStore = require('../bookshelf-session'), models = require('../models'), - expressServer; + expressServer, + ONE_HOUR_S = 60 * 60, + ONE_YEAR_S = 365 * 24 * ONE_HOUR_S, + ONE_HOUR_MS = ONE_HOUR_S * 1000, + ONE_YEAR_MS = 365 * 24 * ONE_HOUR_MS; // ##Custom Middleware @@ -186,9 +190,7 @@ function checkSSL(req, res, next) { } module.exports = function (server, dbHash) { - var oneHour = 60 * 60 * 1000, - oneYear = 365 * 24 * oneHour, - subdir = config.paths().subdir, + var subdir = config.paths().subdir, corePath = config.paths().corePath, cookie; @@ -206,16 +208,11 @@ module.exports = function (server, dbHash) { // Favicon expressServer.use(subdir, express.favicon(corePath + '/shared/favicon.ico')); - // Shared static config - expressServer.use(subdir + '/shared', express['static'](path.join(corePath, '/shared'))); - + // Static assets + // For some reason send divides the max age number by 1000 + expressServer.use(subdir + '/shared', express['static'](path.join(corePath, '/shared'), {maxAge: ONE_HOUR_MS})); expressServer.use(subdir + '/content/images', storage.get_storage().serve()); - - // Serve our built scripts; can't use /scripts here because themes already are - expressServer.use(subdir + '/built/scripts', express['static'](path.join(corePath, '/built/scripts'), { - // Put a maxAge of one year on built scripts - maxAge: oneYear - })); + expressServer.use(subdir + '/ghost/scripts', express['static'](path.join(corePath, '/built/scripts'), {maxAge: ONE_YEAR_MS})); // First determine whether we're serving admin or theme content expressServer.use(manageAdminAndTheme); @@ -223,25 +220,27 @@ module.exports = function (server, dbHash) { // Force SSL expressServer.use(checkSSL); + // Admin only config - expressServer.use(subdir + '/ghost', middleware.whenEnabled('admin', express['static'](path.join(corePath, '/client/assets')))); + expressServer.use(subdir + '/ghost', middleware.whenEnabled('admin', express['static'](path.join(corePath, '/client/assets'), {maxAge: ONE_YEAR_MS}))); // Theme only config expressServer.use(subdir, middleware.whenEnabled(expressServer.get('activeTheme'), middleware.staticTheme())); // Add in all trailing slashes - expressServer.use(slashes()); + expressServer.use(slashes(true, {headers: {'Cache-Control': 'public, max-age=' + ONE_YEAR_S}})); + // Body parsing expressServer.use(express.json()); expressServer.use(express.urlencoded()); expressServer.use(subdir + '/ghost/upload/', middleware.busboy); expressServer.use(subdir + '/ghost/api/v0.1/db/', middleware.busboy); - // Session handling + // ### Sessions cookie = { path: subdir + '/ghost', - maxAge: 12 * oneHour + maxAge: 12 * ONE_HOUR_MS }; // if SSL is forced, add secure flag to cookie @@ -258,17 +257,25 @@ module.exports = function (server, dbHash) { cookie: cookie })); + //enable express csrf protection expressServer.use(middleware.conditionalCSRF); + + // local data expressServer.use(ghostLocals); - // So on every request we actually clean out reduntant passive notifications from the server side + // So on every request we actually clean out redundant passive notifications from the server side expressServer.use(middleware.cleanNotifications); - // Initialise the views expressServer.use(initViews); - // process the application routes + + // ### Caching + expressServer.use(middleware.cacheControl('public')); + expressServer.use('/api/', middleware.cacheControl('private')); + expressServer.use('/ghost/', middleware.cacheControl('private')); + + // ### Routing expressServer.use(subdir, expressServer.router); // ### Error handling diff --git a/core/server/middleware/middleware.js b/core/server/middleware/middleware.js index 69f5c0f007..aad635102b 100644 --- a/core/server/middleware/middleware.js +++ b/core/server/middleware/middleware.js @@ -8,7 +8,9 @@ var _ = require('underscore'), config = require('../config'), path = require('path'), api = require('../api'), - expressServer; + + expressServer, + ONE_HOUR_MS = 60 * 60 * 1000; function isBlackListedFileType(file) { var blackListedFileTypes = ['.hbs', '.md', '.json'], @@ -89,16 +91,26 @@ var middleware = { }); }, - // ### DisableCachedResult Middleware - // Disable any caching until it can be done properly - disableCachedResult: function (req, res, next) { + // ### CacheControl Middleware + // provide sensible cache control headers + cacheControl: function (options) { /*jslint unparam:true*/ - res.set({ - 'Cache-Control': 'no-cache, must-revalidate', - 'Expires': 'Sat, 26 Jul 1997 05:00:00 GMT' - }); + var profiles = { + 'public': 'public, max-age=0', + 'private': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0' + }, + output; - next(); + if (_.isString(options) && profiles.hasOwnProperty(options)) { + output = profiles[options]; + } + + return function cacheControlHeaders(req, res, next) { + if (output) { + res.set({'Cache-Control': output}); + } + next(); + }; }, // ### whenEnabled Middleware @@ -128,7 +140,8 @@ var middleware = { // to allow unit testing forwardToExpressStatic: function (req, res, next) { api.settings.read('activeTheme').then(function (activeTheme) { - express['static'](path.join(config.paths().themePath, activeTheme.value))(req, res, next); + // For some reason send divides the max age number by 1000 + express['static'](path.join(config.paths().themePath, activeTheme.value), {maxAge: ONE_HOUR_MS})(req, res, next); }); }, diff --git a/core/server/routes/api.js b/core/server/routes/api.js index 6b39b002e6..39c2c6fa52 100644 --- a/core/server/routes/api.js +++ b/core/server/routes/api.js @@ -5,26 +5,26 @@ module.exports = function (server) { // ### API routes /* TODO: auth should be public auth not user auth */ // #### Posts - server.get('/ghost/api/v0.1/posts', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.posts.browse)); - server.post('/ghost/api/v0.1/posts', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.posts.add)); - server.get('/ghost/api/v0.1/posts/:id', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.posts.read)); - server.put('/ghost/api/v0.1/posts/:id', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.posts.edit)); - server.del('/ghost/api/v0.1/posts/:id', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.posts.destroy)); + server.get('/ghost/api/v0.1/posts', middleware.authAPI, api.requestHandler(api.posts.browse)); + server.post('/ghost/api/v0.1/posts', middleware.authAPI, api.requestHandler(api.posts.add)); + server.get('/ghost/api/v0.1/posts/:id', middleware.authAPI, api.requestHandler(api.posts.read)); + server.put('/ghost/api/v0.1/posts/:id', middleware.authAPI, api.requestHandler(api.posts.edit)); + server.del('/ghost/api/v0.1/posts/:id', middleware.authAPI, api.requestHandler(api.posts.destroy)); // #### Settings - server.get('/ghost/api/v0.1/settings/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.settings.browse)); - server.get('/ghost/api/v0.1/settings/:key/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.settings.read)); - server.put('/ghost/api/v0.1/settings/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.settings.edit)); + server.get('/ghost/api/v0.1/settings/', middleware.authAPI, api.requestHandler(api.settings.browse)); + server.get('/ghost/api/v0.1/settings/:key/', middleware.authAPI, api.requestHandler(api.settings.read)); + server.put('/ghost/api/v0.1/settings/', middleware.authAPI, api.requestHandler(api.settings.edit)); // #### Users - server.get('/ghost/api/v0.1/users/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.users.browse)); - server.get('/ghost/api/v0.1/users/:id/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.users.read)); - server.put('/ghost/api/v0.1/users/:id/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.users.edit)); + server.get('/ghost/api/v0.1/users/', middleware.authAPI, api.requestHandler(api.users.browse)); + server.get('/ghost/api/v0.1/users/:id/', middleware.authAPI, api.requestHandler(api.users.read)); + server.put('/ghost/api/v0.1/users/:id/', middleware.authAPI, api.requestHandler(api.users.edit)); // #### Tags - server.get('/ghost/api/v0.1/tags/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.tags.all)); + server.get('/ghost/api/v0.1/tags/', middleware.authAPI, api.requestHandler(api.tags.all)); // #### Notifications - server.del('/ghost/api/v0.1/notifications/:id', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.notifications.destroy)); - server.post('/ghost/api/v0.1/notifications/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.notifications.add)); + server.del('/ghost/api/v0.1/notifications/:id', middleware.authAPI, api.requestHandler(api.notifications.destroy)); + server.post('/ghost/api/v0.1/notifications/', middleware.authAPI, api.requestHandler(api.notifications.add)); // #### Import/Export server.get('/ghost/api/v0.1/db/', middleware.auth, api.db['export']); server.post('/ghost/api/v0.1/db/', middleware.auth, api.db['import']); - server.del('/ghost/api/v0.1/db/', middleware.authAPI, middleware.disableCachedResult, api.requestHandler(api.db.deleteAllContent)); + server.del('/ghost/api/v0.1/db/', middleware.authAPI, api.requestHandler(api.db.deleteAllContent)); }; \ No newline at end of file diff --git a/core/server/storage/localfilesystem.js b/core/server/storage/localfilesystem.js index 53b30810d5..92d0892d41 100644 --- a/core/server/storage/localfilesystem.js +++ b/core/server/storage/localfilesystem.js @@ -56,7 +56,11 @@ localFileStore = _.extend(baseStore, { // middleware for serving the files 'serve': function () { - return express['static'](configPaths().imagesPath); + var ONE_HOUR_MS = 60 * 60 * 1000, + ONE_YEAR_MS = 365 * 24 * ONE_HOUR_MS; + + // For some reason send divides the max age number by 1000 + return express['static'](configPaths().imagesPath, {maxAge: ONE_YEAR_MS}); } }); diff --git a/core/test/functional/admin/content_test.js b/core/test/functional/admin/content_test.js index f1f5b535bd..194db5e522 100644 --- a/core/test/functional/admin/content_test.js +++ b/core/test/functional/admin/content_test.js @@ -1,6 +1,6 @@ /*globals casper, __utils__, url, testPost */ -CasperTest.begin("Content screen is correct", 21, function suite(test) { +CasperTest.begin("Content screen is correct", 20, function suite(test) { // Create a sample post casper.thenOpen(url + 'ghost/editor/', function testTitleAndUrl() { test.assertTitle('Ghost Admin', 'Ghost admin has no title'); @@ -37,10 +37,6 @@ CasperTest.begin("Content screen is correct", 21, function suite(test) { test.assertSelectorHasText("#usermenu .usermenu-profile a", "Your Profile"); test.assertSelectorHasText("#usermenu .usermenu-help a", "Help / Support"); test.assertSelectorHasText("#usermenu .usermenu-signout a", "Sign Out"); - - test.assertResourceExists(function (resource) { - return resource.url.match(/user-image\.png$/) && (resource.status === 200 || resource.status === 304); - }, 'Default user image'); }); casper.then(function testViews() { diff --git a/core/test/functional/admin/settings_test.js b/core/test/functional/admin/settings_test.js index 4b2625b956..0882aef54a 100644 --- a/core/test/functional/admin/settings_test.js +++ b/core/test/functional/admin/settings_test.js @@ -1,6 +1,6 @@ /*globals casper, __utils__, url */ -CasperTest.begin("Settings screen is correct", 17, function suite(test) { +CasperTest.begin("Settings screen is correct", 15, function suite(test) { casper.thenOpen(url + "ghost/settings/", function testTitleAndUrl() { test.assertTitle("Ghost Admin", "Ghost admin has no title"); test.assertUrlMatch(/ghost\/settings\/general\/$/, "Ghost doesn't require login this time"); @@ -32,16 +32,6 @@ CasperTest.begin("Settings screen is correct", 17, function suite(test) { test.assertEval(function testContentIsUser() { return document.querySelector('.settings-content').id === 'user'; }, "loaded content is user screen"); - - test.assertResourceExists(function (resource) { - return resource.url.match(/user-image\.png$/) && (resource.status === 200 || resource.status === 304); - }, 'Default user image'); - - test.assertResourceExists(function (resource) { - return resource.url.match(/user-cover\.png$/) && (resource.status === 200 || resource.status === 304); - }, 'Default cover image'); - - }, function onTimeOut() { test.fail('User screen failed to load'); }); diff --git a/core/test/functional/routes/admin_test.js b/core/test/functional/routes/admin_test.js new file mode 100644 index 0000000000..06f9516ea8 --- /dev/null +++ b/core/test/functional/routes/admin_test.js @@ -0,0 +1,121 @@ +/*global describe, it, before, after */ + +// # Frontend Route tests +// As it stands, these tests depend on the database, and as such are integration tests. +// Mocking out the models to not touch the DB would turn these into unit tests, and should probably be done in future, +// But then again testing real code, rather than mock code, might be more useful... + +var request = require('supertest'), + should = require('should'), + moment = require('moment'), + + testUtils = require('../../utils'), + config = require('../../../server/config'), + + ONE_HOUR_S = 60 * 60, + ONE_YEAR_S = 365 * 24 * ONE_HOUR_S, + cacheRules = { + 'public': 'public, max-age=0', + 'hour': 'public, max-age=' + ONE_HOUR_S, + 'year': 'public, max-age=' + ONE_YEAR_S, + 'private': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0' + }; + +describe('Admin Routing', function () { + function doEnd(done) { + return function (err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + should.not.exist(res.headers['X-CSRF-Token']); + should.exist(res.headers['set-cookie']); + should.exist(res.headers.date); + + done(); + }; + } + + before(function (done) { + testUtils.clearData().then(function () { + // we initialise data, but not a user. No user should be required for navigating the frontend + return testUtils.initData(); + }).then(function () { + done(); + }, done); + + // Setup the request object with the correct URL + request = request(config().url); + }); + + describe('Ghost Admin Signup', function () { + it('should have a session cookie which expires in 12 hours', function (done) { + request.get('/ghost/signup/') + .end(function firstRequest(err, res) { + if (err) { + return done(err); + } + + should.not.exist(res.headers['x-cache-invalidate']); + should.not.exist(res.headers['X-CSRF-Token']); + should.exist(res.headers['set-cookie']); + should.exist(res.headers.date); + + var expires; + // Session should expire 12 hours after the time in the date header + expires = moment(res.headers.date).add('Hours', 12).format("ddd, DD MMM YYYY HH:mm"); + expires = new RegExp("Expires=" + expires); + + res.headers['set-cookie'].should.match(expires); + + done(); + }); + }); + + it('should redirect from /ghost to /ghost/signup when no user', function (done) { + request.get('/ghost/') + .expect('Location', /ghost\/signup/) + .expect('Cache-Control', cacheRules['private']) + .expect(302) + .end(doEnd(done)); + }); + + it('should redirect from /ghost/signin to /ghost/signup when no user', function (done) { + request.get('/ghost/signin/') + .expect('Location', /ghost\/signup/) + .expect('Cache-Control', cacheRules['private']) + .expect(302) + .end(doEnd(done)); + }); + + it('should respond with html for /ghost/signup', function (done) { + request.get('/ghost/signup/') + .expect('Content-Type', /html/) + .expect('Cache-Control', cacheRules['private']) + .expect(200) + .end(doEnd(done)); + }); + + // Add user + +// it('should redirect from /ghost/signup to /ghost/signin with user', function (done) { +// done(); +// }); + +// it('should respond with html for /ghost/signin', function (done) { +// done(); +// }); + + // Do Login + +// it('should redirect from /ghost/signup to /ghost/ when logged in', function (done) { +// done(); +// }); + +// it('should redirect from /ghost/signup to /ghost/ when logged in', function (done) { +// done(); +// }); + + }); +}); \ No newline at end of file diff --git a/core/test/functional/routes/frontend_test.js b/core/test/functional/routes/frontend_test.js index 6323bbb7ae..8a5f15af2d 100644 --- a/core/test/functional/routes/frontend_test.js +++ b/core/test/functional/routes/frontend_test.js @@ -5,12 +5,21 @@ // Mocking out the models to not touch the DB would turn these into unit tests, and should probably be done in future, // But then again testing real code, rather than mock code, might be more useful... -var request = require('supertest'), - should = require('should'), - moment = require('moment'), +var request = require('supertest'), + should = require('should'), + moment = require('moment'), - testUtils = require('../../utils'), - config = require('../../../server/config'); + testUtils = require('../../utils'), + config = require('../../../server/config'), + + ONE_HOUR_S = 60 * 60, + ONE_YEAR_S = 365 * 24 * ONE_HOUR_S, + cacheRules = { + 'public': 'public, max-age=0', + 'hour': 'public, max-age=' + ONE_HOUR_S, + 'year': 'public, max-age=' + ONE_YEAR_S, + 'private': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0' + }; describe('Frontend Routing', function () { function doEnd(done) { @@ -18,6 +27,12 @@ describe('Frontend Routing', function () { if (err) { return done(err); } + + should.not.exist(res.headers['x-cache-invalidate']); + should.not.exist(res.headers['X-CSRF-Token']); + should.not.exist(res.headers['set-cookie']); + should.exist(res.headers.date); + done(); }; } @@ -38,6 +53,7 @@ describe('Frontend Routing', function () { it('should respond with html', function (done) { request.get('/') .expect('Content-Type', /html/) + .expect('Cache-Control', cacheRules['public']) .expect(200) .end(doEnd(done)); }); @@ -45,6 +61,7 @@ describe('Frontend Routing', function () { it('should not have as second page', function (done) { request.get('/page/2/') .expect('Location', '/') + .expect('Cache-Control', cacheRules['public']) .expect(302) .end(doEnd(done)); }); @@ -54,6 +71,7 @@ describe('Frontend Routing', function () { it('should redirect without slash', function (done) { request.get('/welcome-to-ghost') .expect('Location', '/welcome-to-ghost/') + .expect('Cache-Control', cacheRules.year) .expect(301) .end(doEnd(done)); }); @@ -61,6 +79,7 @@ describe('Frontend Routing', function () { it('should respond with html', function (done) { request.get('/welcome-to-ghost/') .expect('Content-Type', /html/) + .expect('Cache-Control', cacheRules['public']) .expect(200) .end(doEnd(done)); }); @@ -72,17 +91,17 @@ describe('Frontend Routing', function () { console.log('date', date); request.get('/' + date + '/welcome-to-ghost/') + .expect('Cache-Control', cacheRules.hour) .expect(404) - // TODO this error message is inconsistent .expect(/Page Not Found/) .end(doEnd(done)); }); it('should 404 for unknown post', function (done) { request.get('/spectacular/') + .expect('Cache-Control', cacheRules.hour) .expect(404) - // TODO this error message is inconsistent - .expect(/Post not found/) + .expect(/Page Not Found/) .end(doEnd(done)); }); }); @@ -91,6 +110,7 @@ describe('Frontend Routing', function () { it('should redirect without slash', function (done) { request.get('/rss') .expect('Location', '/rss/') + .expect('Cache-Control', cacheRules.year) .expect(301) .end(doEnd(done)); }); @@ -98,14 +118,16 @@ describe('Frontend Routing', function () { it('should respond with xml', function (done) { request.get('/rss/') .expect('Content-Type', /xml/) + .expect('Cache-Control', cacheRules['public']) .expect(200) .end(doEnd(done)); }); it('should not have as second page', function (done) { request.get('/rss/2/') - // TODO this should probably redirect straight to /rss/ ? + // TODO this should probably redirect straight to /rss/ with 301? .expect('Location', '/rss/1/') + .expect('Cache-Control', cacheRules['public']) .expect(302) .end(doEnd(done)); }); @@ -129,6 +151,7 @@ describe('Frontend Routing', function () { it('should redirect without slash', function (done) { request.get('/page/2') .expect('Location', '/page/2/') + .expect('Cache-Control', cacheRules.year) .expect(301) .end(doEnd(done)); }); @@ -136,6 +159,7 @@ describe('Frontend Routing', function () { it('should respond with html', function (done) { request.get('/page/2/') .expect('Content-Type', /html/) + .expect('Cache-Control', cacheRules['public']) .expect(200) .end(doEnd(done)); }); @@ -143,6 +167,7 @@ describe('Frontend Routing', function () { it('should redirect page 1', function (done) { request.get('/page/1/') .expect('Location', '/') + .expect('Cache-Control', cacheRules['public']) // TODO: This should probably be a 301? .expect(302) .end(doEnd(done)); @@ -151,6 +176,7 @@ describe('Frontend Routing', function () { it('should redirect to last page is page too high', function (done) { request.get('/page/4/') .expect('Location', '/page/3/') + .expect('Cache-Control', cacheRules['public']) .expect(302) .end(doEnd(done)); }); @@ -158,6 +184,7 @@ describe('Frontend Routing', function () { it('should redirect to first page is page too low', function (done) { request.get('/page/0/') .expect('Location', '/') + .expect('Cache-Control', cacheRules['public']) .expect(302) .end(doEnd(done)); }); @@ -167,6 +194,7 @@ describe('Frontend Routing', function () { it('should redirect without slash', function (done) { request.get('/rss/2') .expect('Location', '/rss/2/') + .expect('Cache-Control', cacheRules.year) .expect(301) .end(doEnd(done)); }); @@ -174,6 +202,7 @@ describe('Frontend Routing', function () { it('should respond with xml', function (done) { request.get('/rss/2/') .expect('Content-Type', /xml/) + .expect('Cache-Control', cacheRules['public']) .expect(200) .end(doEnd(done)); }); @@ -181,6 +210,7 @@ describe('Frontend Routing', function () { it('should redirect page 1', function (done) { request.get('/rss/1/') .expect('Location', '/rss/') + .expect('Cache-Control', cacheRules['public']) // TODO: This should probably be a 301? .expect(302) .end(doEnd(done)); @@ -189,6 +219,7 @@ describe('Frontend Routing', function () { it('should redirect to last page is page too high', function (done) { request.get('/rss/3/') .expect('Location', '/rss/2/') + .expect('Cache-Control', cacheRules['public']) .expect(302) .end(doEnd(done)); }); @@ -196,6 +227,7 @@ describe('Frontend Routing', function () { it('should redirect to first page is page too low', function (done) { request.get('/rss/0/') .expect('Location', '/rss/') + .expect('Cache-Control', cacheRules['public']) .expect(302) .end(doEnd(done)); }); @@ -205,6 +237,7 @@ describe('Frontend Routing', function () { it('should redirect without slash', function (done) { request.get('/static-page-test') .expect('Location', '/static-page-test/') + .expect('Cache-Control', cacheRules.year) .expect(301) .end(doEnd(done)); }); @@ -212,11 +245,39 @@ describe('Frontend Routing', function () { it('should respond with xml', function (done) { request.get('/static-page-test/') .expect('Content-Type', /html/) + .expect('Cache-Control', cacheRules['public']) .expect(200) .end(doEnd(done)); }); }); + describe('Static assets', function () { + it('should retrieve shared assets', function (done) { + request.get('/shared/img/usr-image.png') + .expect('Cache-Control', cacheRules.year) + .end(doEnd(done)); + }); + + it('should retrieve theme assets', function (done) { + request.get('/assets/css/screen.css') + .expect('Cache-Control', cacheRules.hour) + .end(doEnd(done)); + }); + + it('should retrieve built assets', function (done) { + request.get('/ghost/built/vendor.js') + .expect('Cache-Control', cacheRules.year) + .end(doEnd(done)); + }); + + // at the moment there is no image fixture to test + // it('should retrieve image assets', function (done) { + // request.get('/assets/css/screen.css') + // .expect('Cache-Control', cacheRules.year) + // .end(doEnd(done)); + // }); + }); + // ### The rest of the tests switch to date permalinks // describe('Date permalinks', function () { diff --git a/core/test/unit/middleware_spec.js b/core/test/unit/middleware_spec.js index 76405d3418..b519dc239f 100644 --- a/core/test/unit/middleware_spec.js +++ b/core/test/unit/middleware_spec.js @@ -1,11 +1,11 @@ -/*globals describe, beforeEach, it*/ +/*globals describe, beforeEach, afterEach, it*/ var assert = require('assert'), should = require('should'), sinon = require('sinon'), when = require('when'), _ = require('underscore'), express = require('express'), - api = require('../../server/api'); + api = require('../../server/api'), middleware = require('../../server/middleware').middleware; describe('Middleware', function () { @@ -33,9 +33,9 @@ describe('Middleware', function () { middleware.auth(req, res, null).then(function () { assert(res.redirect.calledWithMatch('/ghost/signin/')); - return done(); + return done(); }); - + }); it('should redirect to signin path with redirect paramater stripped of /ghost/', function(done) { @@ -145,22 +145,24 @@ describe('Middleware', function () { beforeEach(function (done) { api.notifications.add({ - id: 0, + id: 0, + status: 'passive', + message: 'passive-one' + }).then(function () { + return api.notifications.add({ + id: 1, status: 'passive', - message: 'passive-one' - }).then(function () { - return api.notifications.add({ - id: 1, - status: 'passive', - message: 'passive-two'}); - }).then(function () { - return api.notifications.add({ - id: 2, - status: 'aggressive', - message: 'aggressive'}); - }).then(function () { - done(); + message: 'passive-two' }); + }).then(function () { + return api.notifications.add({ + id: 2, + status: 'aggressive', + message: 'aggressive' + }); + }).then(function () { + done(); + }); }); it('should clean all passive messages', function (done) { @@ -177,7 +179,7 @@ describe('Middleware', function () { }); }); - describe('disableCachedResult', function () { + describe('cacheControl', function () { var res; beforeEach(function () { @@ -186,12 +188,28 @@ describe('Middleware', function () { }; }); - it('should set correct cache headers', function (done) { - middleware.disableCachedResult(null, res, function () { - assert(res.set.calledWith({ - 'Cache-Control': 'no-cache, must-revalidate', - 'Expires': 'Sat, 26 Jul 1997 05:00:00 GMT' - })); + it('correctly sets the public profile headers', function (done) { + middleware.cacheControl('public')(null, res, function (a) { + should.not.exist(a); + res.set.calledOnce.should.be.true; + res.set.calledWith({'Cache-Control': 'public, max-age=0'}); + return done(); + }); + }); + + it('correctly sets the private profile headers', function (done) { + middleware.cacheControl('private')(null, res, function (a) { + should.not.exist(a); + res.set.calledOnce.should.be.true; + res.set.calledWith({'Cache-Control': 'no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0'}); + return done(); + }); + }); + + it('will not set headers without a profile', function (done) { + middleware.cacheControl()(null, res, function (a) { + should.not.exist(a); + res.set.called.should.be.false; return done(); }); }); @@ -235,8 +253,6 @@ describe('Middleware', function () { }); describe('staticTheme', function () { - var realExpressStatic = express.static; - beforeEach(function () { sinon.stub(middleware, 'forwardToExpressStatic').yields(); }); diff --git a/core/test/unit/server_helpers_index_spec.js b/core/test/unit/server_helpers_index_spec.js index 75e8ad9e68..92166eeb7e 100644 --- a/core/test/unit/server_helpers_index_spec.js +++ b/core/test/unit/server_helpers_index_spec.js @@ -1,17 +1,19 @@ /*globals describe, beforeEach, afterEach, it*/ -var testUtils = require('../utils'), - should = require('should'), - sinon = require('sinon'), - when = require('when'), - _ = require('underscore'), - path = require('path'), - api = require('../../server/api'), - hbs = require('express-hbs'), +var testUtils = require('../utils'), + should = require('should'), + sinon = require('sinon'), + when = require('when'), + _ = require('underscore'), + path = require('path'), + rewire = require('rewire'), + api = require('../../server/api'), + hbs = require('express-hbs'), + // Stuff we are testing handlebars = hbs.handlebars, - helpers = require('../../server/helpers'), - config = require('../../server/config'); + helpers = require('../../server/helpers'), + config = require('../../server/config'); describe('Core Helpers', function () { @@ -302,9 +304,12 @@ describe('Core Helpers', function () { }); it('returns meta tag string', function (done) { - helpers.ghost_foot.call({version: "0.9"}).then(function (rendered) { + + helpers.assetHash = 'abc'; + + helpers.ghost_foot.call().then(function (rendered) { should.exist(rendered); - rendered.string.should.match(/' + + '' + + '' + + '' + + '' + ); + + configStub = sinon.stub(config, 'paths', function () { + return {'subdir': '/blog'}; + }); + + // with subdirectory + rendered = helpers.ghostScriptTags(); + should.exist(rendered); + String(rendered).should.equal( + '' + + '' + + '' + + '' + + '' + ); + }); + + it('outputs correct scripts for production mode', function () { + + helpers.__set__('isProduction', true); + + rendered = helpers.ghostScriptTags(); + should.exist(rendered); + String(rendered).should.equal(''); + + configStub = sinon.stub(config, 'paths', function () { + return {'subdir': '/blog'}; + }); + + // with subdirectory + rendered = helpers.ghostScriptTags(); + should.exist(rendered); + String(rendered).should.equal(''); + }); + }); + }); \ No newline at end of file