diff --git a/README.md b/README.md index 239483628..79c523925 100644 --- a/README.md +++ b/README.md @@ -17,30 +17,29 @@ To launch a Talk server of your own from your browser without any need to muck a ### Configuration -The Talk application requires specific configuration options to be available -inside the environment in order to run, those variables are listed here: +The Talk application looks for the following configuration values either as environment variables: - `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database. - `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database. - `TALK_SESSION_SECRET` (*required*) - a random string which will be used to secure cookies. -- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook -Login enabled app. -- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your -Facebook Login enabled app. - `TALK_ROOT_URL` (*required*) - root url of the installed application externally available in the format: `://` without the path. -- `TALK_SMTP_EMAIL` (*required*) - the address to send emails from using the + +- `TALK_FACEBOOK_APP_ID` (*required for login via fb*) - the Facebook app id for your Facebook +Login enabled app. +- `TALK_FACEBOOK_APP_SECRET` (*required for login via fb*) - the Facebook app secret for your +Facebook Login enabled app. + +- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the SMTP provider. -- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using. -- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using. -- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`. -- `TALK_SMTP_PORT` (*required*) - SMTP port. +- `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using. +- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using. +- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`. +- `TALK_SMTP_PORT` (*required for email*) - SMTP port. - -### Install from Source - -If you want to run Talk in development mode from source (without docker) you can read the [INSTALL file](INSTALL.md). +Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for +alternative methods of loading configuration during development. ### License diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js index e8276a9da..fe4895ed3 100644 --- a/client/coral-admin/src/actions/comments.js +++ b/client/coral-admin/src/actions/comments.js @@ -2,14 +2,21 @@ import coralApi from '../../../coral-framework/helpers/response'; import * as commentTypes from '../constants/comments'; import * as actionTypes from '../constants/actions'; +function addUsersCommentsActions (dispatch, {comments, users, actions}) { + dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users}); + dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments}); + dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions}); +} + // Get comments to fill each of the three lists on the mod queue export const fetchModerationQueueComments = () => { return dispatch => { dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); + return Promise.all([ coralApi('/queue/comments/pending'), - coralApi('/comments?status=rejected'), - coralApi('/comments?action_type=flag') + coralApi('/queue/comments/rejected'), + coralApi('/queue/comments/flagged') ]) .then(([pending, rejected, flagged]) => { @@ -21,14 +28,38 @@ export const fetchModerationQueueComments = () => { actions: [...pending.actions, ...rejected.actions, ...flagged.actions] }; }) - .then(({comments, users, actions}) => { + .then(addUsersCommentsActions.bind(this, dispatch)); + }; +}; - /* Post comments and users to redux store. Actions will be posted when they are needed. */ - dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users}); - dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments}); - dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions}); +export const fetchPendingQueue = () => { + return dispatch => { + dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); - }); + return coralApi('/queue/comments/pending') + .then(addUsersCommentsActions.bind(this, dispatch)); + }; +}; + +export const fetchRejectedQueue = () => { + return dispatch => { + dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); + + return coralApi('/queue/comments/rejected') + .then(addUsersCommentsActions.bind(this, dispatch)); + }; +}; + +export const fetchFlaggedQueue = () => { + return dispatch => { + dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); + + return coralApi('/queue/comments/flagged') + .then(results => { + results.comments.forEach(comment => comment.flagged = true); + return results; + }) + .then(addUsersCommentsActions.bind(this, dispatch)); }; }; diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js index 8a684184a..d2c888e9e 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationContainer.js @@ -6,7 +6,10 @@ import { updateStatus, showBanUserDialog, hideBanUserDialog, - fetchModerationQueueComments + fetchPendingQueue, + fetchRejectedQueue, + fetchFlaggedQueue, + fetchModerationQueueComments, } from 'actions/comments'; import {userStatusUpdate} from 'actions/users'; import {fetchSettings} from 'actions/settings'; @@ -54,6 +57,16 @@ class ModerationContainer extends React.Component { onTabClick(activeTab) { this.setState({activeTab}); + + if (activeTab === 'pending') { + this.props.fetchPendingQueue(); + } else if (activeTab === 'rejected') { + this.props.fetchRejectedQueue(); + } else if (activeTab === 'flagged') { + this.props.fetchFlaggedQueue(); + } else { + this.props.fetchModerationQueueComments(); + } } onClose() { @@ -90,6 +103,9 @@ const mapDispatchToProps = dispatch => { return { fetchSettings: () => dispatch(fetchSettings()), fetchModerationQueueComments: () => dispatch(fetchModerationQueueComments()), + fetchPendingQueue: () => dispatch(fetchPendingQueue()), + fetchRejectedQueue: () => dispatch(fetchRejectedQueue()), + fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()), showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)), hideBanUserDialog: () => dispatch(hideBanUserDialog(false)), banUser: (userId, commentId) => dispatch(userStatusUpdate('banned', userId, commentId)).then(() => { diff --git a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js index 55a2e705e..6121ca307 100644 --- a/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js +++ b/client/coral-admin/src/containers/ModerationQueue/ModerationQueue.js @@ -22,49 +22,61 @@ export default ({onTabClick, ...props}) => ( className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}
- - + { + props.activeTab === 'pending' + ?
+ + +
+ : null + }
- + { + props.activeTab === 'rejected' + ? + : null + }
- -
+ { + props.activeTab === 'flagged' + ? + : null + } + diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json index 112f7db1a..5bf9b40a9 100644 --- a/client/coral-framework/translations.json +++ b/client/coral-framework/translations.json @@ -14,6 +14,8 @@ "PASSWORD_REQUIRED": "Must input a password", "PASSWORD_LENGTH": "Password is too short", "EMAIL_IN_USE": "Email address already in use", + "EMAIL_DISPLAY_NAME_IN_USE": "Email address or display name already in use", + "DISPLAYNAME_IN_USE": "Display name already in use", "DISPLAY_NAME_REQUIRED": "Must input a display name", "NO_SPECIAL_CHARACTERS": "Display names can contain letters, numbers and _ only", "PROFANITY_ERROR": "Display names must not contain profanity. Please contact the administrator if you believe this to be in error." @@ -34,6 +36,8 @@ "PASSWORD_REQUIRED": "Debe ingresar una contraseña", "PASSWORD_LENGTH": "La contraseña es muy corta", "EMAIL_IN_USE": "La dirección de correo electrónico se encuentra en uso", + "EMAIL_DISPLAY_NAME_IN_USE": "Correo o Nombre en uso.", + "DISPLAYNAME_IN_USE": "Nombre en uso.", "DISPLAY_NAME_REQUIRED": "Debe ingresar un nombre", "NO_SPECIAL_CHARACTERS": "Los nombres pueden contener letras, números y _", "PROFANITY_ERROR": "Los nombres no pueden contener blasfemias. Por favor contacte al administrador si cree que esto es un error" diff --git a/client/coral-sign-in/translations.js b/client/coral-sign-in/translations.js index 9155a6789..d4f5208bd 100644 --- a/client/coral-sign-in/translations.js +++ b/client/coral-sign-in/translations.js @@ -19,6 +19,7 @@ export default { alreadyHaveAnAccount: 'Already have an account?', recoverPassword: 'Recover password', emailInUse: 'Email address already in use', + emailORusernameInUse: 'Email address or Username already in use', requiredField: 'This field is required', passwordsDontMatch: 'Passwords don\'t match.', specialCharacters: 'Display names can contain letters, numbers and _ only', @@ -45,6 +46,7 @@ export default { alreadyHaveAnAccount: 'Ya tienes una cuenta?', recoverPassword: 'Recuperar contraseña', emailInUse: 'Este email se encuentra en uso', + emailORusernameInUse: 'Este email ó nombre se encuentran en uso', requiredField: 'Este campo es requerido', passwordsDontMatch: 'Las contraseñas no coinciden', specialCharacters: 'Los nombres pueden contener letras, números y _', diff --git a/errors.js b/errors.js index b5b217b61..1b26e7170 100644 --- a/errors.js +++ b/errors.js @@ -54,6 +54,11 @@ const ErrEmailTaken = new APIError('Email address already in use', { status: 400 }); +const ErrDisplayTaken = new APIError('Display name already in use', { + translation_key: 'DISPLAYNAME_IN_USE', + status: 400 +}); + const ErrSpecialChars = new APIError('No special characters are allowed in a display name', { translation_key: 'NO_SPECIAL_CHARACTERS', status: 400 @@ -116,6 +121,7 @@ module.exports = { ErrSpecialChars, ErrMissingDisplay, ErrContainsProfanity, + ErrDisplayTaken, ErrAssetCommentingClosed, ErrNotFound, ErrInvalidAssetURL, diff --git a/models/comment.js b/models/comment.js index 3f9ae1a65..99eaec069 100644 --- a/models/comment.js +++ b/models/comment.js @@ -47,6 +47,7 @@ const CommentSchema = new Schema({ asset_id: String, author_id: String, status_history: [StatusSchema], + status: {type: String, default: null}, parent_id: String }, { timestamps: { @@ -84,24 +85,6 @@ CommentSchema.method('filterForUser', function(user = false) { return this.toJSON(); }); -/** - * Sets up a virtual getter function on a comment such that when you try and - * access the `comment.last_status` it returns the last status in the array - * of status's on the comment, or `null` if there was no status_history. - */ -CommentSchema.virtual('status').get(function() { - - // Here we are taking advantage of the fact that when documents are inserted - // for the new status on a comment that they are always appended to the end - // of the list in the order that they are inserted, hence, the last status - // is always the most recent. - if (this.status_history && this.status_history.length > 0) { - return this.status_history[this.status_history.length - 1].type; - } - - return null; -}); - /** * Creates a new Comment that came from a public source. * @param {Mixed} comment either a single comment or an array of comments. @@ -118,7 +101,7 @@ CommentSchema.statics.publicCreate = (comment) => { body, asset_id, parent_id, - status = false, + status = null, author_id } = comment; @@ -130,6 +113,7 @@ CommentSchema.statics.publicCreate = (comment) => { type: status, created_at: new Date() }] : [], + status, author_id }); @@ -153,33 +137,18 @@ CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({ }); /** - * Finds the accepted comments by the asset_id get the comments that are - * accepted. - * @param {String} asset_id identifier of the asset which owns the comments (uuid) - * @return {Promise} + * findByAssetIdWithStatuses finds all the comments where the asset id matches + * what's provided and the status is one of the ones listed in the statuses + * array. + * @param {String} asset_id the asset id to search by + * @param {Array} [statuses=[]] the array of statuses to search by + * @return {Promise} resolves to an array of comments */ -CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({ +CommentSchema.statics.findByAssetIdWithStatuses = (asset_id, statuses = []) => Comment.find({ asset_id, - 'status_history.type': 'accepted' -}); - -/** - * Finds the new and accepted comments by the asset_id. - * @param {String} asset_id identifier of the asset which owns the comments (uuid) - * @return {Promise} - */ -CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({ - asset_id, - $or: [ - { - 'status_history.type': 'accepted' - }, - { - status_history: { - $size: 0 - } - } - ] + status: { + $in: statuses + } }); /** @@ -205,58 +174,26 @@ CommentSchema.statics.findIdsByActionType = (action_type) => Action .then((actions) => actions.map(a => a.item_id)); /** - * Find comments by their status_history. - * @param {String} status the status of the comment to search for - * @return {Promise} + * Find comments by current status + * @param {String} status status of the comment to search for + * @return {Promise} resovles to comment array */ -CommentSchema.statics.findByStatus = (status = false) => { - let q = {}; - - if (status) { - q['status_history.type'] = status; - } else { - q.status_history = {$size: 0}; - } - - return Comment.find(q); +CommentSchema.statics.findByStatus = (status = null) => { + return Comment.find({status}); }; /** * Find comments that need to be moderated (aka moderation queue). - * @param {String} moderationValue pre or post moderation setting. If it is undefined then look at the settings. + * @param {String} asset_id * @return {Promise} */ -CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => { +CommentSchema.statics.moderationQueue = (status, asset_id = null) => { - /** - * This adds the asset_id requirement to the query if the asset_id is defined. - */ - const assetIDWrap = (query) => { - if (asset_id) { - query = query.where('asset_id', asset_id); - } + // Fetch the comments with statuses. + let comments = Comment.findByStatus(status); - return query; - }; - - // Decide on whether or not we need to load extended options for the - // moderation based on the moderation options. - let comments; - - if (moderation === 'pre') { - - // Pre-moderation: New comments are shown in the moderator queues immediately. - comments = assetIDWrap(CommentSchema.statics.findByStatus('premod')); - - } else { - - // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. - comments = CommentSchema.statics.findIdsByActionType('flag') - .then((ids) => assetIDWrap(Comment.find({ - id: { - $in: ids - } - }))); + if (asset_id) { + comments = comments.where('asset_id', asset_id); } return comments; @@ -277,7 +214,8 @@ CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.u created_at: new Date(), assigned_by } - } + }, + $set: {status} }); /** @@ -328,8 +266,16 @@ CommentSchema.statics.all = () => Comment.find(); * probably to be paginated at some point in the future * @return {Promise} array resolves to an array of comments by that user */ -CommentSchema.statics.findByUserId = function (author_id) { - return Comment.find({author_id}); +CommentSchema.statics.findByUserId = function (author_id, admin = false) { + + // do not return un-published comments for non-admins + let query = {author_id}; + + if (!admin) { + query.$nor = [{status: 'premod'}, {status: 'rejected'}]; + } + + return Comment.find(query); }; // Comment model. diff --git a/models/user.js b/models/user.js index a0993c55a..a0ca34d7a 100644 --- a/models/user.js +++ b/models/user.js @@ -81,7 +81,11 @@ const UserSchema = new mongoose.Schema({ // This is sourced from the social provider or set manually during user setup // and simply provides a name to display for the given user. - displayName: String, + displayName: { + type: String, + unique: true, + required: true + }, // This is true when the user account is disabled, no action should be // acknowledged when they are disabled. Logins are also prevented. @@ -379,6 +383,9 @@ UserService.createLocalUser = (email, password, displayName) => { user.save((err) => { if (err) { if (err.code === 11000) { + if (err.message.match('displayName')) { + return reject(errors.ErrDisplayTaken); + } return reject(errors.ErrEmailTaken); } return reject(err); diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 226f772be..1d5d73ea0 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -48,7 +48,7 @@ router.get('/', (req, res, next) => { // otherwise this will be a vulnerability if you pass user_id and something else, // the app will return admin-level data without the proper checks if (user_id) { - query = Comment.findByUserId(user_id); + query = Comment.findByUserId(user_id, authorization.has(req.user, 'admin')); } else if (status) { query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status)); } else if (action_type) { @@ -57,7 +57,7 @@ router.get('/', (req, res, next) => { .then((ids) => assetIDWrap(Comment.find({ id: { $in: ids - }, + } }))); } else { query = assetIDWrap(Comment.all()); @@ -124,7 +124,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => { if (charCountEnable && body.length > charCount) { return 'rejected'; } - return moderation === 'pre' ? 'premod' : ''; + return moderation === 'pre' ? 'premod' : null; }); } diff --git a/routes/api/queue/index.js b/routes/api/queue/index.js index 9c85fbe94..34902557c 100644 --- a/routes/api/queue/index.js +++ b/routes/api/queue/index.js @@ -2,12 +2,22 @@ const express = require('express'); const Comment = require('../../../models/comment'); const User = require('../../../models/user'); const Action = require('../../../models/action'); -const Setting = require('../../../models/setting'); -const Asset = require('../../../models/asset'); +const authorization = require('../../../middleware/authorization'); const _ = require('lodash'); const router = express.Router(); +function gatherActionsAndUsers (comments) { + return Promise.all([ + comments, + User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), + Action.getActionSummaries(_.uniq([ + ...comments.map((comment) => comment.id), + ...comments.map((comment) => comment.author_id) + ])) + ]); +} + //============================================================================== // Get Routes //============================================================================== @@ -16,49 +26,51 @@ const router = express.Router(); // depending on the settings. The :moderation overwrites this settings. // Pre-moderation: New comments are shown in the moderator queues immediately. // Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users. -router.get('/comments/pending', (req, res, next) => { +router.get('/comments/pending', authorization.needed('admin'), (req, res, next) => { - const { - asset_id - } = req.query; + const {asset_id} = req.query; - let settings = Setting.retrieve(); - - if (asset_id) { - - // In the event that we have an asset_id, we should fetch the asset settings - // in order to actually determine if there is additional comments to parse. - settings = Promise.all([ - settings, - Asset.findById(asset_id).select('settings') - ]).then(([{moderation}, asset]) => { - if (asset.settings && asset.settings.moderation) { - return {moderation: asset.settings.moderation}; - } - - return {moderation}; - }); - } - - settings - .then(({moderation}) => { - return Comment.moderationQueue(moderation); - }).then((comments) => { - return Promise.all([ - comments, - User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))), - Action.getActionSummaries(_.uniq([ - ...comments.map((comment) => comment.id), - ...comments.map((comment) => comment.author_id) - ])) - ]); - }) + Comment.moderationQueue('premod', asset_id) + .then(gatherActionsAndUsers) .then(([comments, users, actions]) => { - res.json({ - comments, - users, - actions - }); + res.json({comments, users, actions}); + }) + .catch(error => { + next(error); + }); +}); + +router.get('/comments/rejected', authorization.needed('admin'), (req, res, next) => { + const {asset_id} = req.query; + + Comment.moderationQueue('rejected', asset_id) + .then(gatherActionsAndUsers) + .then(([comments, users, actions]) => { + res.json({comments, users, actions}); + }) + .catch(error => { + next(error); + }); +}); + +router.get('/comments/flagged', authorization.needed('admin'), (req, res, next) => { + const {asset_id} = req.query; + + const assetIDWrap = (query) => { + if (asset_id) { + query = query.where('asset_id', asset_id); + } + + return query; + }; + + Comment.findIdsByActionType('flag') + .then(ids => assetIDWrap(Comment.find({ + id: {$in: ids} + }))) + .then(gatherActionsAndUsers) + .then(([comments, users, actions]) => { + res.json({comments, users, actions}); }) .catch(error => { next(error); diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index 540e36e52..06845a21a 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -48,20 +48,11 @@ router.get('/', (req, res, next) => { settings.merge(asset.settings); } - // Fetch the appropriate comments stream. - let comments; - - if (settings.moderation === 'pre') { - comments = Comment.findAcceptedByAssetId(asset.id); - } else { - comments = Comment.findAcceptedAndNewByAssetId(asset.id); - } - return Promise.all([ // This is the promised component... Fetch the comments based on the // moderation settings. - comments, + Comment.findByAssetIdWithStatuses(asset.id, [null, 'accepted']), // Send back the reference to the asset. asset, diff --git a/tests/models/comment.js b/tests/models/comment.js index 91f51ccb8..83a081da5 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -21,6 +21,7 @@ describe('models.Comment', () => { status_history: [{ type: 'accepted' }], + status: 'accepted', parent_id: '', author_id: '123', id: '2' @@ -37,6 +38,7 @@ describe('models.Comment', () => { status_history: [{ type: 'rejected' }], + status: 'rejected', parent_id: '', author_id: '456', id: '4' @@ -46,6 +48,7 @@ describe('models.Comment', () => { status_history: [{ type: 'premod' }], + status: 'premod', parent_id: '', author_id: '456', id: '5' @@ -55,6 +58,7 @@ describe('models.Comment', () => { status_history: [{ type: 'premod' }], + status: 'premod', parent_id: '', author_id: '456', id: '6' @@ -157,47 +161,17 @@ describe('models.Comment', () => { expect(result[2]).to.have.property('body', 'comment 40'); }); }); - - it('should find an array of accepted comments by asset id', () => { - return Comment.findAcceptedByAssetId('123').then((result) => { - expect(result).to.have.length(1); - result.sort((a, b) => { - if (a.body < b.body) {return -1;} - else {return 1;} - }); - expect(result[0]).to.have.property('body', 'comment 20'); - }); - }); - - it('should find an array of new and accepted comments by asset id', () => { - return Comment.findAcceptedAndNewByAssetId('123').then((result) => { - expect(result).to.have.length(2); - result.sort((a, b) => { - if (a.body < b.body) {return -1;} - else {return 1;} - }); - expect(result[0]).to.have.property('body', 'comment 10'); - }); - }); }); describe('#moderationQueue()', () => { it('should find an array of new comments to moderate when pre-moderation', () => { - return Comment.moderationQueue('pre').then((result) => { + return Comment.moderationQueue('premod').then((result) => { expect(result).to.not.be.null; expect(result).to.have.lengthOf(2); }); }); - it('should find an array of new comments to moderate when post-moderation', () => { - return Comment.moderationQueue('post').then((result) => { - expect(result).to.not.be.null; - expect(result).to.have.lengthOf(1); - expect(result[0]).to.have.property('body', 'comment 30'); - }); - }); - }); describe('#removeAction', () => { @@ -213,6 +187,23 @@ describe('models.Comment', () => { }); }); + describe('#findByUserId', () => { + it('should return all comments if admin', () => { + return Comment.findByUserId('456', true) + .then(comments => { + expect(comments).to.have.length(4); + }); + }); + + it('should not return premod and rejected comments if not admin', () => { + return Comment.findByUserId('456') + .then(comments => { + expect(comments).to.have.length(1); + }); + }); + + }); + describe('#changeStatus', () => { it('should change the status of a comment from no status', () => { @@ -227,6 +218,7 @@ describe('models.Comment', () => { .then(() => Comment.findById(comment_id)) .then((c) => { expect(c).to.have.property('status'); + expect(c.status).to.equal('rejected'); expect(c.status_history).to.have.length(1); expect(c.status_history[0]).to.have.property('type', 'rejected'); expect(c.status_history[0]).to.have.property('assigned_by', '123'); @@ -238,6 +230,8 @@ describe('models.Comment', () => { .then(() => Comment.findById(comments[1].id)) .then((c) => { expect(c).to.have.property('status_history'); + expect(c).to.have.property('status'); + expect(c.status).to.equal('rejected'); expect(c.status_history).to.have.length(2); expect(c.status_history[0]).to.have.property('type', 'accepted'); expect(c.status_history[0]).to.have.property('assigned_by', null); diff --git a/tests/models/user.js b/tests/models/user.js index 69be314c4..16ccacede 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -85,6 +85,22 @@ describe('models.User', () => { }); + describe('#createLocalUser', () => { + it('should not create a user with duplicate display name', () => { + return User.createLocalUsers([{ + email: 'otrostampi@gmail.com', + displayName: 'Stampi', + password: '1Coralito!' + }]) + .then((user) => { + expect(user).to.be.null; + }) + .catch((error) => { + expect(error).to.not.be.null; + }); + }); + }); + describe('#createEmailConfirmToken', () => { it('should create a token for a valid user', () => { diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index 246da24a7..e905f273d 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -34,12 +34,14 @@ describe('/api/v1/comments', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', + status: 'rejected', status_history: [{ type: 'rejected' }] }, { body: 'comment 30', asset_id: '456', + status: 'accepted', status_history: [{ type: 'accepted' }] @@ -81,15 +83,14 @@ describe('/api/v1/comments', () => { ]); }); - it('should return only the owner’s comments if the user is not an admin', () => { + it('should return only the owner’s published comments if the user is not an admin', () => { return chai.request(app) .get('/api/v1/comments?user_id=456') .set(passport.inject({id: '456', roles: []})) .then(res => { expect(res).to.have.status(200); - expect(res.body.comments).to.have.length(2); + expect(res.body.comments).to.have.length(1); expect(res.body.comments[0]).to.have.property('author_id', '456'); - expect(res.body.comments[1]).to.have.property('author_id', '456'); }); }); @@ -190,6 +191,7 @@ describe('/api/v1/comments', () => { .then((res) => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); + expect(res.body).to.have.property('status', 'premod'); }); }); @@ -212,6 +214,7 @@ describe('/api/v1/comments', () => { expect(res).to.have.status(201); expect(res.body).to.have.property('id'); expect(res.body).to.have.property('status', null); + return Promise.all([ res.body, Action.findByType('flag', 'comments') @@ -250,6 +253,27 @@ describe('/api/v1/comments', () => { }); }); + it('should create a comment with null status if it\'s asset is has post-moderation enabled', () => { + return Asset + .findOrCreateByUrl('https://coralproject.net/article1') + .then((asset) => { + return Asset + .overrideSettings(asset.id, {moderation: 'post'}) + .then(() => asset); + }) + .then((asset) => { + return chai.request(app).post('/api/v1/comments') + .set(passport.inject({roles: []})) + .send({'body': 'Something body.', 'author_id': '123', 'asset_id': asset.id, 'parent_id': ''}); + }) + .then((res) => { + expect(res).to.have.status(201); + expect(res.body).to.have.property('id'); + expect(res.body).to.have.property('asset_id'); + expect(res.body).to.have.property('status', null); + }); + }); + it('should create a rejected comment if the body is above the character count', () => { return Asset .findOrCreateByUrl('https://coralproject.net/article1') diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index 378899967..6a7507618 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -21,6 +21,7 @@ describe('/api/v1/queue', () => { body: 'comment 10', asset_id: 'asset', author_id: '123', + status: 'rejected', status_history: [{ type: 'rejected' }] @@ -29,6 +30,7 @@ describe('/api/v1/queue', () => { body: 'comment 20', asset_id: 'asset', author_id: '456', + status: 'premod', status_history: [{ type: 'premod' }] @@ -36,6 +38,7 @@ describe('/api/v1/queue', () => { id: 'hij', body: 'comment 30', asset_id: '456', + status: 'accepted', status_history: [{ type: 'accepted' }] @@ -89,6 +92,7 @@ describe('/api/v1/queue', () => { .set(passport.inject({roles: ['admin']})) .then((res) => { expect(res).to.have.status(200); + expect(res.body.comments).to.have.length(1); expect(res.body.comments[0]).to.have.property('body'); expect(res.body.users[0]).to.have.property('displayName'); expect(res.body.actions[0]).to.have.property('action_type'); diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index 5d192e976..104801996 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -24,11 +24,27 @@ describe('/api/v1/stream', () => { } }; + const assets = [ + { + url: 'https://example.com/article/1' + }, + { + url: 'https://example.com/article/2', + settings: { + moderation: 'pre' + } + }, + { + url: 'https://example.com/article/3' + } + ]; + const comments = [{ id: 'abc', body: 'comment 10', author_id: '', parent_id: '', + status: 'accepted', status_history: [{ type: 'accepted' }] @@ -37,6 +53,7 @@ describe('/api/v1/stream', () => { body: 'comment 20', author_id: '', parent_id: '', + status: null, status_history: [] }, { id: 'uio', @@ -44,6 +61,7 @@ describe('/api/v1/stream', () => { asset_id: 'asset', author_id: '456', parent_id: '', + status: 'accepted', status_history: [{ type: 'accepted' }] @@ -51,9 +69,32 @@ describe('/api/v1/stream', () => { id: 'hij', body: 'comment 40', asset_id: '456', + status: 'rejected', status_history: [{ type: 'rejected' }] + }, { + body: 'comment 50', + status: 'premod', + status_history: [{ + type: 'premod' + }] + }, { + body: 'comment 60', + status: 'accepted', + status_history: [{ + type: 'accepted' + }] + }, { + body: 'comment 70', + status: 'rejected', + status_history: [{ + type: 'rejected' + }] + }, { + body: 'comment 70', + status: null, + status_history: [] }]; const users = [{ @@ -75,42 +116,46 @@ describe('/api/v1/stream', () => { }]; beforeEach(() => { - return Setting.init(settings).then(() => { - return Promise.all([ - User.createLocalUsers(users), - Asset.findOrCreateByUrl('http://test.com'), - Asset - .findOrCreateByUrl('http://coralproject.net/asset2') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'pre'}) - .then(() => asset); - }) - ]) - .then(([users, asset1, asset2]) => { + return Setting.init(settings) + .then(() => Promise.all([ + User.createLocalUsers(users), + Promise.all(assets.map((asset) => Asset.create(asset))) + ])) + .then(([mockUsers, mockAssets]) => { - comments[0].author_id = users[0].id; - comments[1].author_id = users[1].id; - comments[2].author_id = users[0].id; - comments[3].author_id = users[1].id; - - comments[0].asset_id = asset1.id; - comments[1].asset_id = asset1.id; - comments[2].asset_id = asset2.id; - comments[3].asset_id = asset2.id; - - return Promise.all([ - Comment.create(comments), - Action.create(actions) - ]); + // Map the id's over. + mockAssets.forEach((asset, i) => { + assets[i].id = asset.id; }); + + mockUsers.forEach((user, i) => { + users[i].id = user.id; + }); + + comments.forEach((comment, i) => { + comments[i].author_id = users[(i % 2) === 0 ? 0 : 1].id; + }); + + comments[0].asset_id = assets[0].id; + comments[1].asset_id = assets[0].id; + comments[2].asset_id = assets[1].id; + comments[3].asset_id = assets[1].id; + comments[4].asset_id = assets[2].id; + comments[5].asset_id = assets[2].id; + comments[6].asset_id = assets[2].id; + comments[7].asset_id = assets[2].id; + + return Promise.all([ + Comment.create(comments), + Action.create(actions) + ]); }); }); it('should return a stream with comments, users and actions for an existing asset', () => { return chai.request(app) .get('/api/v1/stream') - .query({'asset_url': 'http://test.com'}) + .query({asset_url: assets[0].url}) .then(res => { expect(res).to.have.status(200); expect(res.body.assets.length).to.equal(1); @@ -134,15 +179,47 @@ describe('/api/v1/stream', () => { it('should merge the settings when the asset contains settings to override it with', () => { return chai.request(app) .get('/api/v1/stream') - .query({'asset_url': 'http://coralproject.net/asset2'}) + .query({asset_url: assets[1].url}) .then((res) => { expect(res).to.have.status(200); - expect(res.body.assets.length).to.equal(1); - expect(res.body.comments.length).to.equal(1); - expect(res.body.users.length).to.equal(1); + expect(res.body.assets).to.have.length(1); + expect(res.body.comments).to.have.length(1); + expect(res.body.users).to.have.length(1); expect(res.body.settings).to.have.property('moderation', 'pre'); expect(res.body.settings).to.not.have.property('wordlist'); }); }); + + it('should not change the previously displayed comments based on moderation state changes', () => { + + let preComments, postComments; + + return chai.request(app) + .get('/api/v1/stream') + .query({asset_url: assets[2].url}) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body.comments.length).to.equal(2); + expect(res.body.settings).to.have.property('moderation', 'post'); + + preComments = res.body.comments; + + return Asset.overrideSettings(assets[2].id, {moderation: 'pre'}); + }) + .then(() => { + return chai.request(app) + .get('/api/v1/stream') + .query({asset_url: assets[2].url}); + }) + .then((res) => { + expect(res).to.have.status(200); + expect(res.body.comments.length).to.equal(2); + expect(res.body.settings).to.have.property('moderation', 'pre'); + + postComments = res.body.comments; + + expect(preComments).to.deep.equal(postComments); + }); + }); }); }); diff --git a/util.js b/util.js index 6907d90cd..b081078e7 100644 --- a/util.js +++ b/util.js @@ -37,6 +37,8 @@ util.onshutdown = (jobs) => { }; // Attach to the SIGTERM + SIGINT handles to ensure a clean shutdown in the -// event that we have an external event. -process.on('SIGTERM', () => util.shutdown()); -process.on('SIGINT', () => util.shutdown()); +// event that we have an external event. SIGUSR2 is called when the app is asked +// to be 'killed', same procedure here. +process.on('SIGTERM', () => util.shutdown()); +process.on('SIGINT', () => util.shutdown()); +process.once('SIGUSR2', () => util.shutdown());