Merge pull request #1798 from ErisDS/cache-control

Cache control headers & query string asset management
This commit is contained in:
Hannah Wolfe 2014-01-02 12:20:47 -08:00
commit b955f13cc7
13 changed files with 570 additions and 158 deletions

View File

@ -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();

View File

@ -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('<script src="<%= source %>?v=<%= version %>"></script>'),
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('<script src="<%= source %>?v=<%= version %>"></script>'),
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);

View File

@ -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);

View File

@ -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

View File

@ -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);
});
},

View File

@ -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));
};

View File

@ -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});
}
});

View File

@ -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() {

View File

@ -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');
});

View File

@ -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();
// });
});
});

View File

@ -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 () {

View File

@ -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();
});

View File

@ -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(/<script src=".*\/shared\/vendor\/jquery\/jquery.js\?v=0.9"><\/script>/);
rendered.string.should.match(/<script src=".*\/shared\/vendor\/jquery\/jquery.js\?v=abc"><\/script>/);
done();
}).then(null, done);
@ -325,9 +330,9 @@ describe('Core Helpers', function () {
it('should output an absolute URL if the option is present', function () {
helpers.url.call(
{html: 'content', markdown: "ff", title: "title", slug: "slug", created_at: new Date(0)},
{hash: { absolute: 'true'}})
.then(function (rendered) {
{html: 'content', markdown: "ff", title: "title", slug: "slug", created_at: new Date(0)},
{hash: { absolute: 'true'}}
).then(function (rendered) {
should.exist(rendered);
rendered.should.equal('http://testurl.com/slug/');
});
@ -551,13 +556,13 @@ describe('Core Helpers', function () {
});
});
describe("meta_description helper", function (done) {
describe("meta_description helper", function () {
it('has loaded meta_description helper', function () {
should.exist(handlebars.helpers.meta_description);
});
it('can return blog description', function () {
it('can return blog description', function (done) {
helpers.meta_description.call({ghostRoot: '/'}).then(function (rendered) {
should.exist(rendered);
rendered.string.should.equal('Just a blogging platform.');
@ -578,4 +583,175 @@ describe('Core Helpers', function () {
});
describe("asset helper", function () {
var rendered,
configStub;
beforeEach(function () {
// set the asset hash
helpers.assetHash = 'abc';
});
afterEach(function () {
if (configStub) {
configStub.restore();
}
});
it('has loaded asset helper', function () {
should.exist(handlebars.helpers.asset);
});
it("handles favicon correctly", function () {
// with ghost set
rendered = helpers.asset('favicon.ico', {"hash": {ghost: 'true'}});
should.exist(rendered);
String(rendered).should.equal('/favicon.ico');
// without ghost set
rendered = helpers.asset('favicon.ico');
should.exist(rendered);
String(rendered).should.equal('/favicon.ico');
configStub = sinon.stub(config, 'paths', function () {
return {'subdir': '/blog'};
});
// with subdirectory
rendered = helpers.asset('favicon.ico', {"hash": {ghost: 'true'}});
should.exist(rendered);
String(rendered).should.equal('/blog/favicon.ico');
// without ghost set
rendered = helpers.asset('favicon.ico');
should.exist(rendered);
String(rendered).should.equal('/blog/favicon.ico');
});
it('handles shared assets correctly', function () {
// with ghost set
rendered = helpers.asset('shared/asset.js', {"hash": {ghost: 'true'}});
should.exist(rendered);
String(rendered).should.equal('/shared/asset.js?v=abc');
// without ghost set
rendered = helpers.asset('shared/asset.js');
should.exist(rendered);
String(rendered).should.equal('/shared/asset.js?v=abc');
configStub = sinon.stub(config, 'paths', function () {
return {'subdir': '/blog'};
});
// with subdirectory
rendered = helpers.asset('shared/asset.js', {"hash": {ghost: 'true'}});
should.exist(rendered);
String(rendered).should.equal('/blog/shared/asset.js?v=abc');
// without ghost set
rendered = helpers.asset('shared/asset.js');
should.exist(rendered);
String(rendered).should.equal('/blog/shared/asset.js?v=abc');
});
it('handles admin assets correctly', function () {
// with ghost set
rendered = helpers.asset('js/asset.js', {"hash": {ghost: 'true'}});
should.exist(rendered);
String(rendered).should.equal('/ghost/js/asset.js?v=abc');
configStub = sinon.stub(config, 'paths', function () {
return {'subdir': '/blog'};
});
// with subdirectory
rendered = helpers.asset('js/asset.js', {"hash": {ghost: 'true'}});
should.exist(rendered);
String(rendered).should.equal('/blog/ghost/js/asset.js?v=abc');
});
it('handles theme assets correctly', function () {
// with ghost set
rendered = helpers.asset('js/asset.js');
should.exist(rendered);
String(rendered).should.equal('/assets/js/asset.js?v=abc');
configStub = sinon.stub(config, 'paths', function () {
return {'subdir': '/blog'};
});
// with subdirectory
rendered = helpers.asset('js/asset.js');
should.exist(rendered);
String(rendered).should.equal('/blog/assets/js/asset.js?v=abc');
});
});
describe("ghostScriptTags helper", function () {
var rendered,
configStub;
beforeEach(function () {
// set the asset hash
helpers = rewire('../../server/helpers');
helpers.assetHash = 'abc';
});
afterEach(function () {
if (configStub) {
configStub.restore();
}
});
it('has loaded ghostScriptTags helper', function () {
should.exist(helpers.ghostScriptTags);
});
it('outputs correct scripts for development mode', function () {
rendered = helpers.ghostScriptTags();
should.exist(rendered);
String(rendered).should.equal(
'<script src="/ghost/scripts/vendor.js?v=abc"></script>' +
'<script src="/ghost/scripts/helpers.js?v=abc"></script>' +
'<script src="/ghost/scripts/templates.js?v=abc"></script>' +
'<script src="/ghost/scripts/models.js?v=abc"></script>' +
'<script src="/ghost/scripts/views.js?v=abc"></script>'
);
configStub = sinon.stub(config, 'paths', function () {
return {'subdir': '/blog'};
});
// with subdirectory
rendered = helpers.ghostScriptTags();
should.exist(rendered);
String(rendered).should.equal(
'<script src="/blog/ghost/scripts/vendor.js?v=abc"></script>' +
'<script src="/blog/ghost/scripts/helpers.js?v=abc"></script>' +
'<script src="/blog/ghost/scripts/templates.js?v=abc"></script>' +
'<script src="/blog/ghost/scripts/models.js?v=abc"></script>' +
'<script src="/blog/ghost/scripts/views.js?v=abc"></script>'
);
});
it('outputs correct scripts for production mode', function () {
helpers.__set__('isProduction', true);
rendered = helpers.ghostScriptTags();
should.exist(rendered);
String(rendered).should.equal('<script src="/ghost/scripts/ghost.min.js?v=abc"></script>');
configStub = sinon.stub(config, 'paths', function () {
return {'subdir': '/blog'};
});
// with subdirectory
rendered = helpers.ghostScriptTags();
should.exist(rendered);
String(rendered).should.equal('<script src="/blog/ghost/scripts/ghost.min.js?v=abc"></script>');
});
});
});