From 5b77f052d93494b39bf62e9ab5871625a69fc633 Mon Sep 17 00:00:00 2001 From: Katharina Irrgang Date: Tue, 9 Jan 2018 15:20:00 +0100 Subject: [PATCH] Update Notification improvements (#9123) closes #5071 - Remove hardcoded notification in admin controller - NOTE: update check notifications are no longer blocking the admin rendering - this is one of the most import changes - we remove the hardcoded release message - we also remove adding a notification manually in here, because this will work differently from now on -> you receive a notification (release or custom) in the update check module and this module adds the notification as is to our database - Change default core settings keys - remove displayUpdateNotification -> this was used to store the release version number send from the UCS -> based on this value, Ghost creates a notification container with self defined values -> not needed anymore - rename seenNotifications to notifications -> the new notifications key will hold both 1. the notification from the USC 2. the information about if a notification was seen or not - this key hold only one release notification - and n custom notifications - Update Check Module: Request to the USC depends on the privacy configuration - useUpdateCheck: true -> does a checkin in the USC (exposes data) - useUpdateCheck: false -> does only a GET query to the USC (does not expose any data) - make the request handling dynamic, so it depends on the flag - add an extra logic to be able to define a custom USC endpoint (helpful for testing) - add an extra logic to be able to force the request to the service (helpful for testing) - Update check module: re-work condition when a check should happen - only if the env is not correct - remove deprecated config.updateCheck - remove isPrivacyDisabled check (handled differently now, explained in last commit) - Update check module: remove `showUpdateNotification` and readability - showUpdateNotification was used in the admin controller to fetch the latest release version number from the db - no need to check against semver in general, the USC takes care of that (no need to double check) - improve readability of `nextUpdateCheck` condition - Update check module: refactor `updateCheckResponse` - remove db call to displayUpdateNotification, not used anymore - support receiving multiple custom notifications - support custom notification groups - the default group is `all` - this will always be consumed - groups can be extended via config e.g. `notificationGroups: ['migration']` - Update check module: refactor createCustomNotification helper - get rid of taking over notification duplication handling (this is not the task of the update check module) - ensure we have good fallback values for non present attributes in a notification - get rid of semver check (happens in the USC) - could be reconsidered later if LTS is gone - Refactor notification API - reason: get rid of in process notification store -> this was an object hold in process -> everything get's lost after restart -> not helpful anymore, because imagine the following case -> you get a notification -> you store it in process -> you mark this notification as seen -> you restart Ghost, you will receive the same notification on the next check again -> because we are no longer have a separate seen notifications object - use database settings key `notification` instead - refactor all api endpoints to support reading and storing into the `notifications` object - most important: notification deletion happens via a `seen` property (the notification get's physically deleted 3 month automatically) -> we have to remember a seen property, because otherwise you don't know which notification was already received/seen - Add listener to remove seen notifications automatically after 3 month - i just decided for 3 month (we can decrease?) - at the end it doesn't really matter, as long as the windows is not tooooo short - listen on updates for the notifications settings - check if notification was seen and is older than 3 month - ignore release notification - Updated our privacy document - Updated docs.ghost.org for privacy config behaviour - contains a migration script to remove old settings keys --- PRIVACY.md | 6 +- core/server/api/notifications.js | 235 ++++--- core/server/config/defaults.json | 4 + .../versions/1.20/1-remove-settings-keys.js | 67 ++ core/server/data/schema/default-settings.json | 5 +- core/server/models/base/listeners.js | 36 ++ core/server/translations/en.json | 3 - core/server/update-check.js | 218 ++++--- core/server/web/admin/controller.js | 44 +- .../integration/api/api_notifications_spec.js | 176 ++++- .../data/importer/importers/data_spec.js | 30 +- .../integration/model/base/listeners_spec.js | 68 +- core/test/integration/update_check_spec.js | 600 +++++++++++++++--- core/test/unit/api/notifications_spec.js | 35 - 14 files changed, 1161 insertions(+), 366 deletions(-) create mode 100644 core/server/data/migrations/versions/1.20/1-remove-settings-keys.js delete mode 100644 core/test/unit/api/notifications_spec.js diff --git a/PRIVACY.md b/PRIVACY.md index d2f701bb09..db97de7b88 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -11,9 +11,11 @@ Some official services for Ghost are enabled by default. These services connect ### Automatic Update Checks -When a new session is started, Ghost pings a Ghost.org endpoint to check if the current version of Ghost is the latest version of Ghost. If an update is available, a notification appears inside Ghost to let you know. Ghost.org collects basic anonymised usage statistics from update check requests. +When a new session is started, Ghost pings a Ghost.org service to check if the current version of Ghost is the latest version of Ghost. If an update is available, a notification on the About Page appears to let you know. -This service can be disabled at any time. All of the information and code related to this service is available in the [update-check.js](https://github.com/TryGhost/Ghost/blob/master/core/server/update-check.js) file. +Ghost will collect basic anonymised usage statistics from your blog before sending the request to the service. You can disable collecting statistics using the [privacy configuration](https://docs.ghost.org/v1/docs/config#section-update-check). You will still receive notifications from the service. + +All of the information and code related to this service is available in the [update-check.js](https://github.com/TryGhost/Ghost/blob/master/core/server/update-check.js) file. ## Third Party Services diff --git a/core/server/api/notifications.js b/core/server/api/notifications.js index 3ef662fde2..c50d8944c8 100644 --- a/core/server/api/notifications.js +++ b/core/server/api/notifications.js @@ -1,17 +1,48 @@ +'use strict'; + // # Notifications API // RESTful API for creating notifications -var Promise = require('bluebird'), + +const Promise = require('bluebird'), _ = require('lodash'), + moment = require('moment'), ObjectId = require('bson-objectid'), pipeline = require('../lib/promise/pipeline'), permissions = require('../services/permissions'), - canThis = permissions.canThis, localUtils = require('./utils'), common = require('../lib/common'), - settingsAPI = require('./settings'), - // Holds the persistent notifications - notificationsStore = [], - notifications; + SettingsAPI = require('./settings'), + internalContext = {context: {internal: true}}, + canThis = permissions.canThis; + +let notifications, + _private = {}; + +_private.fetchAllNotifications = function fetchAllNotifications() { + let allNotifications; + + return SettingsAPI.read(_.merge({key: 'notifications'}, internalContext)) + .then(function (response) { + allNotifications = JSON.parse(response.settings[0].value || []); + + _.each(allNotifications, function (notification) { + notification.addedAt = moment(notification.addedAt).toDate(); + }); + + return allNotifications; + }); +}; + +_private.publicResponse = function publicResponse(notificationsToReturn) { + _.each(notificationsToReturn, function (notification) { + delete notification.seen; + delete notification.addedAt; + }); + + return { + notifications: notificationsToReturn + }; +}; /** * ## Notification API Methods @@ -27,9 +58,20 @@ notifications = { */ browse: function browse(options) { return canThis(options.context).browse.notification().then(function () { - return {notifications: notificationsStore}; + return _private.fetchAllNotifications() + .then(function (allNotifications) { + allNotifications = _.orderBy(allNotifications, 'addedAt', 'desc'); + + allNotifications = allNotifications.filter(function (notification) { + return notification.seen !== true; + }); + + return _private.publicResponse(allNotifications); + }); }, function () { - return Promise.reject(new common.errors.NoPermissionError({message: common.i18n.t('errors.api.notifications.noPermissionToBrowseNotif')})); + return Promise.reject(new common.errors.NoPermissionError({ + message: common.i18n.t('errors.api.notifications.noPermissionToBrowseNotif') + })); }); }, @@ -69,7 +111,9 @@ notifications = { return canThis(options.context).add.notification().then(function () { return options; }, function () { - return Promise.reject(new common.errors.NoPermissionError({message: common.i18n.t('errors.api.notifications.noPermissionToAddNotif')})); + return Promise.reject(new common.errors.NoPermissionError({ + message: common.i18n.t('errors.api.notifications.noPermissionToAddNotif') + })); }); } @@ -80,31 +124,61 @@ notifications = { * @returns {Object} options */ function saveNotifications(options) { - var defaults = { + let defaults = { dismissible: true, location: 'bottom', - status: 'alert' - }, - addedNotifications = [], existingNotification; - - _.each(options.data.notifications, function (notification) { - notification = _.assign(defaults, notification, { + status: 'alert', id: ObjectId.generate() + }, + overrides = { + seen: false, + addedAt: moment().toDate() + }, + notificationsToCheck = options.data.notifications, + addedNotifications = []; + + return _private.fetchAllNotifications() + .then(function (allNotifications) { + _.each(notificationsToCheck, function (notification) { + let isDuplicate = _.find(allNotifications, {id: notification.id}); + + if (!isDuplicate) { + addedNotifications.push(_.merge({}, defaults, notification, overrides)); + } + }); + + let hasReleaseNotification = _.find(notificationsToCheck, {custom: false}); + + // CASE: remove any existing release notifications if a new release notification comes in + if (hasReleaseNotification) { + _.remove(allNotifications, function (el) { + return !el.custom; + }); + } + + // CASE: nothing to add, skip + if (!addedNotifications.length) { + return Promise.resolve(); + } + + let addedReleaseNotifications = _.filter(addedNotifications, {custom: false}); + + // CASE: only latest release notification + if (addedReleaseNotifications.length > 1) { + addedNotifications = _.filter(addedNotifications, {custom: true}); + addedNotifications.push(_.orderBy(addedReleaseNotifications, 'created_at', 'desc')[0]); + } + + return SettingsAPI.edit({ + settings: [{ + key: 'notifications', + value: allNotifications.concat(addedNotifications) + }] + }, internalContext); + }) + .then(function () { + return _private.publicResponse(addedNotifications); }); - - existingNotification = _.find(notificationsStore, {message: notification.message}); - - if (!existingNotification) { - notificationsStore.push(notification); - addedNotifications.push(notification); - } else { - addedNotifications.push(existingNotification); - } - }); - - return { - notifications: addedNotifications - }; } tasks = [ @@ -124,26 +198,7 @@ notifications = { * @returns {Promise} */ destroy: function destroy(options) { - var tasks; - - /** - * Adds the id of notification to "seen_notifications" array. - * @param {Object} notification - * @return {*|Promise} - */ - function markAsSeen(notification) { - var context = {internal: true}; - return settingsAPI.read({key: 'seen_notifications', context: context}).then(function then(response) { - var seenNotifications = JSON.parse(response.settings[0].value); - seenNotifications = _.uniqBy(seenNotifications.concat([notification.id])); - return settingsAPI.edit({ - settings: [{ - key: 'seen_notifications', - value: seenNotifications - }] - }, {context: context}); - }); - } + let tasks; /** * ### Handle Permissions @@ -155,36 +210,47 @@ notifications = { return canThis(options.context).destroy.notification().then(function () { return options; }, function () { - return Promise.reject(new common.errors.NoPermissionError({message: common.i18n.t('errors.api.notifications.noPermissionToDestroyNotif')})); + return Promise.reject(new common.errors.NoPermissionError({ + message: common.i18n.t('errors.api.notifications.noPermissionToDestroyNotif') + })); }); } function destroyNotification(options) { - var notification = _.find(notificationsStore, function (element) { - return element.id === options.id; - }); + return _private.fetchAllNotifications() + .then(function (allNotifications) { + let notificationToMarkAsSeen = _.find(allNotifications, {id: options.id}), + notificationToMarkAsSeenIndex = _.findIndex(allNotifications, {id: options.id}); - if (notification && !notification.dismissible) { - return Promise.reject( - new common.errors.NoPermissionError({message: common.i18n.t('errors.api.notifications.noPermissionToDismissNotif')}) - ); - } + if (notificationToMarkAsSeenIndex > -1 && !notificationToMarkAsSeen.dismissible) { + return Promise.reject(new common.errors.NoPermissionError({ + message: common.i18n.t('errors.api.notifications.noPermissionToDismissNotif') + })); + } - if (!notification) { - return Promise.reject(new common.errors.NotFoundError({message: common.i18n.t('errors.api.notifications.notificationDoesNotExist')})); - } + if (notificationToMarkAsSeenIndex < 0) { + return Promise.reject(new common.errors.NotFoundError({ + message: common.i18n.t('errors.api.notifications.notificationDoesNotExist') + })); + } - notificationsStore = _.reject(notificationsStore, function (element) { - return element.id === options.id; - }); + if (notificationToMarkAsSeen.seen) { + return Promise.resolve(); + } - if (notification.custom) { - return markAsSeen(notification); - } + allNotifications[notificationToMarkAsSeenIndex].seen = true; + + return SettingsAPI.edit({ + settings: [{ + key: 'notifications', + value: allNotifications + }] + }, internalContext); + }) + .return(); } tasks = [ - localUtils.validate('notifications', {opts: localUtils.idDefaultOptions}), handlePermissions, destroyNotification ]; @@ -200,15 +266,28 @@ notifications = { * @returns {Promise} */ destroyAll: function destroyAll(options) { - return canThis(options.context).destroy.notification().then(function () { - notificationsStore = []; - return notificationsStore; - }, function (err) { - return Promise.reject(new common.errors.NoPermissionError({ - err: err, - context: common.i18n.t('errors.api.notifications.noPermissionToDestroyNotif') - })); - }); + return canThis(options.context).destroy.notification() + .then(function () { + return _private.fetchAllNotifications() + .then(function (allNotifications) { + _.each(allNotifications, function (notification) { + notification.seen = true; + }); + + return SettingsAPI.edit({ + settings: [{ + key: 'notifications', + value: allNotifications + }] + }, internalContext); + }) + .return(); + }, function (err) { + return Promise.reject(new common.errors.NoPermissionError({ + err: err, + context: common.i18n.t('errors.api.notifications.noPermissionToDestroyNotif') + })); + }); } }; diff --git a/core/server/config/defaults.json b/core/server/config/defaults.json index e2ca640559..fb7305ecb8 100644 --- a/core/server/config/defaults.json +++ b/core/server/config/defaults.json @@ -4,6 +4,10 @@ "host": "127.0.0.1", "port": 2368 }, + "updateCheck": { + "url": "https://updates.ghost.org", + "forceUpdate": false + }, "privacy": false, "useMinFiles": true, "paths": { diff --git a/core/server/data/migrations/versions/1.20/1-remove-settings-keys.js b/core/server/data/migrations/versions/1.20/1-remove-settings-keys.js new file mode 100644 index 0000000000..aed7370b80 --- /dev/null +++ b/core/server/data/migrations/versions/1.20/1-remove-settings-keys.js @@ -0,0 +1,67 @@ +'use strict'; + +const _ = require('lodash'), + models = require('../../../../models'), + common = require('../../../../lib/common'); + +module.exports.config = { + transaction: true +}; + +module.exports.up = function removeSettingKeys(options) { + let localOptions = _.merge({ + context: {internal: true} + }, options); + + return models.Settings.findOne({key: 'display_update_notification'}, localOptions) + .then(function (settingsModel) { + if (!settingsModel) { + common.logging.warn('Deleted Settings Key `display_update_notification`.'); + return; + } + + common.logging.info('Deleted Settings Key `display_update_notification`.'); + return models.Settings.destroy({id: settingsModel.id}, localOptions); + }) + .then(function () { + return models.Settings.findOne({key: 'seen_notifications'}, localOptions); + }) + .then(function (settingsModel) { + if (!settingsModel) { + common.logging.warn('Deleted Settings Key `seen_notifications`.'); + return; + } + + common.logging.info('Deleted Settings Key `seen_notifications`.'); + return models.Settings.destroy({id: settingsModel.id}, localOptions); + }); +}; + +module.exports.down = function addSettingsKeys(options) { + let localOptions = _.merge({ + context: {internal: true} + }, options); + + return models.Settings.findOne({key: 'display_update_notification'}, localOptions) + .then(function (settingsModel) { + if (settingsModel) { + common.logging.warn('Added Settings Key `display_update_notification`.'); + return; + } + + common.logging.info('Added Settings Key `display_update_notification`.'); + return models.Settings.forge({key: 'display_update_notification'}).save(null, localOptions); + }) + .then(function () { + return models.Settings.findOne({key: 'seen_notifications'}, localOptions); + }) + .then(function (settingsModel) { + if (settingsModel) { + common.logging.warn('Added Settings Key `seen_notifications`.'); + return; + } + + common.logging.info('Added Settings Key `seen_notifications`.'); + return models.Settings.forge({key: 'seen_notifications', value: '[]'}).save([], localOptions); + }); +}; diff --git a/core/server/data/schema/default-settings.json b/core/server/data/schema/default-settings.json index e04b3d6027..a9ccab3c02 100644 --- a/core/server/data/schema/default-settings.json +++ b/core/server/data/schema/default-settings.json @@ -6,10 +6,7 @@ "next_update_check": { "defaultValue": null }, - "display_update_notification": { - "defaultValue": null - }, - "seen_notifications": { + "notifications": { "defaultValue": "[]" } }, diff --git a/core/server/models/base/listeners.js b/core/server/models/base/listeners.js index 932dfd9fa0..d9c1263940 100644 --- a/core/server/models/base/listeners.js +++ b/core/server/models/base/listeners.js @@ -120,3 +120,39 @@ common.events.on('settings.active_timezone.edited', function (settingModel, opti }); }); }); + +/** + * Remove all notifications, which are seen, older than 3 months. + * No transaction, because notifications are not sensitive and we would have to add `forUpdate` + * to the settings model to create real lock. + */ +common.events.on('settings.notifications.edited', function (settingModel) { + var allNotifications = JSON.parse(settingModel.attributes.value || []), + options = {context: {internal: true}}, + skip = true; + + allNotifications = allNotifications.filter(function (notification) { + // Do not delete the release notification + if (notification.hasOwnProperty('custom') && !notification.custom) { + return true; + } + + if (notification.seen && moment().diff(moment(notification.addedAt), 'month') > 2) { + skip = false; + return false; + } + + return true; + }); + + if (skip) { + return; + } + + return models.Settings.edit({ + key: 'notifications', + value: JSON.stringify(allNotifications) + }, options).catch(function (err) { + common.errors.logError(err); + }); +}); diff --git a/core/server/translations/en.json b/core/server/translations/en.json index cf985b88c0..a4240465c4 100644 --- a/core/server/translations/en.json +++ b/core/server/translations/en.json @@ -544,9 +544,6 @@ } }, "notices": { - "controllers": { - "newVersionAvailable": "Ghost {version} is available! Hot Damn. {link} to upgrade." - }, "index": { "welcomeToGhost": "Welcome to Ghost.", "youAreRunningUnderEnvironment": "You're running under the {environment} environment.", diff --git a/core/server/update-check.js b/core/server/update-check.js index fa3326d2f4..1dd109e0ad 100644 --- a/core/server/update-check.js +++ b/core/server/update-check.js @@ -1,3 +1,5 @@ +'use strict'; + // # Update Checking Service // // Makes a request to Ghost.org to check if there is a new version of Ghost available. @@ -20,31 +22,34 @@ // - theme - name of the currently active theme // - apps - names of any active apps -var crypto = require('crypto'), +const crypto = require('crypto'), exec = require('child_process').exec, moment = require('moment'), - semver = require('semver'), Promise = require('bluebird'), _ = require('lodash'), url = require('url'), + debug = require('ghost-ignition').debug('update-check'), api = require('./api'), config = require('./config'), urlService = require('./services/url'), common = require('./lib/common'), request = require('./lib/request'), - currentVersion = require('./lib/ghost-version').full, + ghostVersion = require('./lib/ghost-version'), internal = {context: {internal: true}}, - checkEndpoint = config.get('updateCheckUrl') || 'https://updates.ghost.org'; + allowedCheckEnvironments = ['development', 'production']; + +function nextCheckTimestamp() { + var now = Math.round(new Date().getTime() / 1000); + return now + (24 * 3600); +} function updateCheckError(err) { - if (err.response && err.response.body && typeof err.response.body === 'object') { - err = common.errors.utils.deserialize(err.response.body); - } - - api.settings.edit( - {settings: [{key: 'next_update_check', value: Math.round(Date.now() / 1000 + 24 * 3600)}]}, - internal - ); + api.settings.edit({ + settings: [{ + key: 'next_update_check', + value: nextCheckTimestamp() + }] + }, internal); err.context = common.i18n.t('errors.updateCheck.checkingForUpdatesFailed.error'); err.help = common.i18n.t('errors.updateCheck.checkingForUpdatesFailed.help', {url: 'https://docs.ghost.org/v1'}); @@ -53,40 +58,36 @@ function updateCheckError(err) { /** * If the custom message is intended for current version, create and store a custom notification. - * @param {Object} message {id: uuid, version: '0.9.x', content: '' } + * @param {Object} notification * @return {*|Promise} */ -function createCustomNotification(message) { - if (!semver.satisfies(currentVersion, message.version)) { +function createCustomNotification(notification) { + if (!notification) { return Promise.resolve(); } - var notification = { - status: 'alert', - type: 'info', - custom: true, - uuid: message.id, - dismissible: true, + return Promise.each(notification.messages, function (message) { + let toAdd = { + custom: !!notification.custom, + createdAt: moment(notification.created_at).toDate(), + status: message.status || 'alert', + type: message.type || 'info', + id: message.id, + dismissible: message.hasOwnProperty('dismissible') ? message.dismissible : true, + top: !!message.top, message: message.content - }, - getAllNotifications = api.notifications.browse({context: {internal: true}}), - getSeenNotifications = api.settings.read(_.extend({key: 'seen_notifications'}, internal)); + }; - return Promise.join(getAllNotifications, getSeenNotifications, function joined(all, seen) { - var isSeen = _.includes(JSON.parse(seen.settings[0].value || []), notification.id), - isDuplicate = _.some(all.notifications, {message: notification.message}); - - if (!isSeen && !isDuplicate) { - return api.notifications.add({notifications: [notification]}, {context: {internal: true}}); - } + debug('Add Custom Notification', toAdd); + return api.notifications.add({notifications: [toAdd]}, {context: {internal: true}}); }); } function updateCheckData() { - var data = {}, + let data = {}, mailConfig = config.get('mail'); - data.ghost_version = currentVersion; + data.ghost_version = ghostVersion.original; data.node_version = process.versions.node; data.env = config.get('env'); data.database_type = config.get('database').client; @@ -134,19 +135,53 @@ function updateCheckData() { }).catch(updateCheckError); } +/** + * With the privacy setting `useUpdateCheck` you can control if you want to expose data from your blog to the + * Update Check Service. Enabled or disabled, you will receive the latest notification available from the service. + */ function updateCheckRequest() { return updateCheckData() .then(function then(reqData) { - return request(checkEndpoint, { - json: true, - body: reqData, - headers: { - 'Content-Length': Buffer.byteLength(JSON.stringify(reqData)) + let reqObj = { + timeout: 1000, + headers: {} }, - timeout: 1000 - }).then(function (response) { - return response.body; - }); + checkEndpoint = config.get('updateCheck:url'), + checkMethod = config.isPrivacyDisabled('useUpdateCheck') ? 'GET' : 'POST'; + + if (checkMethod === 'POST') { + reqObj.json = true; + reqObj.body = reqData; + reqObj.headers['Content-Length'] = Buffer.byteLength(JSON.stringify(reqData)); + reqObj.headers['Content-Type'] = 'application/json'; + } else { + reqObj.json = true; + reqObj.query = { + ghost_version: reqData.ghost_version + }; + } + + debug('Request Update Check Service', checkEndpoint); + + return request(checkEndpoint, reqObj) + .then(function (response) { + return response.body; + }) + .catch(function (err) { + // CASE: no notifications available, ignore + if (err.statusCode === 404) { + return { + next_check: nextCheckTimestamp(), + notifications: [] + }; + } + + if (err.response && err.response.body && typeof err.response.body === 'object') { + err = common.errors.utils.deserialize(err.response.body); + } + + throw err; + }); }); } @@ -154,65 +189,84 @@ function updateCheckRequest() { * Handles the response from the update check * Does three things with the information received: * 1. Updates the time we can next make a check - * 2. Checks if the version in the response is new, and updates the notification setting - * 3. Create custom notifications is response from UpdateCheck as "messages" array which has the following structure: + * 2. Create custom notifications is response from UpdateCheck as "messages" array which has the following structure: * * "messages": [{ * "id": ed9dc38c-73e5-4d72-a741-22b11f6e151a, * "version": "0.5.x", - * "content": "

Hey there! 0.6 is available, visit Ghost.org to grab your copy now" + * "content": "

Hey there! 0.6 is available, visit Ghost.org to grab your copy now", + * "dismissible": true | false, + * "top": true | false * ]} * + * Example for grouped custom notifications in config: + * + * notificationGroups: ['migration', 'something'] + * + * 'all' is a reserved name for general custom notifications. + * * @param {Object} response * @return {Promise} */ function updateCheckResponse(response) { - return Promise.all([ - api.settings.edit({settings: [{key: 'next_update_check', value: response.next_check}]}, internal), - api.settings.edit({settings: [{key: 'display_update_notification', value: response.version}]}, internal) - ]).then(function () { - var messages = response.messages || []; + let notifications = [], + notificationGroups = (config.get('notificationGroups') || []).concat(['all']); - /** - * by default the update check service returns messages: [] - * but the latest release version get's stored anyway, because we adding the `display_update_notification` ^ - */ - return Promise.map(messages, createCustomNotification); - }); + debug('Notification Groups', notificationGroups); + debug('Response Update Check Service', response); + + return api.settings.edit({settings: [{key: 'next_update_check', value: response.next_check}]}, internal) + .then(function () { + // CASE: Update Check Service returns multiple notifications. + if (_.isArray(response)) { + notifications = response; + } else if ((response.hasOwnProperty('notifications') && _.isArray(response.notifications))) { + notifications = response.notifications; + } else { + notifications = [response]; + } + + // CASE: Hook into received notifications and decide whether you are allowed to receive custom group messages. + if (notificationGroups.length) { + notifications = notifications.filter(function (notification) { + if (!notification.custom) { + return true; + } + + return _.includes(notificationGroups.map(function (groupIdentifier) { + if (notification.version.match(new RegExp(groupIdentifier))) { + return true; + } + + return false; + }), true) === true; + }); + } + + return Promise.each(notifications, createCustomNotification); + }); } function updateCheck() { - if (config.isPrivacyDisabled('useUpdateCheck')) { + // CASE: The check will not happen if your NODE_ENV is not in the allowed defined environments. + if (_.indexOf(allowedCheckEnvironments, process.env.NODE_ENV) === -1) { return Promise.resolve(); - } else { - return api.settings.read(_.extend({key: 'next_update_check'}, internal)).then(function then(result) { + } + + return api.settings.read(_.extend({key: 'next_update_check'}, internal)) + .then(function then(result) { var nextUpdateCheck = result.settings[0]; - if (nextUpdateCheck && nextUpdateCheck.value && nextUpdateCheck.value > moment().unix()) { - // It's not time to check yet - return; // eslint-disable-line no-useless-return - } else { - // We need to do a check - return updateCheckRequest() - .then(updateCheckResponse) - .catch(updateCheckError); + // CASE: Next update check should happen now? + if (!config.get('updateCheck:forceUpdate') && nextUpdateCheck && nextUpdateCheck.value && nextUpdateCheck.value > moment().unix()) { + return Promise.resolve(); } - }).catch(updateCheckError); - } -} -function showUpdateNotification() { - return api.settings.read(_.extend({key: 'display_update_notification'}, internal)).then(function then(response) { - var display = response.settings[0]; - - // @TODO: We only show minor/major releases. This is a temporary fix. #5071 is coming soon. - if (display && display.value && currentVersion && semver.gt(display.value, currentVersion) && semver.patch(display.value) === 0) { - return display.value; - } - - return false; - }); + return updateCheckRequest() + .then(updateCheckResponse) + .catch(updateCheckError); + }) + .catch(updateCheckError); } module.exports = updateCheck; -module.exports.showUpdateNotification = showUpdateNotification; diff --git a/core/server/web/admin/controller.js b/core/server/web/admin/controller.js index ef1bc9a21e..852fcf10ec 100644 --- a/core/server/web/admin/controller.js +++ b/core/server/web/admin/controller.js @@ -1,8 +1,8 @@ -var debug = require('ghost-ignition').debug('admin:controller'), - _ = require('lodash'), +'use strict'; + +const debug = require('ghost-ignition').debug('admin:controller'), path = require('path'), config = require('../../config'), - api = require('../../api'), updateCheck = require('../../update-check'), common = require('../../lib/common'); @@ -12,36 +12,14 @@ var debug = require('ghost-ignition').debug('admin:controller'), module.exports = function adminController(req, res) { debug('index called'); - updateCheck().then(function then() { - return updateCheck.showUpdateNotification(); - }).then(function then(updateVersion) { - if (!updateVersion) { - return; - } - - var notification = { - status: 'alert', - type: 'info', - location: 'upgrade.new-version-available', - dismissible: false, - message: common.i18n.t('notices.controllers.newVersionAvailable', - { - version: updateVersion, - link: 'Click here' - }) - }; - - return api.notifications.browse({context: {internal: true}}).then(function then(results) { - if (!_.some(results.notifications, {message: notification.message})) { - return api.notifications.add({notifications: [notification]}, {context: {internal: true}}); - } + // run in background, don't block the admin rendering + updateCheck() + .catch(function onError(err) { + common.logging.error(err); }); - }).finally(function noMatterWhat() { - var defaultTemplate = config.get('env') === 'production' ? 'default-prod.html' : 'default.html', - templatePath = path.resolve(config.get('paths').adminViews, defaultTemplate); - res.sendFile(templatePath); - }).catch(function (err) { - common.logging.error(err); - }); + let defaultTemplate = config.get('env') === 'production' ? 'default-prod.html' : 'default.html', + templatePath = path.resolve(config.get('paths').adminViews, defaultTemplate); + + res.sendFile(templatePath); }; diff --git a/core/test/integration/api/api_notifications_spec.js b/core/test/integration/api/api_notifications_spec.js index 07bfb4fa2e..38bcf506a2 100644 --- a/core/test/integration/api/api_notifications_spec.js +++ b/core/test/integration/api/api_notifications_spec.js @@ -1,9 +1,9 @@ var should = require('should'), - testUtils = require('../../utils'), _ = require('lodash'), + uuid = require('uuid'), ObjectId = require('bson-objectid'), - NotificationsAPI = require('../../../server/api/notifications'), - SettingsAPI = require('../../../server/api/settings'); + testUtils = require('../../utils'), + NotificationsAPI = require('../../../server/api/notifications'); describe('Notifications API', function () { // Keep the DB clean @@ -13,10 +13,6 @@ describe('Notifications API', function () { should.exist(NotificationsAPI); - after(function () { - return NotificationsAPI.destroyAll(testUtils.context.internal); - }); - it('can add, adds defaults (internal)', function (done) { var msg = { type: 'info', @@ -54,6 +50,7 @@ describe('Notifications API', function () { notification.dismissible.should.be.true(); should.exist(notification.location); notification.location.should.equal('bottom'); + notification.id.should.be.a.String(); done(); }).catch(done); @@ -74,7 +71,7 @@ describe('Notifications API', function () { should.exist(result.notifications); notification = result.notifications[0]; - notification.id.should.not.equal(msg.id); + notification.id.should.be.a.String(); should.exist(notification.status); notification.status.should.equal('alert'); @@ -82,6 +79,32 @@ describe('Notifications API', function () { }).catch(done); }); + it('duplicates', function (done) { + var customNotification1 = { + status: 'alert', + type: 'info', + location: 'test.to-be-deleted1', + custom: true, + id: uuid.v1(), + dismissible: true, + message: 'Hello, this is dog number 1' + }; + + NotificationsAPI + .add({notifications: [customNotification1]}, testUtils.context.internal) + .then(function () { + return NotificationsAPI.add({notifications: [customNotification1]}, testUtils.context.internal); + }) + .then(function () { + return NotificationsAPI.browse(testUtils.context.internal); + }) + .then(function (response) { + response.notifications.length.should.eql(1); + done(); + }) + .catch(done); + }); + it('can browse (internal)', function (done) { var msg = { type: 'error', // this can be 'error', 'success', 'warn' and 'info' @@ -114,6 +137,40 @@ describe('Notifications API', function () { }); }); + it('receive correct order', function (done) { + var customNotification1 = { + status: 'alert', + type: 'info', + custom: true, + id: uuid.v1(), + dismissible: true, + message: '1' + }, customNotification2 = { + status: 'alert', + type: 'info', + custom: true, + id: uuid.v1(), + dismissible: true, + message: '2' + }; + + NotificationsAPI + .add({notifications: [customNotification1]}, testUtils.context.internal) + .then(function () { + return NotificationsAPI.add({notifications: [customNotification2]}, testUtils.context.internal); + }) + .then(function () { + return NotificationsAPI.browse(testUtils.context.internal); + }) + .then(function (response) { + response.notifications.length.should.eql(2); + response.notifications[0].message.should.eql('2'); + response.notifications[1].message.should.eql('1'); + done(); + }) + .catch(done); + }); + it('can destroy (internal)', function (done) { var msg = { type: 'error', @@ -123,13 +180,13 @@ describe('Notifications API', function () { NotificationsAPI.add({notifications: [msg]}, testUtils.context.internal).then(function (result) { var notification = result.notifications[0]; - NotificationsAPI.destroy( - _.extend({}, testUtils.context.internal, {id: notification.id}) - ).then(function (result) { - should.not.exist(result); - - done(); - }).catch(done); + NotificationsAPI + .destroy(_.extend({}, testUtils.context.internal, {id: notification.id})) + .then(function (result) { + should.not.exist(result); + done(); + }) + .catch(done); }); }); @@ -142,22 +199,23 @@ describe('Notifications API', function () { NotificationsAPI.add({notifications: [msg]}, testUtils.context.internal).then(function (result) { var notification = result.notifications[0]; - NotificationsAPI.destroy( - _.extend({}, testUtils.context.owner, {id: notification.id}) - ).then(function (result) { - should.not.exist(result); - - done(); - }).catch(done); + NotificationsAPI + .destroy(_.extend({}, testUtils.context.owner, {id: notification.id})) + .then(function (result) { + should.not.exist(result); + done(); + }) + .catch(done); }); }); - it('can destroy a custom notification and add its uuid to seen_notifications (owner)', function (done) { + it('ensure notification get\'s removed', function (done) { var customNotification = { status: 'alert', type: 'info', location: 'test.to-be-deleted', custom: true, + id: uuid.v1(), dismissible: true, message: 'Hello, this is dog number 4' }; @@ -165,16 +223,68 @@ describe('Notifications API', function () { NotificationsAPI.add({notifications: [customNotification]}, testUtils.context.internal).then(function (result) { var notification = result.notifications[0]; - NotificationsAPI.destroy( - _.extend({}, testUtils.context.internal, {id: notification.id}) - ).then(function () { - return SettingsAPI.read(_.extend({key: 'seen_notifications'}, testUtils.context.internal)); - }).then(function (response) { - should.exist(response); - response.settings[0].value.should.containEql(notification.id); - - done(); - }).catch(done); + return NotificationsAPI.browse(testUtils.context.internal) + .then(function (response) { + response.notifications.length.should.eql(1); + return NotificationsAPI.destroy(_.extend({}, testUtils.context.internal, {id: notification.id})); + }) + .then(function () { + return NotificationsAPI.browse(testUtils.context.internal); + }) + .then(function (response) { + response.notifications.length.should.eql(0); + done(); + }) + .catch(done); }); }); + + it('destroy unknown id', function (done) { + NotificationsAPI + .destroy(_.extend({}, testUtils.context.internal, {id: 1})) + .then(function () { + done(new Error('Expected notification error.')); + }) + .catch(function (err) { + err.statusCode.should.eql(404); + done(); + }); + }); + + it('destroy all', function (done) { + var customNotification1 = { + status: 'alert', + type: 'info', + location: 'test.to-be-deleted1', + custom: true, + id: uuid.v1(), + dismissible: true, + message: 'Hello, this is dog number 1' + }, customNotification2 = { + status: 'alert', + type: 'info', + location: 'test.to-be-deleted2', + custom: true, + id: uuid.v1(), + dismissible: true, + message: 'Hello, this is dog number 2' + }; + + NotificationsAPI + .add({notifications: [customNotification1]}, testUtils.context.internal) + .then(function () { + return NotificationsAPI.add({notifications: [customNotification2]}, testUtils.context.internal); + }) + .then(function () { + return NotificationsAPI.destroyAll(testUtils.context.internal); + }) + .then(function () { + return NotificationsAPI.browse(testUtils.context.internal); + }) + .then(function (response) { + response.notifications.length.should.eql(0); + done(); + }) + .catch(done); + }); }); diff --git a/core/test/integration/data/importer/importers/data_spec.js b/core/test/integration/data/importer/importers/data_spec.js index d71ada0744..19fb53f3b1 100644 --- a/core/test/integration/data/importer/importers/data_spec.js +++ b/core/test/integration/data/importer/importers/data_spec.js @@ -1517,19 +1517,21 @@ describe('Import (new test structure)', function () { users[1].profile_image.should.eql(exportData.data.users[0].image); // Check feature image is correctly mapped for a tag tags[0].feature_image.should.eql(exportData.data.tags[0].image); + // Check logo image is correctly mapped for a blog - settings[6].key.should.eql('logo'); - settings[6].value.should.eql('/content/images/2017/05/bloglogo.jpeg'); + settings[5].key.should.eql('logo'); + settings[5].value.should.eql('/content/images/2017/05/bloglogo.jpeg'); + // Check cover image is correctly mapped for a blog - settings[7].key.should.eql('cover_image'); - settings[7].value.should.eql('/content/images/2017/05/blogcover.jpeg'); + settings[6].key.should.eql('cover_image'); + settings[6].value.should.eql('/content/images/2017/05/blogcover.jpeg'); // Check default settings locale is not overwritten by defaultLang - settings[9].key.should.eql('default_locale'); - settings[9].value.should.eql('en'); + settings[8].key.should.eql('default_locale'); + settings[8].value.should.eql('en'); - settings[18].key.should.eql('labs'); - settings[18].value.should.eql('{"publicAPI":true}'); + settings[17].key.should.eql('labs'); + settings[17].value.should.eql('{"publicAPI":true}'); // Check post language is null should(firstPost.locale).equal(null); @@ -1600,15 +1602,15 @@ describe('Import (new test structure)', function () { // Check feature image is correctly mapped for a tag tags[0].feature_image.should.eql(exportData.data.tags[0].image); // Check logo image is correctly mapped for a blog - settings[6].key.should.eql('logo'); - settings[6].value.should.eql('/content/images/2017/05/bloglogo.jpeg'); + settings[5].key.should.eql('logo'); + settings[5].value.should.eql('/content/images/2017/05/bloglogo.jpeg'); // Check cover image is correctly mapped for a blog - settings[7].key.should.eql('cover_image'); - settings[7].value.should.eql('/content/images/2017/05/blogcover.jpeg'); + settings[6].key.should.eql('cover_image'); + settings[6].value.should.eql('/content/images/2017/05/blogcover.jpeg'); // Check default settings locale is not overwritten by defaultLang - settings[9].key.should.eql('default_locale'); - settings[9].value.should.eql('en'); + settings[8].key.should.eql('default_locale'); + settings[8].value.should.eql('en'); // Check post language is set to null should(firstPost.locale).equal(null); diff --git a/core/test/integration/model/base/listeners_spec.js b/core/test/integration/model/base/listeners_spec.js index 103b57a5fc..0614b48f88 100644 --- a/core/test/integration/model/base/listeners_spec.js +++ b/core/test/integration/model/base/listeners_spec.js @@ -22,7 +22,7 @@ describe('Models: listeners', function () { }; before(testUtils.teardown); - beforeEach(testUtils.setup('owner', 'user-token:0')); + beforeEach(testUtils.setup('owner', 'user-token:0', 'settings')); beforeEach(function () { sandbox.stub(common.events, 'on').callsFake(function (eventName, callback) { @@ -361,4 +361,70 @@ describe('Models: listeners', function () { })(); }); }); + + describe('on notifications changed', function () { + it('nothing to delete', function (done) { + var notifications = JSON.stringify([ + { + addedAt: moment().subtract(1, 'week').format(), + seen: true + }, + { + addedAt: moment().subtract(2, 'month').format(), + seen: true + }, + { + addedAt: moment().subtract(1, 'day').format(), + seen: false + } + ]); + + models.Settings.edit({key: 'notifications', value: notifications}, testUtils.context.internal) + .then(function () { + eventsToRemember['settings.notifications.edited']({ + attributes: { + value: notifications + } + }); + + return models.Settings.findOne({key: 'notifications'}, testUtils.context.internal); + }).then(function (model) { + JSON.parse(model.get('value')).length.should.eql(3); + done(); + }).catch(done); + }); + + it('expect deletion', function (done) { + var notifications = JSON.stringify([ + { + content: 'keep-1', + addedAt: moment().subtract(1, 'week').toDate(), + seen: true + }, + { + content: 'delete-me', + addedAt: moment().subtract(3, 'month').toDate(), + seen: true + }, + { + content: 'keep-2', + addedAt: moment().subtract(1, 'day').toDate(), + seen: false + } + ]); + + models.Settings.edit({key: 'notifications', value: notifications}, testUtils.context.internal) + .then(function () { + setTimeout(function () { + return models.Settings.findOne({key: 'notifications'}, testUtils.context.internal) + .then(function (model) { + JSON.parse(model.get('value')).length.should.eql(2); + done(); + }) + .catch(done); + }, 1000); + }) + .catch(done); + }); + }); }); diff --git a/core/test/integration/update_check_spec.js b/core/test/integration/update_check_spec.js index 6d8e2fe1ed..889d08c815 100644 --- a/core/test/integration/update_check_spec.js +++ b/core/test/integration/update_check_spec.js @@ -1,20 +1,102 @@ -var should = require('should'), - _ = require('lodash'), +var _ = require('lodash'), + Promise = require('bluebird'), + should = require('should'), rewire = require('rewire'), + sinon = require('sinon'), + moment = require('moment'), uuid = require('uuid'), testUtils = require('../utils'), configUtils = require('../utils/configUtils'), packageInfo = require('../../../package'), updateCheck = rewire('../../server/update-check'), - settingsCache = require('../../server/services/settings/cache'), - NotificationsAPI = require('../../server/api/notifications'); + SettingsAPI = require('../../server/api/settings'), + NotificationsAPI = require('../../server/api/notifications'), + sandbox = sinon.sandbox.create(); describe('Update Check', function () { - after(function () { - return NotificationsAPI.destroyAll(testUtils.context.internal); + beforeEach(function () { + updateCheck = rewire('../../server/update-check'); }); - describe('Reporting to UpdateCheck', function () { + afterEach(function () { + sandbox.restore(); + configUtils.restore(); + }); + + describe('fn: updateCheck', function () { + var updateCheckRequestSpy, + updateCheckResponseSpy, + updateCheckErrorSpy; + + beforeEach(testUtils.setup('owner', 'posts', 'perms:setting', 'perms:user', 'perms:init')); + afterEach(testUtils.teardown); + + beforeEach(function () { + updateCheckRequestSpy = sandbox.stub().returns(Promise.resolve()); + updateCheckResponseSpy = sandbox.stub().returns(Promise.resolve()); + updateCheckErrorSpy = sandbox.stub(); + + updateCheck.__set__('updateCheckRequest', updateCheckRequestSpy); + updateCheck.__set__('updateCheckResponse', updateCheckResponseSpy); + updateCheck.__set__('updateCheckError', updateCheckErrorSpy); + updateCheck.__set__('allowedCheckEnvironments', ['development', 'production', 'testing', 'testing-mysql', 'testing-pg']); + }); + + it('update check was never executed', function (done) { + sandbox.stub(SettingsAPI, 'read').returns(Promise.resolve({ + settings: [{ + value: null + }] + })); + + updateCheck() + .then(function () { + updateCheckRequestSpy.calledOnce.should.eql(true); + updateCheckResponseSpy.calledOnce.should.eql(true); + updateCheckErrorSpy.called.should.eql(false); + done(); + }) + .catch(done); + }); + + it('update check won\'t happen if it\'s too early', function (done) { + sandbox.stub(SettingsAPI, 'read').returns(Promise.resolve({ + settings: [{ + value: moment().add('10', 'minutes').unix() + }] + })); + + updateCheck() + .then(function () { + updateCheckRequestSpy.calledOnce.should.eql(false); + updateCheckResponseSpy.calledOnce.should.eql(false); + updateCheckErrorSpy.called.should.eql(false); + done(); + }) + .catch(done); + }); + + it('update check will happen if it\'s time to check', function (done) { + sandbox.stub(SettingsAPI, 'read').returns(Promise.resolve({ + settings: [{ + value: moment().subtract('10', 'minutes').unix() + }] + })); + + updateCheck() + .then(function () { + updateCheckRequestSpy.calledOnce.should.eql(true); + updateCheckResponseSpy.calledOnce.should.eql(true); + updateCheckErrorSpy.called.should.eql(false); + done(); + }) + .catch(done); + }); + }); + + describe('fn: updateCheckData', function () { + var environmentsOrig; + before(function () { configUtils.set('privacy:useUpdateCheck', true); }); @@ -52,156 +134,512 @@ describe('Update Check', function () { }); }); - describe('Custom Notifications', function () { + describe('fn: createCustomNotification', function () { var currentVersionOrig; before(function () { - currentVersionOrig = updateCheck.__get__('currentVersion'); - updateCheck.__set__('currentVersion', '0.9.0'); + currentVersionOrig = updateCheck.__get__('ghostVersion.original'); + updateCheck.__set__('ghostVersion.original', '0.9.0'); }); after(function () { - updateCheck.__set__('currentVersion', currentVersionOrig); + updateCheck.__set__('ghostVersion.original', currentVersionOrig); }); beforeEach(testUtils.setup('owner', 'posts', 'settings', 'perms:setting', 'perms:notification', 'perms:user', 'perms:init')); + beforeEach(function () { + return NotificationsAPI.destroyAll(testUtils.context.internal); + }); + afterEach(testUtils.teardown); - it('should create a custom notification for target version', function (done) { + it('should create a release notification for target version', function (done) { var createCustomNotification = updateCheck.__get__('createCustomNotification'), - message = { - id: uuid.v4(), - version: '0.9.x', - content: '

Hey there! This is for 0.9.0 version

' + notification = { + id: 1, + custom: 0, + messages: [{ + id: uuid.v4(), + version: '0.9.x', + content: '

Hey there! This is for 0.9.0 version

', + dismissible: true, + top: true + }] }; - createCustomNotification(message).then(function () { + createCustomNotification(notification).then(function () { return NotificationsAPI.browse(testUtils.context.internal); }).then(function (results) { should.exist(results); should.exist(results.notifications); - results.notifications.length.should.be.above(0); - should.exist(_.find(results.notifications, {uuid: message.id})); + results.notifications.length.should.eql(1); + + var targetNotification = _.find(results.notifications, {id: notification.messages[0].id}); + should.exist(targetNotification); + + targetNotification.dismissible.should.eql(notification.messages[0].dismissible); + targetNotification.id.should.eql(notification.messages[0].id); + targetNotification.top.should.eql(notification.messages[0].top); + targetNotification.type.should.eql('info'); + targetNotification.message.should.eql(notification.messages[0].content); done(); }).catch(done); }); - it('should not create notifications meant for other versions', function (done) { + it('should create a custom notification', function (done) { var createCustomNotification = updateCheck.__get__('createCustomNotification'), - message = { - id: uuid.v4(), - version: '0.5.x', - content: '

Hey there! This is for 0.5.0 version

' + notification = { + id: 1, + custom: 1, + messages: [{ + id: uuid.v4(), + version: 'custom1', + content: '

How about migrating your blog?

', + dismissible: false, + top: true, + type: 'warn' + }] }; - createCustomNotification(message).then(function () { + createCustomNotification(notification).then(function () { return NotificationsAPI.browse(testUtils.context.internal); }).then(function (results) { - should.not.exist(_.find(results.notifications, {uuid: message.id})); + should.exist(results); + should.exist(results.notifications); + results.notifications.length.should.eql(1); + + var targetNotification = _.find(results.notifications, {id: notification.messages[0].id}); + should.exist(targetNotification); + targetNotification.dismissible.should.eql(notification.messages[0].dismissible); + targetNotification.top.should.eql(notification.messages[0].top); + targetNotification.type.should.eql(notification.messages[0].type); done(); }).catch(done); }); + + it('should not add duplicates', function (done) { + var createCustomNotification = updateCheck.__get__('createCustomNotification'), + notification = { + id: 1, + custom: 1, + messages: [{ + id: uuid.v4(), + version: 'custom1', + content: '

How about migrating your blog?

', + dismissible: false, + top: true, + type: 'warn' + }] + }; + + createCustomNotification(notification) + .then(function () { + return NotificationsAPI.browse(testUtils.context.internal); + }) + .then(function (results) { + should.exist(results); + should.exist(results.notifications); + results.notifications.length.should.eql(1); + }) + .then(function () { + return createCustomNotification(notification); + }) + .then(function () { + return NotificationsAPI.browse(testUtils.context.internal); + }) + .then(function (results) { + should.exist(results); + should.exist(results.notifications); + results.notifications.length.should.eql(1); + done(); + }) + .catch(done); + }); }); - describe('Show notification', function () { - var currentVersionOrig; - - before(function () { - currentVersionOrig = updateCheck.__get__('currentVersion'); - }); - - after(function () { - updateCheck.__set__('currentVersion', currentVersionOrig); - }); - - beforeEach(testUtils.setup('settings', 'perms:setting', 'perms:notification', 'perms:init')); - + describe('fn: updateCheckResponse', function () { + beforeEach(testUtils.setup('settings', 'perms:setting', 'perms:init')); afterEach(testUtils.teardown); - it('should show update notification', function (done) { - var showUpdateNotification = updateCheck.__get__('showUpdateNotification'); + it('receives a notifications with messages', function (done) { + var updateCheckResponse = updateCheck.__get__('updateCheckResponse'), + createNotificationSpy = sandbox.spy(), + message = { + id: uuid.v4(), + version: '^0.11.11', + content: 'Test', + dismissible: true, + top: true + }; - updateCheck.__set__('currentVersion', '1.7.1'); - settingsCache.set('display_update_notification', {value: '1.9.0'}); + updateCheck.__set__('createCustomNotification', createNotificationSpy); - showUpdateNotification() - .then(function (result) { - result.should.eql('1.9.0'); + updateCheckResponse({version: '0.11.12', messages: [message]}) + .then(function () { + createNotificationSpy.callCount.should.eql(1); done(); }) .catch(done); }); - it('should show update notification', function (done) { - var showUpdateNotification = updateCheck.__get__('showUpdateNotification'); + it('receives multiple notifications', function (done) { + var updateCheckResponse = updateCheck.__get__('updateCheckResponse'), + createNotificationSpy = sandbox.spy(), + message1 = { + id: uuid.v4(), + version: '^0.11.11', + content: 'Test1', + dismissible: true, + top: true + }, + message2 = { + id: uuid.v4(), + version: '^0', + content: 'Test2', + dismissible: true, + top: false + }, + notifications = [ + {version: '0.11.12', messages: [message1]}, + {version: 'custom1', messages: [message2]} + ]; - updateCheck.__set__('currentVersion', '1.7.1'); - settingsCache.set('display_update_notification', {value: '2.0.0'}); + updateCheck.__set__('createCustomNotification', createNotificationSpy); - showUpdateNotification() - .then(function (result) { - result.should.eql('2.0.0'); + updateCheckResponse(notifications) + .then(function () { + createNotificationSpy.callCount.should.eql(2); done(); }) .catch(done); }); - it('should not show update notification: latest minor release is not greater than your Ghost version', function (done) { - var showUpdateNotification = updateCheck.__get__('showUpdateNotification'); + it('ignores some custom notifications which are not marked as group', function (done) { + var updateCheckResponse = updateCheck.__get__('updateCheckResponse'), + createNotificationSpy = sandbox.spy(), + message1 = { + id: uuid.v4(), + version: '^0.11.11', + content: 'Test1', + dismissible: true, + top: true + }, + message2 = { + id: uuid.v4(), + version: '^0', + content: 'Test2', + dismissible: true, + top: false + }, + message3 = { + id: uuid.v4(), + version: '^0', + content: 'Test2', + dismissible: true, + top: false + }, + notifications = [ + {version: '0.11.12', messages: [message1]}, + {version: 'all1', messages: [message2], custom: 1}, + {version: 'migration1', messages: [message3], custom: 1} + ]; - updateCheck.__set__('currentVersion', '1.9.0'); - settingsCache.set('display_update_notification', {value: '1.9.0'}); + updateCheck.__set__('createCustomNotification', createNotificationSpy); - showUpdateNotification() - .then(function (result) { - result.should.eql(false); + updateCheckResponse(notifications) + .then(function () { + createNotificationSpy.callCount.should.eql(2); done(); }) .catch(done); }); - it('should not show update notification: latest minor release is not greater than your Ghost version', function (done) { - var showUpdateNotification = updateCheck.__get__('showUpdateNotification'); + it('group matches', function (done) { + var updateCheckResponse = updateCheck.__get__('updateCheckResponse'), + createNotificationSpy = sandbox.spy(), + message1 = { + id: uuid.v4(), + version: '^0.11.11', + content: 'Test1', + dismissible: true, + top: true + }, + message2 = { + id: uuid.v4(), + version: '^0', + content: 'Test2', + dismissible: true, + top: false + }, + message3 = { + id: uuid.v4(), + version: '^0', + content: 'Test2', + dismissible: true, + top: false + }, + notifications = [ + {version: '0.11.12', messages: [message1], custom: 0}, + {version: 'all1', messages: [message2], custom: 1}, + {version: 'migration1', messages: [message3], custom: 1} + ]; - updateCheck.__set__('currentVersion', '1.9.1'); - settingsCache.set('display_update_notification', {value: '1.9.1'}); + updateCheck.__set__('createCustomNotification', createNotificationSpy); - showUpdateNotification() - .then(function (result) { - result.should.eql(false); + configUtils.set({notificationGroups: ['migration']}); + + updateCheckResponse(notifications) + .then(function () { + createNotificationSpy.callCount.should.eql(3); done(); }) .catch(done); }); - it('should not show update notification: latest release is a patch', function (done) { - var showUpdateNotification = updateCheck.__get__('showUpdateNotification'); + it('single custom notification received, group matches', function (done) { + var updateCheckResponse = updateCheck.__get__('updateCheckResponse'), + createNotificationSpy = sandbox.spy(), + message1 = { + id: uuid.v4(), + version: '^0.11.11', + content: 'Custom', + dismissible: true, + top: true + }, + notifications = [ + {version: 'something', messages: [message1], custom: 1} + ]; - updateCheck.__set__('currentVersion', '1.9.0'); - settingsCache.set('display_update_notification', {value: '1.9.1'}); + updateCheck.__set__('createCustomNotification', createNotificationSpy); - showUpdateNotification() - .then(function (result) { - result.should.eql(false); + configUtils.set({notificationGroups: ['something']}); + + updateCheckResponse(notifications) + .then(function () { + createNotificationSpy.callCount.should.eql(1); done(); }) .catch(done); }); - it('should not show update notification: latest release is a patch', function (done) { - var showUpdateNotification = updateCheck.__get__('showUpdateNotification'); + it('single custom notification received, group does not match', function (done) { + var updateCheckResponse = updateCheck.__get__('updateCheckResponse'), + createNotificationSpy = sandbox.spy(), + message1 = { + id: uuid.v4(), + version: '^0.11.11', + content: 'Custom', + dismissible: true, + top: true + }, + notifications = [ + {version: 'something', messages: [message1], custom: 1} + ]; - updateCheck.__set__('currentVersion', '1.9.1'); - settingsCache.set('display_update_notification', {value: '1.9.0'}); + updateCheck.__set__('createCustomNotification', createNotificationSpy); - showUpdateNotification() - .then(function (result) { - result.should.eql(false); + configUtils.set({notificationGroups: ['migration']}); + + updateCheckResponse(notifications) + .then(function () { + createNotificationSpy.callCount.should.eql(0); done(); }) .catch(done); }); }); + + describe('fn: updateCheckRequest', function () { + beforeEach(function () { + configUtils.set('privacy:useUpdateCheck', true); + }); + + afterEach(function () { + configUtils.restore(); + }); + + it('[default]', function () { + var updateCheckRequest = updateCheck.__get__('updateCheckRequest'), + updateCheckDataSpy = sandbox.stub(), + hostname, + reqObj, + data = { + ghost_version: '0.11.11', + blog_id: 'something', + npm_version: 'something' + }; + + updateCheck.__set__('request', function (_hostname, _reqObj) { + hostname = _hostname; + reqObj = _reqObj; + + return Promise.resolve({ + statusCode: 200, + body: {version: 'something'} + }); + }); + + updateCheck.__set__('updateCheckData', updateCheckDataSpy); + + updateCheckDataSpy.returns(Promise.resolve(data)); + + return updateCheckRequest() + .then(function () { + hostname.should.eql('https://updates.ghost.org'); + should.exist(reqObj.headers['Content-Length']); + reqObj.body.should.eql(data); + reqObj.json.should.eql(true); + }); + }); + + it('privacy flag is used', function () { + var updateCheckRequest = updateCheck.__get__('updateCheckRequest'), + updateCheckDataSpy = sandbox.stub(), + reqObj, + hostname; + + configUtils.set({ + privacy: { + useUpdateCheck: false + } + }); + + updateCheck.__set__('request', function (_hostname, _reqObj) { + hostname = _hostname; + reqObj = _reqObj; + + return Promise.resolve({ + statusCode: 200, + body: {version: 'something'} + }); + }); + + updateCheck.__set__('updateCheckData', updateCheckDataSpy); + + updateCheckDataSpy.returns(Promise.resolve({ + ghost_version: '0.11.11', + blog_id: 'something', + npm_version: 'something' + })); + + return updateCheckRequest() + .then(function () { + hostname.should.eql('https://updates.ghost.org'); + reqObj.query.should.eql({ + ghost_version: '0.11.11' + }); + + should.not.exist(reqObj.body); + reqObj.json.should.eql(true); + should.not.exist(reqObj.headers['Content-Length']); + }); + }); + + it('received 500 from the service', function () { + var updateCheckRequest = updateCheck.__get__('updateCheckRequest'), + updateCheckDataSpy = sandbox.stub(), + reqObj, + hostname; + + updateCheck.__set__('request', function (_hostname, _reqObj) { + hostname = _hostname; + reqObj = _reqObj; + + return Promise.reject({ + statusCode: 500, + message: 'something went wrong' + }); + }); + + updateCheck.__set__('updateCheckData', updateCheckDataSpy); + + updateCheckDataSpy.returns(Promise.resolve({ + ghost_version: '0.11.11', + blog_id: 'something', + npm_version: 'something' + })); + + return updateCheckRequest() + .then(function () { + throw new Error('Should fail.'); + }) + .catch(function (err) { + err.message.should.eql('something went wrong'); + }); + }); + + it('received 404 from the service', function () { + var updateCheckRequest = updateCheck.__get__('updateCheckRequest'), + updateCheckDataSpy = sandbox.stub(), + reqObj, + hostname; + + updateCheck.__set__('request', function (_hostname, _reqObj) { + hostname = _hostname; + reqObj = _reqObj; + + return Promise.reject({ + statusCode: 404, + response: { + body: { + errors: [{detail: 'No Notifications available.'}] + } + } + }); + }); + + updateCheck.__set__('updateCheckData', updateCheckDataSpy); + + updateCheckDataSpy.returns(Promise.resolve({ + ghost_version: '0.11.11', + blog_id: 'something', + npm_version: 'something' + })); + + return updateCheckRequest() + .then(function () { + hostname.should.eql('https://updates.ghost.org'); + }); + }); + + it('custom url', function () { + var updateCheckRequest = updateCheck.__get__('updateCheckRequest'), + updateCheckDataSpy = sandbox.stub(), + reqObj, + hostname; + + configUtils.set({ + updateCheck: { + url: 'http://localhost:3000' + } + }); + + updateCheck.__set__('request', function (_hostname, _reqObj) { + hostname = _hostname; + reqObj = _reqObj; + + return Promise.resolve({ + statusCode: 200, + body: { + version: 'something' + } + }); + }); + + updateCheck.__set__('updateCheckData', updateCheckDataSpy); + + updateCheckDataSpy.returns(Promise.resolve({ + ghost_version: '0.11.11', + blog_id: 'something', + npm_version: 'something' + })); + + return updateCheckRequest() + .then(function () { + hostname.should.eql('http://localhost:3000'); + }); + }); + }); }); diff --git a/core/test/unit/api/notifications_spec.js b/core/test/unit/api/notifications_spec.js deleted file mode 100644 index c93f22ede0..0000000000 --- a/core/test/unit/api/notifications_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -var should = require('should'), // jshint ignore:line - rewire = require('rewire'), - NotificationAPI = rewire('../../../server/api/notifications'); - -describe('UNIT: Notification API', function () { - it('ensure non duplicates', function (done) { - var options = {context: {internal: true}}, - notifications = [{ - type: 'info', - message: 'Hello, this is dog' - }], - notificationStore = NotificationAPI.__get__('notificationsStore'); - - NotificationAPI.add({notifications: notifications}, options) - .then(function () { - notificationStore.length.should.eql(1); - return NotificationAPI.add({notifications: notifications}, options); - }) - .then(function () { - notificationStore.length.should.eql(1); - - notifications.push({ - type: 'info', - message: 'Hello, this is cat' - }); - - return NotificationAPI.add({notifications: notifications}, options); - }) - .then(function () { - notificationStore.length.should.eql(2); - done(); - }) - .catch(done); - }); -});