initial pass at status support

This commit is contained in:
Wyatt Johnson
2017-11-02 17:16:57 -06:00
parent 1f3722edc1
commit 76a255fb7b
49 changed files with 1112 additions and 1598 deletions
+7 -1
View File
@@ -1,5 +1,11 @@
{
"exec": "npm-run-all --parallel generate-introspection start:development",
"verbose": true,
"ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"],
"ext": "js,json,graphql"
"ext": "js,json,graphql",
"watch": [
".",
"bin/cli",
"bin/cli-serve"
]
}
+9 -100
View File
@@ -106,20 +106,17 @@ async function createUser(options) {
}
const user = await UsersService.createLocalUser(answers.email.trim(), answers.password.trim(), answers.username.trim());
console.log(`Created user ${user.id}.`);
if (answers.roles.length > 0) {
return Promise.all(answers.roles.map((role) => {
return UsersService
.addRoleToUser(user.id, role)
.then(() => {
console.log(`Added the role ${role} to User ${user.id}.`);
});
}));
for (const role of answers.roles) {
await UsersService.addRoleToUser(user.id, role);
}
}
util.shutdown();
await UsersService.sendEmailConfirmation(user, answers.email.trim());
console.log(`Created User ${user.id}.`);
util.shutdown();
} catch (err) {
console.error(err);
util.shutdown();
@@ -241,12 +238,12 @@ function listUsers() {
});
users.forEach((user) => {
let state = user.disabled ? 'Disabled' : 'Enabled';
const profile = user.profiles.find(({provider}) => provider === 'local');
let state;
if (profile && profile.metadata && profile.metadata.confirmed_at) {
state += ', Verified';
state = 'Verified';
} else {
state += ', Unverified';
state = 'Unverified';
}
table.push([
@@ -336,74 +333,6 @@ function removeRole(userID, role) {
});
}
/**
* Ban a user
* @param {String} userID id of the user to ban
*/
function ban(userID) {
UsersService
.setStatus(userID, 'BANNED')
.then(() => {
console.log(`Banned the User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Unban a user
* @param {String} userUD id of the user to remove the role from
*/
function unban(userID) {
UsersService
.setStatus(userID, 'ACTIVE')
.then(() => {
console.log(`Unban the User ${userID}.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Disable a given user.
* @param {String} userID the ID of a user to disable
*/
function disableUser(userID) {
UsersService
.disableUser(userID)
.then(() => {
console.log(`User ${userID} was disabled.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Enabled a given user.
* @param {String} userID the ID of a user to enable
*/
function enableUser(userID) {
UsersService
.enableUser(userID)
.then(() => {
console.log(`User ${userID} was enabled.`);
util.shutdown();
})
.catch((err) => {
console.error(err);
util.shutdown(1);
});
}
/**
* Verifies an email address for a user.
*
@@ -472,26 +401,6 @@ program
.description('removes a role from a given user')
.action(removeRole);
program
.command('ban <userID>')
.description('ban a given user')
.action(ban);
program
.command('uban <userID>')
.description('unban a given user')
.action(unban);
program
.command('disable <userID>')
.description('disable a given user from logging in')
.action(disableUser);
program
.command('enable <userID>')
.description('enable a given user from logging in')
.action(enableUser);
program
.command('verify <userID> <email>')
.description('verifies the given user\'s email address')
+5 -2
View File
@@ -7,6 +7,8 @@ machine:
environment:
PATH: "${PATH}:${HOME}/${CIRCLE_PROJECT_REPONAME}/node_modules/.bin"
NODE_ENV: "test"
MOCHA_FILE: "${CIRCLE_TEST_REPORTS}/junit/test-results.xml"
MOCHA_REPORTER: "mocha-junit-reporter"
pre:
# TODO: use the following to add in support for MongoDB 3.4.
# # Upgrade the database version to 3.4.
@@ -47,10 +49,11 @@ database:
test:
override:
# Run the tests using the junit reporter.
- MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml MOCHA_REPORTER=mocha-junit-reporter yarn test
- yarn test
# Run the end to end tests
- yarn e2e:ci
# Check dependancies using nsp.
- nsp check
- yarn e2e-ci
deployment:
release:
+1 -1
View File
@@ -14,7 +14,7 @@ export default withQuery(gql`
})
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
statuses: [SET, CHANGED]
})
}
`, {
@@ -22,7 +22,7 @@ const withData = withQuery(gql`
query TalkAdmin_Community {
flaggedUsernamesCount: userCount(query: {
action_type: FLAG,
statuses: [PENDING]
statuses: [SET, CHANGED]
})
...${getDefinitionName(FlaggedAccounts.fragments.root)}
...${getDefinitionName(FlaggedUser.fragments.root)}
@@ -26,4 +26,4 @@ CloseCommentsInfo.propTypes = {
onClick: PropTypes.func,
};
export default CloseCommentsInfo;
export default CloseCommentsInfo;
-21
View File
@@ -1,21 +0,0 @@
.bio textarea {
width: 100%;
box-sizing: border-box;
border-radius: 2px;
min-height: 100px;
margin: 10px 0;
border: solid 1px #d8d8d8;
}
.bio h1 {
font-size: 16px;
margin: 3px 0;
}
.bio p {
margin: 3px 0;
}
.actions {
text-align: right;
}
-18
View File
@@ -1,18 +0,0 @@
import React from 'react';
import styles from './Bio.css';
import {Button} from '../../coral-ui';
export default ({bio, handleSave, handleInput, handleCancel}) => (
<div className={styles.bio}>
<h1>Bio</h1>
<p>Tell the community about yourself</p>
<form>
<textarea value={bio} onChange={handleInput} />
<div className={styles.actions}>
<Button cStyle='cancel' type="button" onClick={handleCancel} raised>Cancel</Button>
<Button cStyle='success' type="submit" onClick={handleSave}>Save Changes</Button>
</div>
</form>
</div>
);
@@ -1,45 +0,0 @@
import React, {Component} from 'react';
import Bio from '../components/Bio';
export default class BioContainer extends Component {
constructor (props) {
super(props);
this.state = {
bio: props.bio
};
this.handleSave = this.handleSave.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleCancel = this.handleCancel.bind(this);
}
handleInput(e) {
this.setState({
bio: e.target.value
});
}
handleSave (e) {
e.preventDefault();
const {userData, saveBio} = this.props;
const {bio} = this.state;
saveBio(userData.id, {bio});
}
handleCancel () {
this.setState({
bio: this.props.bio
});
}
render () {
return <Bio
bio={this.state.bio}
userData={this.props.userData}
handleSave={this.handleSave}
handleInput={this.handleInput}
handleCancel={this.handleCancel}
/>;
}
}
+1 -1
View File
@@ -244,4 +244,4 @@ module.exports = {
ErrSpecialChars,
ErrUsernameTaken,
ExtendableError,
};
};
-10
View File
@@ -1,5 +1,4 @@
const ActionsService = require('../../services/actions');
const UsersService = require('../../services/users');
const errors = require('../../errors');
const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
@@ -31,15 +30,6 @@ const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id,
metadata
});
if (item_type === 'USERS' && action_type === 'FLAG') {
// Set the user as pending if it was a user flag and user has no Admin, Staff or Moderation roles
let user = await UsersService.findById(item_id);
if(!user.isStaff()){
await UsersService.setStatus(item_id, 'PENDING');
}
}
if (comment) {
pubsub.publish('commentFlagged', comment);
}
+88 -30
View File
@@ -1,29 +1,82 @@
const errors = require('../../errors');
const UserModel = require('../../models/user');
const UsersService = require('../../services/users');
const {SET_USER_STATUS, SUSPEND_USER, REJECT_USERNAME} = require('../../perms/constants');
const {
SET_USER_USERNAME_STATUS,
SET_USER_BAN_STATUS,
SET_USER_SUSPENSION_STATUS,
} = require('../../perms/constants');
const setUserStatus = async ({pubsub}, {id, status}) => {
const result = await UsersService.setStatus(id, status);
if (result && result.status === 'BANNED') {
pubsub.publish('userBanned', result);
const setUserUsernameStatus = async (ctx, id, status) => {
const user = await UserModel.findOneAndUpdate({id}, {
$set: {
'status.username.status': status
},
$push: {
'status.username.history': {
status,
assigned_by: ctx.user.id,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
throw errors.ErrNotFound;
}
if (status === 'REJECTED') {
ctx.pubsub.publish('usernameRejected', user);
}
return result;
};
const suspendUser = async ({pubsub}, {id, message, until}) => {
const result = await UsersService.suspendUser(id, message, until);
if (result) {
pubsub.publish('userSuspended', result);
const setUserBanStatus = async (ctx, id, status) => {
const user = await UserModel.findOneAndUpdate({id}, {
$set: {
'status.banned.status': status
},
$push: {
'status.banned.history': {
status,
assigned_by: ctx.user.id,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
throw errors.ErrNotFound;
}
if (user.banned) {
ctx.pubsub.publish('userBanned', user);
}
return result;
};
const rejectUsername = async ({pubsub}, {id, message}) => {
const result = await UsersService.rejectUsername(id, message);
if (result) {
pubsub.publish('usernameRejected', result);
const setUserSuspensionStatus = async (ctx, id, until) => {
const user = await UserModel.findOneAndUpdate({id}, {
$set: {
'status.suspension.until': until
},
$push: {
'status.suspension.history': {
until,
assigned_by: ctx.user.id,
created_at: Date.now()
}
}
}, {
new: true
});
if (user === null) {
throw errors.ErrNotFound;
}
if (user.suspended) {
ctx.pubsub.publish('userSuspended', user);
}
return result;
};
const ignoreUser = ({user}, userToIgnore) => {
@@ -34,27 +87,32 @@ const stopIgnoringUser = ({user}, userToStopIgnoring) => {
return UsersService.stopIgnoringUsers(user.id, [userToStopIgnoring.id]);
};
module.exports = (context) => {
module.exports = (ctx) => {
let mutators = {
User: {
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
suspendUser: () => Promise.reject(errors.ErrNotAuthorized),
rejectUsername: () => Promise.reject(errors.ErrNotAuthorized),
ignoreUser: (action) => ignoreUser(context, action),
stopIgnoringUser: (action) => stopIgnoringUser(context, action),
ignoreUser: () => Promise.reject(errors.ErrNotAuthorized),
stopIgnoringUser: () => Promise.reject(errors.ErrNotAuthorized),
setUserUsernameStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserBanStatus: () => Promise.reject(errors.ErrNotAuthorized),
setUserSuspensionStatus: () => Promise.reject(errors.ErrNotAuthorized),
}
};
if (context.user && context.user.can(SET_USER_STATUS)) {
mutators.User.setUserStatus = (action) => setUserStatus(context, action);
}
if (ctx.user) {
mutators.User.ignoreUser = (action) => ignoreUser(ctx, action);
mutators.User.stopIgnoringUser = (action) => stopIgnoringUser(ctx, action);
if (context.user && context.user.can(SUSPEND_USER)) {
mutators.User.suspendUser = (action) => suspendUser(context, action);
}
if (ctx.user.can(SET_USER_USERNAME_STATUS)) {
mutators.User.setUserUsernameStatus = (id, status) => setUserUsernameStatus(ctx, id, status);
}
if (context.user && context.user.can(REJECT_USERNAME)) {
mutators.User.rejectUsername = (action) => rejectUsername(context, action);
if (ctx.user.can(SET_USER_BAN_STATUS)) {
mutators.User.setUserBanStatus = (id, status) => setUserBanStatus(ctx, id, status);
}
if (ctx.user.can(SET_USER_SUSPENSION_STATUS)) {
mutators.User.setUserSuspensionStatus = (id, until) => setUserSuspensionStatus(ctx, id, until);
}
}
return mutators;
+9 -6
View File
@@ -19,14 +19,17 @@ const RootMutation = {
deleteAction: async (_, {id}, {mutators: {Action}}) => {
await Action.delete({id});
},
setUserStatus: async (_, {id, status}, {mutators: {User}}) => {
await User.setUserStatus({id, status});
approveUsername: async (_, {id}, {mutators: {User}}) => {
await User.setUserUsernameStatus(id, 'APPROVED');
},
suspendUser: async (_, {input: {id, message, until}}, {mutators: {User}}) => {
await User.suspendUser({id, message, until});
rejectUsername: async (_, {id}, {mutators: {User}}) => {
await User.setUserUsernameStatus(id, 'REJECTED');
},
rejectUsername: async (_, {input: {id, message}}, {mutators: {User}}) => {
await User.rejectUsername({id, message});
setUserSuspensionStatus: async (_, {input: {id, until}}, {mutators: {User}}) => {
await User.setUserSuspensionStatus(id, until);
},
setUserBanStatus: async (_, {input: {id, status}}, {mutators: {User}}) => {
await User.setUserBanStatus(id, status);
},
ignoreUser: async (_, {id}, {mutators: {User}}) => {
await User.ignoreUser({id});
+6 -7
View File
@@ -6,7 +6,6 @@ const {
SEARCH_OTHERS_COMMENTS,
UPDATE_USER_ROLES,
SEARCH_COMMENT_METRICS,
VIEW_SUSPENSION_INFO,
LIST_OWN_TOKENS
} = require('../../perms/constants');
@@ -84,12 +83,12 @@ const User = {
}
},
suspension({id, suspension}, _, {user}) {
if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) {
return null;
}
return suspension;
}
// suspension({id, suspension}, _, {user}) {
// if (user.id !== id && !user.can(VIEW_SUSPENSION_INFO)) {
// return null;
// }
// return suspension;
// }
};
// Decorate the User type resolver with a tags field.
+3 -3
View File
@@ -27,13 +27,13 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
commentAdded: {
filter: (comment, context) => {
// Only priviledged users can subscribe to all assets.
// Only privileged users can subscribe to all assets.
if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))) {
return false;
}
// If user scubsscribes for statuses other than NONE and/or ACCEPTED statuses, it needs
// special priviledges.
// If user subscribes for statuses other than NONE and/or ACCEPTED statuses, it needs
// special privileges.
if (
(!args.statuses || args.statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))
+139 -55
View File
@@ -64,8 +64,99 @@ type UserProfile {
provider: String!
}
type SuspensionInfo {
# USER_STATUS_USERNAME is the different states that a username can be in.
enum USER_STATUS_USERNAME {
# UNSET is used when the username can be changed, and does not necessarily
# require moderator action to become active. This can be used when the user
# signs up with a social login and has the option of setting their own
# username.
UNSET
# SET is used when the username has been set for the first time, but cannot
# change without the username being rejected by a moderator and that moderator
# agreeing that the username should be allowed to change.
SET
# APPROVED is used when the username was changed, and subsequently approved by
# said moderator.
APPROVED
# REJECTED is used when the username was changed, and subsequently rejected by
# said moderator.
REJECTED
# CHANGED is used after a user has changed their username after it was
# rejected.
CHANGED
}
# UserStatusInput describes the queryable components of the UserStatus.
input UserStatusInput {
# username will restrict the returned users to only those with the given
# username status's. If not provided, no filtering will be performed.
username: [USER_STATUS_USERNAME!]
# banned will restrict the returned users to only those that are, or are not
# banned. If not provided, no filtering will be performed.
banned: Boolean
# suspended will restrict the returned users to only those that are, or are not
# suspended. If not provided, no filtering will be performed.
suspended: Boolean
}
type UsernameStatusHistory {
status: USER_STATUS_USERNAME!
assigned_by: User
created_at: Date!
}
type UsernameStatus {
status: USER_STATUS_USERNAME!
history: [UsernameStatusHistory!]
}
type BannedStatusHistory {
status: Boolean!
assigned_by: User
created_at: Date!
}
type BannedStatus {
status: Boolean!
history: [BannedStatusHistory!]
}
type SuspensionStatusHistory {
until: Date
assigned_by: User
created_at: Date!
}
type SuspensionStatus {
until: Date
history: [SuspensionStatusHistory!]
}
type UserStatus {
# username is the status of the username.
username: UsernameStatus!
# banned is the bool that determines if the user is banned or not.
banned: BannedStatus!
# suspension is the date that the user is suspended until.
suspension: SuspensionStatus!
}
input UserStateInput {
status: UserStatusInput
}
# UserState describes the different permission based details for a user.
type UserState {
# status describes the statuses of different aspects of the user's details.
status: UserStatus
}
# Any person who can author comments, create actions, and view comments on a
@@ -114,11 +205,7 @@ type User {
reliable: Reliability
# returns user status
status: USER_STATUS
# returns suspension info. Only available to Admins and Moderators
# or on own logged in User.
suspension: SuspensionInfo
state: UserState
}
# UserConnection represents a paginable subset of a user list.
@@ -143,8 +230,7 @@ input UsersQuery {
# Users returned will only be ones which have at least one action of this.
action_type: ACTION_TYPE
# Current status of a user..
statuses: [USER_STATUS!]
state: UserStateInput
# Limit the number of results to be returned.
limit: Int = 10
@@ -261,7 +347,7 @@ enum ACTION_TYPE {
# CommentsQuery allows the ability to query comments by a specific methods.
input CommentsQuery {
# Author of the commente
# Author of the comments.
author_id: ID
# Current status of a comment.
@@ -351,8 +437,8 @@ input UserCountQuery {
# type.
action_type: ACTION_TYPE
# Current status of a user.
statuses: [USER_STATUS]
# state queries for a specific subset of users with the given state query.
state: UserStateInput
}
type EditInfo {
@@ -810,14 +896,6 @@ enum SORT_COMMENTS_BY {
REPLIES
}
# All queries that can be executed.
enum USER_STATUS {
ACTIVE
BANNED
PENDING
APPROVED
}
# Metrics for the assets.
enum ASSET_METRICS_SORT {
@@ -995,26 +1073,13 @@ input CreateDontAgreeInput {
}
# Input for suspendUser mutation.
input SuspendUserInput {
input SetUserSuspensionStatusInput {
# id of target user.
id: ID!
# message to be sent to the user.
message: String!
# target user will be suspended until this date.
until: Date!
}
# Input for rejectUsername mutation.
input RejectUsernameInput {
# id of target user.
id: ID!
# message to be sent to the user.
message: String!
until: Date
}
# Configurable settings that can be overridden for the Asset. You must specify
@@ -1027,7 +1092,7 @@ input AssetSettingsInput {
# moderation is the moderation mode for the asset.
moderation: MODERATION_MODE
# questionBoxEnable will enable the Question Boxs' content to be visable above
# questionBoxEnable will enable the Question Boxs' content to be visible above
# the comment box.
questionBoxEnable: Boolean
@@ -1075,17 +1140,9 @@ type DeleteActionResponse implements Response {
errors: [UserError!]
}
# SetUserStatusResponse is the response returned with possibly some errors
# relating to the delete action attempt.
type SetUserStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# SuspendUserResponse is the response returned with possibly some errors
# relating to the suspend action attempt.
type SuspendUserResponse implements Response {
# SetUserSuspensionStatusResponse is the response returned with possibly some
# errors relating to the suspend action attempt.
type SetUserSuspensionStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
@@ -1218,7 +1275,7 @@ input UpdateSettingsInput {
# comment is posted that it can still be edited by the author.
editCommentWindowLength: Int
# wordlist allows chaninging the available wordlists.
# wordlist allows changing the available wordlists.
wordlist: UpdateWordlistInput
# domains allows changing the available lists of domains.
@@ -1281,6 +1338,29 @@ type RevokeTokenResponse implements Response {
errors: [UserError!]
}
# SetUserBanStatusInput contains the input to change the ban status of a given
# user.
input SetUserBanStatusInput {
# id is the user to set the ban status on.
id: ID!
# status is the ban status to set on the target user.
status: Boolean!
}
type SetUserBanStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type SetUsernameStatusResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -1299,17 +1379,21 @@ type RootMutation {
# Edit a comment
editComment(id: ID!, asset_id: ID!, edit: EditCommentInput): EditCommentResponse!
# Sets User status. Requires the `ADMIN` role.
# Sets the suspension status on a given user. Requires the `MODERATOR` role.
# Mutation is restricted.
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
setUserSuspensionStatus(input: SetUserSuspensionStatusInput!): SetUserSuspensionStatusResponse
# Suspends a user. Requires the `ADMIN` role.
# Sets the ban status on a given user. Requires the `MODERATOR` role.
# Mutation is restricted.
suspendUser(input: SuspendUserInput!): SuspendUserResponse
setUserBanStatus(input: SetUserBanStatusInput!): SetUserBanStatusResponse
# Reject a username. Requires the `ADMIN` role.
# Mutation is restricted.
rejectUsername(input: RejectUsernameInput!): RejectUsernameResponse
# Sets the username status on a given user to `APPROVED`. Requires the
# `MODERATOR` role. Mutation is restricted.
approveUsername(id: ID!): SetUsernameStatusResponse
# Sets the username status on a given user to `REJECTED`. Requires the
# `MODERATOR` role. Mutation is restricted.
rejectUsername(id: ID!): SetUsernameStatusResponse
# Sets Comment status. Requires the `ADMIN` role.
# Mutation is restricted.
@@ -1327,7 +1411,7 @@ type RootMutation {
# Updates the status of an asset allowing you to close/reopen an asset for
# commenting.
# Mutation is restricted.
# Mutation is restricted.
updateAssetStatus(id: ID!, input: UpdateAssetStatusInput!): UpdateAssetStatusResponse
# updateSettings will update the global settings.
-6
View File
@@ -1,6 +0,0 @@
module.exports = [
'ACTIVE',
'BANNED',
'PENDING',
'APPROVED' // Indicates that the users' username has been approved
];
+25
View File
@@ -0,0 +1,25 @@
module.exports = [
// UNSET is used when the username can be changed, and does not necessarily
// require moderator action to become active. This can be used when the user
// signs up with a social login and has the option of setting their own
// username.
'UNSET',
// SET is used when the username has been set for the first time, but cannot
// change without the username being rejected by a moderator and that moderator
// agreeing that the username should be allowed to change.
'SET',
// APPROVED is used when the username was changed, and subsequently approved by
// said moderator.
'APPROVED',
// REJECTED is used when the username was changed, and subsequently rejected by
// said moderator.
'REJECTED',
// CHANGED is used after a user has changed their username after it was
// rejected.
'CHANGED',
];
+107 -34
View File
@@ -10,8 +10,9 @@ const can = require('../perms');
// USER_ROLES is the array of roles that is permissible as a user role.
const USER_ROLES = require('./enum/user_roles');
// USER_STATUS is the list of statuses that are permitted for the user status.
const USER_STATUS = require('./enum/user_status');
// USER_STATUS_USERNAME is the list of statuses that are supported by storing
// the username state.
const USER_STATUS_USERNAME = require('./enum/user_status_username');
// ProfileSchema is the mongoose schema defined as the representation of a
// User's profile stored in MongoDB.
@@ -73,10 +74,6 @@ const UserSchema = new Schema({
unique: true
},
// This is true when the user account is disabled, no action should be
// acknowledged when they are disabled. Logins are also prevented.
disabled: Boolean,
// This provides a source of identity proof for users who login using the
// local provider. A local provider will be assumed for users who do not
// have any social profiles.
@@ -97,41 +94,85 @@ const UserSchema = new Schema({
enum: USER_ROLES
}],
// Status provides a string that says in which state the account is.
// When the account is banned, the user login is disabled.
// Status stores the user status information regarding permissions,
// capabilities and moderation state.
status: {
type: String,
enum: USER_STATUS,
default: 'ACTIVE'
},
// Determines whether the user can edit their username.
canEditName: {
type: Boolean,
default: false
},
// Username stores the current user status for the username as well as the
// history of changes.
username: {
// User's suspension details.
suspension: {
until: {
type: Date,
default: null,
// Status stores the current username status.
status: {
type: String,
enum: USER_STATUS_USERNAME,
},
// History stores the history of username status changes.
history: [{
// Status stores the historical username status.
status: {
type: String,
enum: USER_STATUS_USERNAME,
},
// assigned_by stores the user id of the user who assigned this status.
assigned_by: {type: String, default: null},
// created_at stores the date when this status was assigned.
created_at: {type: Date, default: Date.now}
}],
},
},
// User's settings
settings: {
bio: {
type: String,
default: ''
// Banned stores the current user banned status as well as the history of
// changes.
banned: {
// Status stores the current user banned status.
status: {
type: Boolean,
required: true,
default: false,
},
history: [{
// Status stores the historical banned status.
status: Boolean,
// assigned_by stores the user id of the user who assigned this status.
assigned_by: {type: String, default: null},
// created_at stores the date when this status was assigned.
created_at: {type: Date, default: Date.now}
}],
},
// Suspension stores the current user suspension status as well as the
// history of changes.
suspension: {
// until is the date that the user is suspended until.
until: {
type: Date,
default: null,
},
history: [{
// until is the date that the user is suspended until.
until: Date,
// assigned_by stores the user id of the user who assigned this status.
assigned_by: {type: String, default: null},
// created_at stores the date when this status was assigned.
created_at: {type: Date, default: Date.now}
}]
}
},
ignoresUsers: [{
// user id of another user
type: String,
}],
// IgnoresUsers is an array of user id's that the current user is ignoring.
ignoresUsers: [String],
// Tags are added by the self or by administrators.
tags: [TagLinkSchema],
@@ -158,7 +199,7 @@ const UserSchema = new Schema({
}
});
// Add the indixies on the user profile data.
// Add the index on the user profile data.
UserSchema.index({
'profiles.id': 1,
'profiles.provider': 1
@@ -201,6 +242,38 @@ UserSchema.method('can', function(...actions) {
return can(this, ...actions);
});
/**
* banned returns true when the user is currently banned, and sets the banned
* status locally.
*/
UserSchema.virtual('banned')
.get(function() {
return this.status.banned.status;
})
.set(function(status) {
this.status.banned.status = status;
this.status.banned.history.push({
status,
created_at: new Date()
});
});
/**
* suspended returns true when the user is currently suspended, and sets the
* suspension status locally.
*/
UserSchema.virtual('suspended')
.get(function() {
return Boolean(this.status.suspension.until && this.status.suspension.until > new Date());
})
.set(function(until) {
this.status.suspension.until = until;
this.status.suspension.history.push({
until,
created_at: new Date()
});
});
// Create the User model.
const UserModel = mongoose.model('User', UserSchema);
+24 -37
View File
@@ -6,43 +6,34 @@
"private": true,
"scripts": {
"postinstall": "./bin/cli plugins reconcile --skip-remote",
"start": "./bin/cli serve -j -w",
"dev-start": "nodemon -w . -w bin/cli -w bin/cli-serve --config .nodemon.json --exec \"yarn generate-introspection && ./bin/cli -c .env serve -j -w\"",
"prebuild": "yarn generate-introspection",
"build": "WEBPACK=TRUE NODE_ENV=production webpack -p --config webpack.config.js --bail",
"prebuild-watch": "yarn generate-introspection",
"build-watch": "WEBPACK=TRUE NODE_ENV=development webpack --progress --config webpack.config.js --watch",
"lint": "yamllint locales/*.yml && eslint --ext=.js --ext=.json bin/* .",
"lint-fix": "yarn lint --fix",
"jest-watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
"e2e-ci": "./scripts/e2e-ci.sh",
"e2e-browserstack": "NODE_ENV=test ./scripts/e2e-browserstack.js --config nightwatch-browserstack.conf.js",
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js",
"watch": "npm-run-all generate-introspection --parallel watch:*",
"watch:client": "NODE_ENV=development webpack --progress --watch",
"watch:server": "nodemon --config .nodemon.json",
"start:development": "NODE_ENV=development ./bin/cli -c .env serve -j -w",
"start:production": "NODE_ENV=production ./bin/cli serve -j -w",
"build": "NODE_ENV=production webpack -p --bail",
"lint:yaml": "yamllint locales/*.yml",
"lint:js": "eslint --ext=.js --ext=.json bin/* .",
"lint": "npm-run-all lint:*",
"plugins:reconcile": "./bin/cli plugins reconcile",
"test": "npm-run-all test:client test:server",
"test:server": "TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test:client": "TEST_MODE=unit NODE_ENV=test jest",
"test:client:watch": "TEST_MODE=unit NODE_ENV=test jest --watch",
"pree2e": "selenium-standalone install",
"pree2e:ci": "selenium-standalone install",
"pree2e:browserstack": "selenium-standalone install",
"e2e": "NODE_ENV=test nightwatch",
"test": "TEST_MODE=unit NODE_ENV=test jest && TEST_MODE=unit NODE_ENV=test mocha -R ${MOCHA_REPORTER:-spec}",
"test-cover": "TEST_MODE=unit NODE_ENV=test istanbul cover _mocha --report text --check-coverage -- -R spec",
"heroku-postbuild": "./bin/cli plugins reconcile && yarn build",
"generate-introspection": "WEBPACK=TRUE NODE_ENV=test ./scripts/generateIntrospectionResult.js"
"e2e:ci": "./scripts/e2e-ci.sh",
"e2e:browserstack": "NODE_ENV=test ./scripts/e2e-browserstack.js --config nightwatch-browserstack.conf.js",
"heroku-postbuild": "npm-run-all plugins:reconcile build"
},
"talk": {
"migration": {
"minVersion": 1496771633
}
},
"config": {
"pre-git": {
"commit-msg": [],
"pre-commit": [
"yarn lint",
"yarn test"
],
"pre-push": [
"yarn test"
],
"post-commit": [],
"post-merge": []
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/coralproject/talk.git"
@@ -122,7 +113,6 @@
"imports-loader": "^0.7.1",
"inquirer": "^3.2.2",
"ioredis": "3.1.4",
"istanbul": "^1.1.0-alpha.1",
"joi": "^10.6.0",
"json-loader": "^0.5.7",
"jsonwebtoken": "^7.4.3",
@@ -144,6 +134,7 @@
"node-emoji": "^1.8.1",
"node-fetch": "^1.7.2",
"nodemailer": "^2.6.4",
"npm-run-all": "^4.1.1",
"passport": "^0.4.0",
"passport-jwt": "^3.0.0",
"passport-local": "^1.0.0",
@@ -172,7 +163,6 @@
"redux": "^3.6.0",
"redux-thunk": "^2.1.0",
"resolve": "^1.4.0",
"selenium-standalone": "^6.11.0",
"semver": "^5.4.1",
"simplemde": "^1.11.2",
"smoothscroll-polyfill": "^0.3.5",
@@ -199,6 +189,7 @@
"browserstack-local": "^1.3.0",
"chai": "^3.5.0",
"chai-as-promised": "^6.0.0",
"chai-datetime": "^1.5.0",
"chai-http": "^3.0.0",
"enzyme": "^3.0.0",
"enzyme-adapter-react-15": "^1.0.0",
@@ -210,16 +201,12 @@
"mocha-junit-reporter": "^1.12.1",
"nightwatch": "^0.9.16",
"nodemon": "^1.11.0",
"pre-git": "^3.15.3",
"selenium-standalone": "^6.11.0",
"sinon": "^3.2.1",
"sinon-chai": "^2.13.0",
"yaml-lint": "^1.0.0"
},
"engines": {
"node": "^8"
},
"release": {
"analyzeCommits": "simple-commit-message"
},
"snyk": true
}
}
+3 -3
View File
@@ -4,9 +4,9 @@ module.exports = {
DELETE_ACTION: 'DELETE_ACTION',
EDIT_NAME: 'EDIT_NAME',
EDIT_COMMENT: 'EDIT_COMMENT',
REJECT_USERNAME: 'REJECT_USERNAME',
SET_USER_STATUS: 'SET_USER_STATUS',
SUSPEND_USER: 'SUSPEND_USER',
SET_USER_USERNAME_STATUS: 'SET_USER_USERNAME_STATUS',
SET_USER_BAN_STATUS: 'SET_USER_BAN_STATUS',
SET_USER_SUSPENSION_STATUS: 'SET_USER_SUSPENSION_STATUS',
SET_COMMENT_STATUS: 'SET_COMMENT_STATUS',
ADD_COMMENT_TAG: 'ADD_COMMENT_TAG',
REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG',
+4 -7
View File
@@ -5,16 +5,13 @@ const subscription = require('./subscription');
module.exports = [
(user /* , perm*/) => {
// this runs before everything
if (
user.status === 'BANNED' ||
(user.suspension.until && user.suspension.until > new Date())
) {
// If a user is banned or currently suspended, then they aren't allowed to
// do anything.
if (user.banned || user.suspended) {
return false;
}
},
query,
mutation,
subscription,
]
;
];
+9 -3
View File
@@ -8,23 +8,29 @@ module.exports = (user, perm) => {
case types.DELETE_ACTION:
case types.EDIT_NAME:
case types.EDIT_COMMENT:
// Anyone can do these things if they aren't suspended or banned.
return true;
case types.ADD_COMMENT_TAG:
case types.REMOVE_COMMENT_TAG:
return check(user, ['ADMIN', 'MODERATOR', 'STAFF']);
case types.UPDATE_USER_ROLES:
case types.REJECT_USERNAME:
case types.SET_USER_STATUS:
case types.SUSPEND_USER:
case types.SET_COMMENT_STATUS:
case types.SET_USER_USERNAME_STATUS:
case types.SET_USER_BAN_STATUS:
case types.SET_USER_SUSPENSION_STATUS:
case types.UPDATE_CONFIG:
case types.UPDATE_SETTINGS:
case types.UPDATE_ASSET_SETTINGS:
case types.UPDATE_ASSET_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.CREATE_TOKEN:
case types.REVOKE_TOKEN:
return check(user, ['ADMIN']);
default:
break;
}
+4 -10
View File
@@ -4,25 +4,19 @@ const types = require('../constants');
module.exports = (user, perm) => {
switch (perm) {
case types.SEARCH_ASSETS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_OTHER_USERS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_ACTIONS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_OTHERS_COMMENTS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_METRICS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
case types.SEARCH_COMMENT_STATUS_HISTORY:
return check(user, ['ADMIN', 'MODERATOR']);
case types.VIEW_SUSPENSION_INFO:
return check(user, ['ADMIN', 'MODERATOR']);
case types.VIEW_PROTECTED_SETTINGS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.LIST_OWN_TOKENS:
return check(user, ['ADMIN']);
default:
break;
}
+1 -7
View File
@@ -4,21 +4,15 @@ const types = require('../constants');
module.exports = (user, perm) => {
switch (perm) {
case types.SUBSCRIBE_COMMENT_FLAGGED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_COMMENT_ACCEPTED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_COMMENT_REJECTED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_EDITED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_COMMENT_ADDED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USER_SUSPENDED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USER_BANNED:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUBSCRIBE_ALL_USERNAME_REJECTED:
return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
-9
View File
@@ -98,13 +98,4 @@ router.put('/password/reset', async (req, res, next) => {
}
});
router.put('/username', authorization.needed(), async (req, res, next) => {
try {
await UsersService.editName(req.user.id, req.body.username);
res.status(204).end();
} catch (e) {
return next(e);
}
});
module.exports = router;
+3 -77
View File
@@ -4,11 +4,7 @@ const UsersService = require('../../../services/users');
const mailer = require('../../../services/mailer');
const errors = require('../../../errors');
const authorization = require('../../../middleware/authorization');
const i18n = require('../../../services/i18n');
const Limit = require('../../../services/limit');
const {
ROOT_URL
} = require('../../../config');
router.get('/', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
@@ -60,36 +56,6 @@ router.post('/:user_id/role', authorization.needed('ADMIN', 'MODERATOR'), async
}
});
// update the status of a user
router.post('/:user_id/status', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
let {status} = req.body;
try {
let user = await UsersService.setStatus(req.params.user_id, status);
if (!user) {
return next(errors.ErrNotFound);
}
if (user.status === 'BANNED') {
req.pubsub.publish('userBanned', user);
}
// TODO: investigate why this is returning a value? Also why is this a POST vs PUT?
res.status(201).json(user.status);
} catch (e) {
next(e);
}
});
router.post('/:user_id/username-enable', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
try {
await UsersService.toggleNameEdit(req.params.user_id, true);
res.status(204).end();
} catch (e) {
next(e);
}
});
router.post('/:user_id/email', authorization.needed('ADMIN', 'MODERATOR'), async (req, res, next) => {
try {
let user = await UsersService.findById(req.params.user_id);
@@ -114,26 +80,6 @@ router.post('/:user_id/email', authorization.needed('ADMIN', 'MODERATOR'), async
}
});
/**
* SendEmailConfirmation sends a confirmation email to the user.
* @param {String} userID the id for the user to send the email to
* @param {String} email the email for the user to send the email to
*/
const SendEmailConfirmation = async (user, email, referer) => {
let token = await UsersService.createEmailConfirmToken(user, email, referer);
return mailer.sendSimple({
template: 'email-confirm',
locals: {
token,
rootURL: ROOT_URL,
email
},
subject: i18n.t('email.confirm.subject'),
to: email
});
};
// create a local user.
router.post('/', async (req, res, next) => {
const {email, password, username} = req.body;
@@ -144,7 +90,7 @@ router.post('/', async (req, res, next) => {
// Send an email confirmation. The Front end will know about the
// requireEmailConfirmation as it's included in the settings get endpoint.
await SendEmailConfirmation(user, email, redirectUri);
await UsersService.sendEmailConfirmation(user, email, redirectUri);
res.status(201).json(user);
} catch (e) {
@@ -152,26 +98,6 @@ router.post('/', async (req, res, next) => {
}
});
router.post('/:user_id/actions', authorization.needed(), async (req, res, next) => {
const {
action_type,
metadata
} = req.body;
try {
let action = await UsersService.addAction(req.params.user_id, req.user.id, action_type, metadata);
// Set the user status to "pending" for review by moderators
if (action_type === 'FLAG') {
await UsersService.setStatus(req.params.user_id, 'PENDING');
}
res.status(201).json(action);
} catch (e) {
return next(e);
}
});
// This will allow 1 try every minute.
const resendRateLimiter = new Limit('/api/v1/users/resend-verify', 1, '1m');
@@ -205,7 +131,7 @@ router.post('/resend-verify', async (req, res, next) => {
throw errors.ErrNotFound;
}
await SendEmailConfirmation(user, email, redirectUri);
await UsersService.sendEmailConfirmation(user, email, redirectUri);
res.status(204).end();
} catch (e) {
@@ -234,7 +160,7 @@ router.post('/:user_id/email/confirm', authorization.needed('ADMIN', 'MODERATOR'
}
// Send the email to the first local profile that was found.
await SendEmailConfirmation(user, localProfile.id);
await UsersService.sendEmailConfirmation(user, localProfile.id);
res.status(204).end();
} catch (e) {
+45 -30
View File
@@ -12,6 +12,7 @@ const {createGraphOptions} = require('../graph');
const accepts = require('accepts');
const apollo = require('graphql-server-express');
const {DISABLE_STATIC_SERVER} = require('../config');
const SetupService = require('../services/setup');
const router = express.Router();
@@ -19,29 +20,29 @@ const router = express.Router();
// STATIC FILES
//==============================================================================
// If the application is in production mode, then add gzip rewriting for the
// content.
if (process.env.NODE_ENV === 'production') {
router.get('*.js', (req, res, next) => {
const accept = accepts(req);
if (accept.encoding(['gzip']) === 'gzip') {
// Adjsut the headers on the request by adding a content type header
// because express won't be able to detect the mime-type with the .gz
// extension and we need to decalre support for the gzip encoding.
res.set('Content-Type', 'application/javascript');
res.set('Content-Encoding', 'gzip');
// Rewrite the url so that the gzip version will be served instead.
req.url = `${req.url}.gz`;
}
next();
});
}
if (!DISABLE_STATIC_SERVER) {
// If the application is in production mode, then add gzip rewriting for the
// content.
if (process.env.NODE_ENV === 'production') {
router.get('*.js', (req, res, next) => {
const accept = accepts(req);
if (accept.encoding(['gzip']) === 'gzip') {
// Adjust the headers on the request by adding a content type header
// because express won't be able to detect the mime-type with the .gz
// extension and we need to declare support for the gzip encoding.
res.set('Content-Type', 'application/javascript');
res.set('Content-Encoding', 'gzip');
// Rewrite the url so that the gzip version will be served instead.
req.url = `${req.url}.gz`;
}
next();
});
}
/**
* Serve the directories under public/dist from this router.
*/
@@ -100,7 +101,7 @@ if (process.env.NODE_ENV !== 'production') {
});
});
// GraphQL documention.
// GraphQL documentation.
router.get('/admin/docs', (req, res) => {
res.render('admin/docs');
});
@@ -118,14 +119,28 @@ router.use('/embed', require('./embed'));
if (process.env.NODE_ENV !== 'production') {
router.use('/assets', require('./assets'));
router.get('/', (req, res) => {
return res.render('article', {
title: 'Coral Talk',
asset_url: '',
asset_id: '',
body: '',
basePath: '/client/embed/stream'
});
router.get('/', async (req, res) => {
try {
await SetupService.isAvailable();
return res.redirect('/admin/install');
} catch (e) {
return res.render('article', {
title: 'Coral Talk',
asset_url: '',
asset_id: '',
body: '',
basePath: '/client/embed/stream'
});
}
});
} else {
router.get('/', async (req, res, next) => {
try {
await SetupService.isAvailable();
return res.redirect('/admin/install');
} catch (e) {
return res.redirect('/admin');
}
});
}
+2 -2
View File
@@ -29,7 +29,7 @@ if [[ "${CIRCLE_BRANCH}" == "master" ]]; then
echo "-- Start e2e for $1 #$try --"
REPORTS_FOLDER="$CIRCLE_TEST_REPORTS/$1" yarn e2e-browserstack --env "$1"
REPORTS_FOLDER="$CIRCLE_TEST_REPORTS/$1" yarn test:e2e:browserstack --env "$1"
# Determine exit code.
result=$?
@@ -85,7 +85,7 @@ if [[ "${CIRCLE_BRANCH}" == "master" ]]; then
exit $exitCode
else
# When browserstack is not available test locally using chrome headless.
REPORTS_FOLDER="$CIRCLE_TEST_REPORTS/chrome" yarn e2e -- --env chrome-headless
REPORTS_FOLDER="$CIRCLE_TEST_REPORTS/chrome" yarn test:e2e --env chrome-headless
# Will exit with status of last command.
exit $?
+2 -2
View File
@@ -82,7 +82,7 @@ async function onListening() {
let bind = typeof addr === 'string'
? `pipe ${addr}`
: `port ${addr.port}`;
debug(`API Server Listening on ${bind}`);
console.log(`API Server Listening on ${bind}`);
}
/**
@@ -141,7 +141,7 @@ async function serve({jobs = true, websockets = true} = {}) {
// Mount the websocket server if requested.
if (websockets) {
debug(`Websocket Server Listening on ${port}`);
console.log(`Websocket Server Listening on ${port}`);
// Mount the subscriptions server on the application server.
createSubscriptionManager(server);
+2 -2
View File
@@ -1,7 +1,7 @@
const CommentModel = require('../models/comment');
const AssetModel = require('../models/asset');
const SettingsService = require('./settings');
const domainlist = require('./domainlist');
const DomainList = require('./domain_list');
const errors = require('../errors');
const merge = require('lodash/merge');
@@ -64,7 +64,7 @@ module.exports = class AssetsService {
// Check the URL to confirm that is in the domain whitelist
return Promise.all([
domainlist.urlCheck(url),
DomainList.urlCheck(url),
SettingsService.retrieve()
]).then(([whitelisted, settings]) => {
@@ -1,4 +1,4 @@
const debug = require('debug')('talk:services:domainlist');
const debug = require('debug')('talk:services:domain_list');
const _ = require('lodash');
const SettingsService = require('./settings');
@@ -8,7 +8,7 @@ const {ROOT_URL} = require('../config');
* The root domainlist object.
* @type {Object}
*/
class Domainlist {
class DomainList {
constructor() {
this.lists = {
@@ -35,7 +35,7 @@ class Domainlist {
return;
}
this.lists.whitelist = Domainlist.parseList(lists.whitelist);
this.lists.whitelist = DomainList.parseList(lists.whitelist);
debug(`Added ${lists.whitelist.length} domains to the whitelist.`);
}
@@ -47,7 +47,7 @@ class Domainlist {
match(list, url) {
// Parse the url that we're matching with.
const domainToMatch = Domainlist.parseURL(url);
const domainToMatch = DomainList.parseURL(url);
// This will return true in the event that at least one blockword is found
// in the phrase.
@@ -61,7 +61,7 @@ class Domainlist {
* @returns {Boolean} true if the domains match
*/
static matchMount(url) {
return Domainlist.parseURL(url) === Domainlist.parseURL(ROOT_URL);
return DomainList.parseURL(url) === DomainList.parseURL(ROOT_URL);
}
/**
@@ -70,7 +70,7 @@ class Domainlist {
* @return {Array} the parsed list
*/
static parseList(list) {
return _.uniq(list.map((domain) => Domainlist.parseURL(domain)));
return _.uniq(list.map((domain) => DomainList.parseURL(domain)));
}
/**
@@ -95,7 +95,7 @@ class Domainlist {
}
static async urlCheck(url) {
const dl = new Domainlist();
const dl = new DomainList();
// Load the domain list.
await dl.load();
@@ -106,4 +106,4 @@ class Domainlist {
}
module.exports = Domainlist;
module.exports = DomainList;
+42 -251
View File
@@ -1,4 +1,3 @@
const assert = require('assert');
const uuid = require('uuid');
const bcrypt = require('bcryptjs');
const errors = require('../errors');
@@ -15,7 +14,6 @@ const {
const debug = require('debug')('talk:services:users');
const UserModel = require('../models/user');
const USER_STATUS = require('../models/enum/user_status');
const USER_ROLES = require('../models/enum/user_roles');
const RECAPTCHA_WINDOW = '10m'; // 10 minutes.
@@ -23,8 +21,9 @@ const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha
const ActionsService = require('./actions');
const MailerService = require('./mailer');
const i18n = require('./i18n');
const Wordlist = require('./wordlist');
const Domainlist = require('./domainlist');
const DomainList = require('./domain_list');
const {escapeRegExp} = require('./regex');
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
@@ -181,13 +180,40 @@ module.exports = class UsersService {
lowercaseUsername: username.toLowerCase(),
roles: [],
profiles: [{id, provider}],
canEditName: true
status: {
username: {
status: 'UNSET',
history: {
status: 'UNSET'
}
}
}
});
return user.save();
});
}
/**
* sendEmailConfirmation sends a confirmation email to the user.
* @param {String} user the user to send the email to
* @param {String} email the email for the user to send the email to
*/
static async sendEmailConfirmation(user, email, redirectURI = ROOT_URL) {
let token = await UsersService.createEmailConfirmToken(user, email, redirectURI);
return MailerService.sendSimple({
template: 'email-confirm',
locals: {
token,
rootURL: ROOT_URL,
email
},
subject: i18n.t('email.confirm.subject'),
to: email
});
}
static async changePassword(id, password) {
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
@@ -289,7 +315,15 @@ module.exports = class UsersService {
id: email,
provider: 'local'
}
]
],
status: {
username: {
status: 'SET',
history: {
status: 'SET'
}
}
}
});
try {
@@ -307,36 +341,6 @@ module.exports = class UsersService {
return user;
}
/**
* Disables a given user account.
* @param {String} id id of a user
* @param {Function} done callback after the operation is complete
*/
static disableUser(id) {
return UserModel.update({
id
}, {
$set: {
disabled: true
}
});
}
/**
* Enables a given user account.
* @param {String} id id of a user
* @param {Function} done callback after the operation is complete
*/
static enableUser(id) {
return UserModel.update({
id
}, {
$set: {
disabled: false
}
});
}
/**
* Adds a role to a user.
* @param {String} id id of a user
@@ -380,131 +384,6 @@ module.exports = class UsersService {
});
}
/**
* Set status of a user.
* @param {String} id id of a user
* @param {String} status status to set
* @param {Function} done callback after the operation is complete
*/
static async setStatus(id, status) {
// Check to see if the user status is in the allowable set of roles.
if (USER_STATUS.indexOf(status) === -1) {
// User status is not supported! Error out here.
throw new Error(`status ${status} is not supported`);
}
// TODO: current updating status behavior is weird.
// once a user has been `APPROVED` its status cannot be
// changed anymore.
const user = await UserModel.findOneAndUpdate({
id,
status: {
$ne: 'APPROVED'
}
}, {
$set: {
status
}
}, {
new: true,
});
if (status === 'BANNED') {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'banned', // needed to know which template to render!
locals: { // specifies the template locals.
body: 'In accordance with The Coral Projects community guidelines, your account has been banned. You are now longer allowed to comment, flag or engage with our community.'
},
subject: 'Your account has been banned',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
await MailerService.sendSimple(options);
}
}
return user;
}
/**
* Suspend a user until specified time.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static async suspendUser(id, message, until) {
const user = await UserModel.findOneAndUpdate({id}, {
$set: {
suspension: {
until,
},
}
}, {
new: true,
});
if (message) {
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
if (localProfile) {
const options =
{
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Your account has been suspended',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
await MailerService.sendSimple(options);
}
}
return user;
}
/**
* Reject username. It changes the status to BANNED and canEditName to True.
* @param {String} id id of a user
* @param {String} message message to be send to the user
* @param {Date} until date until the suspension is valid.
*/
static async rejectUsername(id, message) {
const user = await UserModel.findOneAndUpdate({id}, {
$set: {
status: 'BANNED',
canEditName: true,
}
}, {
new: true,
});
if (message) {
let localProfile = user.profiles.find(({provider}) => provider === 'local');
if (localProfile) {
const options = {
template: 'suspension', // needed to know which template to render!
locals: { // specifies the template locals.
body: message
},
subject: 'Email Suspension',
to: localProfile.id // This only works if the user has registered via e-mail.
// We may want a standard way to access a user's e-mail address in the future
};
await MailerService.sendSimple(options);
}
}
return user;
}
/**
* Finds a user with the id.
* @param {String} id user id (uuid)
@@ -567,7 +446,7 @@ module.exports = class UsersService {
const [user, domainValidated] = await Promise.all([
UserModel.findOne({profiles: {$elemMatch: {id: email}}}),
Domainlist.urlCheck(loc),
DomainList.urlCheck(loc),
]);
if (!user) {
@@ -579,7 +458,7 @@ module.exports = class UsersService {
// If the domain didn't match any of the whitelisted domains and if it
// didn't match the mount domain, then throw an error.
if (!domainValidated && !Domainlist.matchMount(loc)) {
if (!domainValidated && !DomainList.matchMount(loc)) {
throw new Error('user supplied location exists on non-permitted domain');
}
@@ -727,7 +606,7 @@ module.exports = class UsersService {
*/
static async createEmailConfirmToken(user, email, referer = ROOT_URL) {
if (!email || typeof email !== 'string') {
throw new Error('email is required when creating a JWT for resetting passord');
throw new Error('email is required when creating a JWT for resetting password');
}
// Conform the email to lowercase.
@@ -791,97 +670,12 @@ module.exports = class UsersService {
});
}
/**
* Returns all users with pending 'ADMIN'ation actions.
* @return {Promise}
*/
static moderationQueue() {
return UserModel.find({status: 'PENDING'});
}
/**
* Gives the user the ability to edit their username.
* @param {String} id the id of the user to be toggled.
* @param {Boolean} canEditName sets whether the user can edit their name.
* @return {Promise}
*/
static toggleNameEdit(id, canEditName) {
return UserModel.update({id}, {
$set: {canEditName}
});
}
/**
* Updates the user's username.
* @param {String} id The id of the user.
* @param {String} username The new username for the user.
* @return {Promise}
*/
static async editName(id, username) {
// TODO: Revisit this when we revamped User status workflows.
const queryUsernameRejected = {
id,
username: {$ne: username},
status: 'BANNED',
canEditName: true
};
const queryCreateUsername = {
id,
status: 'ACTIVE',
canEditName: true
};
try {
const result = await UserModel.findOneAndUpdate({
$or: [queryUsernameRejected, queryCreateUsername],
}, {
$set: {
username: username,
lowercaseUsername: username.toLowerCase(),
canEditName: false,
status: 'PENDING',
}
}, {
new: true,
});
if (!result) {
const user = await UsersService.findById(id);
if (user === null) {
throw errors.ErrNotFound;
}
if (!user.canEditName) {
throw errors.ErrPermissionUpdateUsername;
}
if (user.username === username) {
throw errors.ErrSameUsernameProvided;
}
throw new Error('edit username failed for an unexpected reason');
}
return result;
}
catch(err) {
if (err.code === 11000) {
throw errors.ErrUsernameTaken;
}
throw err;
}
}
/**
* Ignore another user
* @param {String} id the id of the user that is ignoring another users
* @param {Array<String>} usersToIgnore Array of user IDs to ignore
*/
static async ignoreUsers(id, usersToIgnore) {
assert(Array.isArray(usersToIgnore), 'usersToIgnore is an array');
assert(usersToIgnore.every((u) => typeof u === 'string'), 'usersToIgnore is an array of string user IDs');
if (usersToIgnore.includes(id)) {
throw new Error('Users cannot ignore themselves');
}
@@ -891,7 +685,6 @@ module.exports = class UsersService {
throw errors.ErrCannotIgnoreStaff;
}
// TODO: For each usersToIgnore, make sure they exist?
return UserModel.update({id}, {
$addToSet: {
ignoresUsers: {
@@ -907,8 +700,6 @@ module.exports = class UsersService {
* @param {Array<String>} usersToStopIgnoring Array of user IDs to stop ignoring
*/
static async stopIgnoringUsers(id, usersToStopIgnoring) {
assert(Array.isArray(usersToStopIgnoring), 'usersToStopIgnoring is an array');
assert(usersToStopIgnoring.every((u) => typeof u === 'string'), 'usersToStopIgnoring is an array of string user IDs');
await UserModel.update({id}, {
$pullAll: {
ignoresUsers: usersToStopIgnoring
+1 -2
View File
@@ -26,7 +26,6 @@ beforeEach(async () => {
}));
});
after(function(done) {
after(async function() {
mongoose.disconnect();
done();
});
+3
View File
@@ -0,0 +1,3 @@
process.on('unhandledRejection', function(reason, promise) {
console.error(promise);
});
+1 -1
View File
@@ -48,7 +48,7 @@ describe('graph.mutations.addTag', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
'banned moderator': new UserModel({roles: ['MODERATOR'], banned: true})
}).forEach(([ userDescription, user ]) => {
it(userDescription, async () => {
const context = new Context({user});
+55 -61
View File
@@ -71,27 +71,28 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => AssetModel.create({id: '123'}));
[
{user: new UserModel({status: 'ACTIVE'}), error: null},
{user: new UserModel({status: 'BANNED'}), error: 'NOT_AUTHORIZED'},
{user: new UserModel({status: 'PENDING'}), error: null},
{user: new UserModel({status: 'APPROVED'}), error: null}
{user: new UserModel({}), error: null},
{user: new UserModel({banned: true}), error: 'NOT_AUTHORIZED'},
{user: new UserModel({suspended: new Date((new Date()).getTime() - (10 * 86400000))}), error: null},
{user: new UserModel({suspended: new Date((new Date()).getTime() + (10 * 86400000))}), error: 'NOT_AUTHORIZED'},
].forEach(({user, error}) => {
describe(`user.status=${user.status}`, () => {
it(error ? 'does not create the comment' : 'creates the comment', () => {
describe(`user.banned=${user.banned} user.suspended=${user.suspended}`, () => {
it(error ? 'does not create the comment' : 'creates the comment', async () => {
const context = new Context({user});
const {data, errors} = await graphql(schema, query, {}, context);
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
expect(errors).to.be.undefined;
if (error) {
expect(data.createComment).to.have.property('comment').null;
expect(data.createComment).to.have.property('errors').not.null;
expect(data.createComment.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
}
});
expect(errors).to.be.undefined;
if (error) {
expect(data.createComment).to.have.property('comment').null;
expect(data.createComment).to.have.property('errors').not.null;
expect(data.createComment.errors[0]).to.have.property('translation_key', error);
} else {
if (data.createComment.errors && data.createComment.errors.length > 0) {
console.error(data.createComment.errors);
}
expect(data.createComment).to.have.property('errors').null;
expect(data.createComment).to.have.property('comment').not.null;
}
});
});
});
@@ -109,7 +110,7 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => asset.save());
it(error ? 'does not create the comment' : 'creates the comment', () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
const context = new Context({user: new UserModel({})});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
@@ -142,7 +143,7 @@ describe('graph.mutations.createComment', () => {
beforeEach(() => AssetModel.create({id: '123', settings: {moderation}}));
it(`creates comment with status=${status}`, () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
const context = new Context({user: new UserModel()});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
@@ -172,33 +173,30 @@ describe('graph.mutations.createComment', () => {
].forEach(({message, body, status, flagged}) => {
describe(message, () => {
it(`should create a comment with status=${status} and it ${flagged ? 'should' : 'should not'} be flagged`, () => {
const context = new Context({user: new UserModel({status: 'ACTIVE'})});
it(`should create a comment with status=${status} and it ${flagged ? 'should' : 'should not'} be flagged`, async () => {
const context = new Context({user: new UserModel({})});
return graphql(schema, query, {}, context, {
const {data, errors} = await graphql(schema, query, {}, context, {
input: {
asset_id: '123',
body
}
})
.then(({data, errors}) => {
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment.comment).to.have.property('status', status);
expect(data.createComment).to.have.property('errors').null;
});
return ActionModel.find({
item_id: data.createComment.comment.id,
action_type: 'FLAG'
});
})
.then((actions) => {
if (flagged) {
expect(actions).to.have.length(1);
} else {
expect(actions).to.have.length(0);
}
});
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment.comment).to.have.property('status', status);
expect(data.createComment).to.have.property('errors').null;
const actions = await ActionModel.find({
item_id: data.createComment.comment.id,
action_type: 'FLAG'
});
if (flagged) {
expect(actions).to.have.length(1);
} else {
expect(actions).to.have.length(0);
}
});
});
@@ -217,30 +215,26 @@ describe('graph.mutations.createComment', () => {
].forEach(({roles, tag}) => {
describe(`user.roles=${JSON.stringify(roles)}`, () => {
it(`creates comment ${tag ? `with tag=${tag}` : 'without tags'}`, () => {
it(`creates comment ${tag ? `with tag=${tag}` : 'without tags'}`, async () => {
const context = new Context({user: new UserModel({roles})});
return graphql(schema, query, {}, context)
.then(({data, errors}) => {
if (errors) {
console.error(errors);
}
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
const {data, errors} = await graphql(schema, query, {}, context);
return CommentsService.findById(data.createComment.comment.id);
})
.then(({tags}) => {
if (tag) {
expect(tags).to.have.length(1);
expect(tags[0].tag.name).to.have.equal(tag);
} else {
expect(tags).length(0);
}
});
if (errors) {
console.error(errors);
}
expect(errors).to.be.undefined;
expect(data.createComment).to.have.property('comment').not.null;
expect(data.createComment).to.have.property('errors').null;
const {tags} = await CommentsService.findById(data.createComment.comment.id);
if (tag) {
expect(tags).to.have.length(1);
expect(tags[0].tag.name).to.have.equal(tag);
} else {
expect(tags).length(0);
}
});
});
});
+1 -1
View File
@@ -66,7 +66,7 @@ describe('graph.mutations.removeTag', () => {
Object.entries({
'anonymous': undefined,
'regular commenter': new UserModel({}),
'banned moderator': new UserModel({roles: ['MODERATOR'], status: 'BANNED'})
'banned moderator': new UserModel({roles: ['MODERATOR'], banned: true})
}).forEach(([userDescription, user]) => {
it(userDescription, async function () {
const context = new Context({user});
@@ -0,0 +1,104 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const SettingsService = require('../../../../services/settings');
const UserModel = require('../../../../models/user');
const UsersService = require('../../../../services/users');
const {expect} = require('chai');
describe('graph.mutations.setUserBanStatus', () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
});
const setUserBanStatusMutation = `
mutation SetUserBanStatus($user_id: ID!, $status: Boolean!) {
setUserBanStatus(input: {
id: $user_id,
status: $status
}) {
errors {
translation_key
}
}
}
`;
[
{self: true, error: 'NOT_AUTHORIZED', roles: null},
{self: true, error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{self: true, error: 'NOT_AUTHORIZED', roles: []},
{error: 'NOT_AUTHORIZED', roles: null},
{error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{error: 'NOT_AUTHORIZED', roles: []},
{error: false, roles: ['MODERATOR']},
{error: false, roles: ['ADMIN']},
{error: false, roles: ['ADMIN', 'MODERATOR']},
].forEach(({self, error, roles}) => {
it(`${error ? 'can not' : 'can'} ban ${self ? 'themself' : 'another user'} as a user with roles ${roles && roles.length ? roles : JSON.stringify(roles)}`, async () => {
const actor = new UserModel({roles});
// If we're testing self assign, set the id of the actor to the user
// we're acting on.
if (self) {
actor.id = user.id;
}
const ctx = new Context({user: actor});
const {data, errors} = await graphql(schema, setUserBanStatusMutation, {}, ctx, {
user_id: user.id,
status: true
});
if (errors && errors.length > 0) {
console.error(errors);
}
expect(errors).to.be.undefined;
if (error) {
expect(data.setUserBanStatus).to.have.property('errors').not.null;
expect(data.setUserBanStatus.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.setUserBanStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
expect(user.status.banned.status).to.be.true;
expect(user.status.banned.history).to.have.length(1);
expect(user.status.banned.history[0]).to.have.property('status', true);
expect(user.status.banned.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.banned.history[0]).to.have.property('created_at').not.null;
expect(user.banned).to.be.true;
const res = await graphql(schema, setUserBanStatusMutation, {}, ctx, {
user_id: user.id,
status: false
});
if (res.errors && res.errors.length > 0) {
console.error(res.errors);
}
expect(res.errors).to.be.undefined;
expect(res.data.setUserBanStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
expect(user.status.banned.status).to.be.false;
expect(user.status.banned.history).to.have.length(2);
expect(user.status.banned.history[0]).to.have.property('status').to.be.true;
expect(user.status.banned.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.banned.history[0]).to.have.property('created_at').not.null;
expect(user.status.banned.history[1]).to.have.property('status').to.be.false;
expect(user.status.banned.history[1]).to.have.property('assigned_by', actor.id);
expect(user.status.banned.history[1]).to.have.property('created_at').not.null;
expect(user.banned).to.be.false;
}
});
});
});
@@ -0,0 +1,115 @@
const {graphql} = require('graphql');
const timekeeper = require('timekeeper');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const SettingsService = require('../../../../services/settings');
const UserModel = require('../../../../models/user');
const UsersService = require('../../../../services/users');
const chai = require('chai');
chai.use(require('chai-datetime'));
const {expect} = chai;
describe('graph.mutations.setUserSuspensionStatus', () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
});
const setUserSuspensionStatusMutation = `
mutation SetUserUsernameStatus($user_id: ID!, $until: Date) {
setUserSuspensionStatus(input: {
id: $user_id,
until: $until
}) {
errors {
translation_key
}
}
}
`;
[
{self: true, error: 'NOT_AUTHORIZED', roles: null},
{self: true, error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{self: true, error: 'NOT_AUTHORIZED', roles: []},
{error: 'NOT_AUTHORIZED', roles: null},
{error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{error: 'NOT_AUTHORIZED', roles: []},
{error: false, roles: ['MODERATOR']},
{error: false, roles: ['ADMIN']},
{error: false, roles: ['ADMIN', 'MODERATOR']},
].forEach(({self, error, roles}) => {
it(`${error ? 'can not' : 'can'} suspend ${self ? 'themself' : 'another user'} as a user with roles ${roles && roles.length ? roles : JSON.stringify(roles)}`, async () => {
const actor = new UserModel({roles});
// If we're testing self assign, set the id of the actor to the user
// we're acting on.
if (self) {
actor.id = user.id;
}
const ctx = new Context({user: actor});
const now = new Date();
const oneHourFromNow = new Date(new Date(now).setHours(now.getHours() + 1));
const {data, errors} = await graphql(schema, setUserSuspensionStatusMutation, {}, ctx, {
user_id: user.id,
until: oneHourFromNow
});
if (errors && errors.length > 0) {
console.error(errors);
}
expect(errors).to.be.undefined;
if (error) {
expect(data.setUserSuspensionStatus).to.have.property('errors').not.null;
expect(data.setUserSuspensionStatus.errors[0]).to.have.property('translation_key', error);
} else {
expect(data.setUserSuspensionStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
// Mongoose messes with the date, check within a 2 second window.
expect(user.status.suspension.until).to.be.withinTime(new Date(oneHourFromNow.getTime() - 1000), new Date(oneHourFromNow.getTime() + 1000));
expect(user.status.suspension.history).to.have.length(1);
expect(user.status.suspension.history[0]).to.have.property('until').to.be.withinTime(new Date(oneHourFromNow.getTime() - 1000), new Date(oneHourFromNow.getTime() + 1000));
expect(user.status.suspension.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.suspension.history[0]).to.have.property('created_at').not.null;
expect(user.suspended).to.be.true;
timekeeper.travel(new Date(oneHourFromNow.getTime() + 10000));
expect(user.suspended).to.be.false;
timekeeper.reset();
const res = await graphql(schema, setUserSuspensionStatusMutation, {}, ctx, {
user_id: user.id,
until: null
});
if (res.errors && res.errors.length > 0) {
console.error(res.errors);
}
expect(res.errors).to.be.undefined;
expect(res.data.setUserSuspensionStatus).to.be.null;
user = await UserModel.findOne({id: user.id});
// Mongoose messes with the date, check within a 2 second window.
expect(user.status.suspension.until).to.be.null;
expect(user.status.suspension.history).to.have.length(2);
expect(user.status.suspension.history[0]).to.have.property('until').to.be.withinTime(new Date(oneHourFromNow.getTime() - 1000), new Date(oneHourFromNow.getTime() + 1000));
expect(user.status.suspension.history[0]).to.have.property('assigned_by', actor.id);
expect(user.status.suspension.history[0]).to.have.property('created_at').not.null;
expect(user.status.suspension.history[1]).to.have.property('until').to.be.null;
expect(user.status.suspension.history[1]).to.have.property('assigned_by', actor.id);
expect(user.status.suspension.history[1]).to.have.property('created_at').not.null;
expect(user.suspended).to.be.false;
}
});
});
});
@@ -0,0 +1,87 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const SettingsService = require('../../../../services/settings');
const UserModel = require('../../../../models/user');
const UsersService = require('../../../../services/users');
const chai = require('chai');
chai.use(require('chai-datetime'));
const {expect} = chai;
[
{status: 'APPROVED', name: 'approve', mutation: 'approveUsername'},
{status: 'REJECTED', name: 'reject', mutation: 'rejectUsername'}
].forEach(({status, name, mutation}) => {
describe(`graph.mutations.${mutation}`, () => {
let user;
beforeEach(async () => {
await SettingsService.init();
user = await UsersService.createLocalUser('usernameA@example.com', 'password', 'usernameA');
});
const setUserUsernameStatusMutation = `
mutation SetUserUsernameStatus($user_id: ID!) {
${mutation}(id: $user_id) {
errors {
translation_key
}
}
}
`;
[
{self: true, error: 'NOT_AUTHORIZED', roles: null},
{self: true, error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{self: true, error: 'NOT_AUTHORIZED', roles: []},
{error: 'NOT_AUTHORIZED', roles: null},
{error: 'NOT_AUTHORIZED', roles: ['STAFF']},
{error: 'NOT_AUTHORIZED', roles: []},
{error: false, roles: ['MODERATOR']},
{error: false, roles: ['ADMIN']},
{error: false, roles: ['ADMIN', 'MODERATOR']},
].forEach(({self, error, roles}) => {
it(`${error ? 'can not' : 'can'} ${name} a username with the user roles ${roles && roles.length ? roles : JSON.stringify(roles)}${self ? ' on themself' : ''}`, async () => {
const actor = new UserModel({roles});
// If we're testing self assign, set the id of the actor to the user
// we're acting on.
if (self) {
actor.id = user.id;
}
const ctx = new Context({user: actor});
const {data, errors} = await graphql(schema, setUserUsernameStatusMutation, {}, ctx, {
user_id: user.id,
});
if (errors && errors.length > 0) {
console.error(errors);
}
expect(errors).to.be.undefined;
if (error) {
expect(data[mutation]).to.have.property('errors').not.null;
expect(data[mutation].errors[0]).to.have.property('translation_key', error);
} else {
expect(data[mutation]).to.be.null;
user = await UserModel.findOne({id: user.id});
expect(user.status.username.status).to.equal(status);
expect(user.status.username.history).to.have.length(2);
expect(user.status.username.history[0]).to.have.property('status', 'SET');
expect(user.status.username.history[0]).to.have.property('assigned_by').is.null;
expect(user.status.username.history[0]).to.have.property('created_at').not.null;
expect(user.status.username.history[1]).to.have.property('status', status);
expect(user.status.username.history[1]).to.have.property('assigned_by', actor.id);
expect(user.status.username.history[1]).to.have.property('created_at').not.null;
expect(user.status.username.history[1].created_at).afterTime(user.status.username.history[0].created_at);
}
});
});
});
});
@@ -22,8 +22,7 @@ describe('graph.mutations.updateAssetSettings', () => {
translation_key
}
}
}
`;
}`;
describe('context with different user roles', () => {
@@ -21,8 +21,7 @@ describe('graph.mutations.updateSettings', () => {
translation_key
}
}
}
`;
}`;
describe('context with different user roles', () => {
-57
View File
@@ -1,57 +0,0 @@
const passport = require('../../../passport');
const app = require('../../../../../app');
const UsersService = require('../../../../../services/users');
const SettingsService = require('../../../../../services/settings');
const settings = {id: '1', moderation: 'PRE', wordlist: {banned: ['bad words'], suspect: ['suspect words']}};
const chai = require('chai');
chai.should();
chai.use(require('chai-http'));
const expect = chai.expect;
describe('/api/v1/account/username', () => {
let mockUser;
beforeEach(async () => {
await SettingsService.init(settings);
mockUser = await UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
});
describe('#put', () => {
it('it should enable a user to edit their username if canEditName is enabled', async () => {
await chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}));
const res = await chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: mockUser.id, roles: []}))
.send({username: 'MojoJojo'});
expect(res).to.have.status(204);
});
it('it should return an error if the wrong user tries to edit a username', async () => {
await chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}));
let res = chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: 'wrongid', roles: []}))
.send({username: 'MojoJojo'});
return expect(res).to.eventually.be.rejected;
});
it('it should return an error when the user tries to edit their username if canEditName is disabled', () => {
let res = chai.request(app)
.put('/api/v1/account/username')
.set(passport.inject({id: mockUser.id, roles: []}))
.send({username: 'MojoJojo'});
return expect(res).to.eventually.be.rejected;
});
});
});
-49
View File
@@ -48,52 +48,3 @@ describe('/api/v1/users/:user_id/email/confirm', () => {
});
});
});
describe('/api/v1/users/:user_id/actions', () => {
let mockUser;
beforeEach(() => SettingsService.init(settings).then(() => {
return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
}));
describe('#post', () => {
it('it should update actions', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/actions`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.send({'action_type': 'FLAG', metadata: {reason: 'Bio is too awesome.'}})
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.body;
expect(res.body).to.have.property('action_type', 'FLAG');
expect(res.body).to.have.property('item_id', mockUser.id);
});
});
});
});
describe('/api/v1/users/:user_id/username-enable', () => {
let mockUser;
beforeEach(() => SettingsService.init(settings).then(() => {
return UsersService.createLocalUser('ana@gmail.com', '123321123', 'Ana');
})
.then((user) => {
mockUser = user;
}));
describe('#post', () => {
it('it should enable a user to edit their username', () => {
return chai.request(app)
.post(`/api/v1/users/${mockUser.id}/username-enable`)
.set(passport.inject({id: '456', roles: ['ADMIN']}))
.then((res) => {
expect(res).to.have.status(204);
});
});
});
});
@@ -1,27 +1,27 @@
const expect = require('chai').expect;
const Domainlist = require('../../../services/domainlist');
const DomainList = require('../../../services/domain_list');
const SettingsService = require('../../../services/settings');
describe('services.Domainlist', () => {
describe('services.DomainList', () => {
const domainlists = {
const domainLists = {
whitelist: [
'nytimes.com',
'wapo.com'
]
};
let domainlist = new Domainlist();
let domainList = new DomainList();
const settings = {id: '1', moderation: 'PRE', domainlist: {whitelist: ['nytimes.com', 'wapo.com']}};
beforeEach(() => SettingsService.init(settings));
describe('#init', () => {
before(() => domainlist.upsert(domainlists));
before(() => domainList.upsert(domainLists));
it('has entries', () => {
expect(domainlist.lists.whitelist).to.not.be.empty;
expect(domainList.lists.whitelist).to.not.be.empty;
});
});
@@ -92,21 +92,21 @@ describe('services.Domainlist', () => {
['google.Ca:80', 'google.ca'],
['google.Ca:443', 'google.ca'],
].forEach(([domain, hostname]) => {
expect(Domainlist.parseURL(domain), `domain ${domain} should be parsed as ${hostname}`).to.equal(hostname);
expect(DomainList.parseURL(domain), `domain ${domain} should be parsed as ${hostname}`).to.equal(hostname);
});
});
});
describe('#match', () => {
const whiteList = Domainlist.parseList(domainlists['whitelist']);
const whiteList = DomainList.parseList(domainLists['whitelist']);
it('does match on an included domain', () => {
[
'http://wapo.com',
'nytimes.com'
].forEach((domain) => {
expect(domainlist.match(whiteList, domain)).to.be.true;
expect(domainList.match(whiteList, domain)).to.be.true;
});
});
@@ -116,7 +116,7 @@ describe('services.Domainlist', () => {
'www.badsite.com',
'otherexample.com'
].forEach((domain) => {
expect(domainlist.match(whiteList, domain)).to.be.false;
expect(domainList.match(whiteList, domain)).to.be.false;
});
});
});
-22
View File
@@ -1,22 +0,0 @@
describe('services.scraper', () => {
describe('#create', () => {
it('should create a new kue job');
});
describe('#scrape', () => {
it('should scrape complete information');
it('should scrape what it can');
});
describe('#update', () => {
it('should update the database record entries from the meta');
});
describe('#process', () => {
it('should start the processor to scrape assets');
});
describe('#shutdown', () => {
it('should shutdown the job processor');
});
});
+53 -116
View File
@@ -151,21 +151,6 @@ describe('services.UsersService', () => {
});
describe('#setStatus', () => {
it('should set the status to active', () => {
return UsersService
.setStatus(mockUsers[0].id, 'ACTIVE')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'ACTIVE');
})
.then(() => {
expect(MailerService.sendSimple).to.not.have.been.called;
});
});
});
describe('#ignoreUser', () => {
it('should add user id to ignoredUsers set', async () => {
const user = mockUsers[0];
@@ -194,54 +179,6 @@ describe('services.UsersService', () => {
});
});
describe('#ban', () => {
it('should set the status to banned', () => {
return UsersService
.setStatus(mockUsers[0].id, 'BANNED')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'BANNED');
})
.then(() => {
expect(MailerService.sendSimple).to.have.been.calledWithMatch({
template: 'banned',
to: mockUsers[0].profiles[0].id
});
});
});
it('should still disable and ban the user if there is no comment', () => {
return UsersService
.setStatus(mockUsers[0].id, 'BANNED')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'BANNED');
});
});
});
describe('#unban', () => {
it('should set the status to active', () => {
return UsersService
.setStatus(mockUsers[0].id, 'ACTIVE')
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('status', 'ACTIVE');
});
});
});
describe('#toggleNameEdit', () => {
it('should toggle the canEditName field', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('canEditName', true);
});
});
});
describe('#search', () => {
it('should return all the results without a value', async () => {
expect(await UsersService.search()).to.have.length(3);
@@ -286,63 +223,63 @@ describe('services.UsersService', () => {
});
});
describe('#editName', () => {
it('should let the user edit their username if the proper toggle is set', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.editName(mockUsers[0].id, 'Jojo'))
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('username', 'Jojo');
expect(user).to.have.property('canEditName', false);
});
});
// describe('#editName', () => {
// it('should let the user edit their username if the proper toggle is set', () => {
// return UsersService
// .toggleNameEdit(mockUsers[0].id, true)
// .then(() => UsersService.editName(mockUsers[0].id, 'Jojo'))
// .then(() => UsersService.findById(mockUsers[0].id))
// .then((user) => {
// expect(user).to.have.property('username', 'Jojo');
// expect(user).to.have.property('canEditName', false);
// });
// });
it('should let the user submit the same username if user is not banned (create username)', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
.then(() => UsersService.findById(mockUsers[0].id))
.then((user) => {
expect(user).to.have.property('username', mockUsers[0].username);
expect(user).to.have.property('canEditName', false);
});
});
// it('should let the user submit the same username if user is not banned (create username)', () => {
// return UsersService
// .toggleNameEdit(mockUsers[0].id, true)
// .then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
// .then(() => UsersService.findById(mockUsers[0].id))
// .then((user) => {
// expect(user).to.have.property('username', mockUsers[0].username);
// expect(user).to.have.property('canEditName', false);
// });
// });
it('should return error when a banned user submits the same username (rejected username)', () => {
return UsersService
.toggleNameEdit(mockUsers[0].id, true)
.then(() => UsersService.setStatus(mockUsers[0].id, 'BANNED'))
.then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
.then(() => UsersService.findById(mockUsers[0].id))
.then(() => {
throw new Error('Error expected');
})
.catch((err) => {
expect(err.status).to.equal(400);
expect(err.translation_key).to.equal('SAME_USERNAME_PROVIDED');
});
});
// it('should return error when a banned user submits the same username (rejected username)', () => {
// return UsersService
// .toggleNameEdit(mockUsers[0].id, true)
// .then(() => UsersService.setStatus(mockUsers[0].id, 'BANNED'))
// .then(() => UsersService.editName(mockUsers[0].id, mockUsers[0].username))
// .then(() => UsersService.findById(mockUsers[0].id))
// .then(() => {
// throw new Error('Error expected');
// })
// .catch((err) => {
// expect(err.status).to.equal(400);
// expect(err.translation_key).to.equal('SAME_USERNAME_PROVIDED');
// });
// });
it('should return an error if canEditName is false', async () => {
return expect(UsersService.editName(mockUsers[0].id, 'Jojo')).to.eventually.be.rejected;
});
// it('should return an error if canEditName is false', async () => {
// return expect(UsersService.editName(mockUsers[0].id, 'Jojo')).to.eventually.be.rejected;
// });
it('should return an error if the username is already taken', async () => {
await UsersService.toggleNameEdit(mockUsers[0].id, true);
return expect(UsersService.editName(mockUsers[0].id, 'Marvel')).to.eventually.be.rejected;
});
// it('should return an error if the username is already taken', async () => {
// await UsersService.toggleNameEdit(mockUsers[0].id, true);
// return expect(UsersService.editName(mockUsers[0].id, 'Marvel')).to.eventually.be.rejected;
// });
it('should not allow non-alphanumeric characters in usernames', () => {
return UsersService
.isValidUsername('hi🖕')
.then(() => {
expect(false).to.be.true;
})
.catch((err) => {
expect(err).to.be.ok;
});
});
});
// it('should not allow non-alphanumeric characters in usernames', () => {
// return UsersService
// .isValidUsername('hi🖕')
// .then(() => {
// expect(false).to.be.true;
// })
// .catch((err) => {
// expect(err).to.be.ok;
// });
// });
// });
});
+129 -485
View File
File diff suppressed because it is too large Load Diff