Merge pull request #899 from coralproject/settings-graph-api

Added graph API for settings
This commit is contained in:
Kim Gardner
2017-09-05 13:23:51 +01:00
committed by GitHub
15 changed files with 518 additions and 25 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
const SettingsService = require('../../services/settings');
const util = require('./util');
const {SingletonResolver} = require('./util');
/**
* Creates a set of loaders based on a GraphQL context.
@@ -7,5 +7,5 @@ const util = require('./util');
* @return {Object} object of loaders
*/
module.exports = () => ({
Settings: new util.SingletonResolver(() => SettingsService.retrieve())
Settings: new SingletonResolver(() => SettingsService.retrieve())
});
+2
View File
@@ -3,6 +3,7 @@ const debug = require('debug')('talk:graph:mutators');
const Comment = require('./comment');
const Action = require('./action');
const Settings = require('./settings');
const Tag = require('./tag');
const Token = require('./token');
const User = require('./user');
@@ -14,6 +15,7 @@ let mutators = [
// Load in the core mutators.
Comment,
Action,
Settings,
Tag,
Token,
User,
+33
View File
@@ -0,0 +1,33 @@
const errors = require('../../errors');
const {
UPDATE_SETTINGS,
UPDATE_WORDLIST,
} = require('../../perms/constants');
const SettingsService = require('../../services/settings');
const update = async (ctx, settings) => SettingsService.update(settings);
const updateWordlist = async (ctx, wordlist) => SettingsService.updateWordlist(wordlist);
module.exports = (ctx) => {
let mutators = {
Settings: {
update: () => Promise.reject(errors.ErrNotAuthorized),
updateWordlist: () => Promise.reject(errors.ErrNotAuthorized)
}
};
if (ctx.user) {
if (ctx.user.can(UPDATE_SETTINGS)) {
mutators.Settings.update = (id, settings) => update(ctx, id, settings);
}
if (ctx.user.can(UPDATE_WORDLIST)) {
mutators.Settings.updateWordlist = (id, status) => updateWordlist(ctx, id, status);
}
}
return mutators;
};
+6
View File
@@ -50,6 +50,12 @@ const RootMutation = {
removeTag(_, {tag}, {mutators: {Tag}}) {
return wrapResponse(null)(Tag.remove(tag));
},
updateSettings(_, {input: settings}, {mutators: {Settings}}) {
return wrapResponse(null)(Settings.update(settings));
},
updateWordlist(_, {input: wordlist}, {mutators: {Settings}}) {
return wrapResponse(null)(Settings.updateWordlist(wordlist));
},
createToken(_, {input}, {mutators: {Token}}) {
return wrapResponse('token')(Token.create(input));
},
+18
View File
@@ -1,3 +1,21 @@
const {
VIEW_PROTECTED_SETTINGS,
} = require('../../perms/constants');
const {decorateWithPermissionCheck} = require('./util');
const Settings = {};
// PROTECTED_SETTINGS are the settings keys that must be protected for only some
// eyes.
const PROTECTED_SETTINGS = {
'premodLinksEnable': [VIEW_PROTECTED_SETTINGS],
'autoCloseStream': [VIEW_PROTECTED_SETTINGS],
'wordlist': [VIEW_PROTECTED_SETTINGS],
'domains': [VIEW_PROTECTED_SETTINGS],
};
// decorate the fields on the settings resolver with a permission check.
decorateWithPermissionCheck(Settings, PROTECTED_SETTINGS);
module.exports = Settings;
+27 -2
View File
@@ -13,6 +13,31 @@ const decorateWithTags = (typeResolver) => {
};
};
module.exports = {
decorateWithTags
/**
* decorateWithPermissionCheck will decorate the field resolver with
* permission checks.
*
* @param {Object} typeResolver the type resolver
* @param {Object} protect the object with field -> Array<String> of permissions
*/
const decorateWithPermissionCheck = (typeResolver, protect) => {
for (const [field, permissions] of Object.entries(protect)) {
let fieldResolver = (obj) => obj[field];
if (field in typeResolver) {
fieldResolver = typeResolver[field];
}
typeResolver[field] = (obj, args, ctx, info) => {
if (!ctx.user || !ctx.user.can(...permissions)) {
return null;
}
return fieldResolver(obj, args, ctx, info);
};
}
};
module.exports = {
decorateWithTags,
decorateWithPermissionCheck,
};
+155 -4
View File
@@ -572,27 +572,85 @@ enum MODERATION_MODE {
POST
}
# Site wide global settings.
# Wordlist describes all the available wordlists.
type Wordlist {
# banned words will by default reject the comment if it is found.
banned: [String!]!
# suspect words will simply flag the comment.
suspect: [String!]!
}
# Domains describes all the available lists of domains.
type Domains {
# whitelist is the list of domains that the embed is allowed to render on.
whitelist: [String!]!
}
# Settings stores the global settings for a given installation.
type Settings {
# Moderation mode for the site.
# moderation is the moderation mode for all Asset's on the site.
moderation: MODERATION_MODE!
# Enables a requirement for email confirmation before a user can login.
requireEmailConfirmation: Boolean
# infoBoxEnable will enable the Info Box content visible above the question
# box.
infoBoxEnable: Boolean
# infoBoxContent is the content of the Info Box.
infoBoxContent: String
premodLinksEnable: Boolean
# questionBoxEnable will enable the Question Box's content to be visible above
# the comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
questionBoxContent: String
# premodLinksEnable will put all comments that contain links into premod.
premodLinksEnable: Boolean
# questionBoxIcon is the icon for the Question Box.
questionBoxIcon: String
closeTimeout: Int
# autoCloseStream when true will auto close the stream when the `closeTimeout`
# amount of seconds have been reached.
autoCloseStream: Boolean
# customCssUrl is the URL of the custom CSS used to display on the frontend.
customCssUrl: String
# closedTimeout is the amount of seconds from the created_at timestamp that a
# given asset will be considered closed.
closedTimeout: Int
# closedMessage is the message shown to the user when the given Asset is
# closed.
closedMessage: String
# editCommentWindowLength is the length of time (in milliseconds) after a
# comment is posted that it can still be edited by the author.
editCommentWindowLength: Int
# charCountEnable is true when the character count restriction is enabled.
charCountEnable: Boolean
# charCount is the maximum number of characters a comment may be.
charCount: Int
# organizationName is the name of the organization.
organizationName: String
# wordlist will return a given list of words.
wordlist: Wordlist
# domains will return a given list of domains.
domains: Domains
}
################################################################################
@@ -998,6 +1056,91 @@ type EditCommentResponse implements Response {
errors: [UserError!]
}
# UpdateSettingsInput is the input used to input the global site settings. This
# will override the existing settings, so all fields must be included.
input UpdateSettingsInput {
# moderation is the moderation mode for all Asset's on the site.
moderation: MODERATION_MODE
# Enables a requirement for email confirmation before a user can login.
requireEmailConfirmation: Boolean
# infoBoxEnable will enable the Info Box content visible above the question
# box.
infoBoxEnable: Boolean
# infoBoxContent is the content of the Info Box.
infoBoxContent: String
# questionBoxEnable will enable the Question Box's content to be visible above
# the comment box.
questionBoxEnable: Boolean
# questionBoxContent is the content of the Question Box.
questionBoxContent: String
# premodLinksEnable will put all comments that contain links into premod.
premodLinksEnable: Boolean
# questionBoxIcon is the icon for the Question Box.
questionBoxIcon: String
# autoCloseStream when true will auto close the stream when the `closeTimeout`
# amount of seconds have been reached.
autoCloseStream: Boolean
# customCssUrl is the URL of the custom CSS used to display on the frontend.
customCssUrl: String
# closeTimeout is the amount of seconds from the created_at timestamp that a
# given asset will be considered closed.
closeTimeout: Int
# closedMessage is the message shown to the user when the given Asset is
# closed.
closedMessage: String
# charCountEnable is true when the character count restriction is enabled.
charCountEnable: Boolean
# charCount is the maximum number of characters a comment may be.
charCount: Int
# organizationName is the name of the organization.
organizationName: String
# editCommentWindowLength is the length of time (in milliseconds) after a
# comment is posted that it can still be edited by the author.
editCommentWindowLength: Int
}
# UpdateSettingsResponse contains any errors that were rendered as a result
# of the mutation.
type UpdateSettingsResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# UpdateWordlistInput is the list of words that composes the Wordlist.
input UpdateWordlistInput {
# banned words will by default reject the comment if it is found.
banned: [String!]!
# suspect words will simply flag the comment.
suspect: [String!]!
}
# UpdateWordlistResponse contains any errors that were rendered as a result
# of the mutation.
type UpdateWordlistResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# CreateTokenInput contains the input to create the token.
input CreateTokenInput {
@@ -1065,6 +1208,14 @@ type RootMutation {
# Removes a tag.
removeTag(tag: ModifyTagInput!): ModifyTagResponse!
# updateSettings will update the global settings.
# Mutation is restricted.
updateSettings(input: UpdateSettingsInput!): UpdateSettingsResponse!
# updateWordlist will update the given Wordlist.
# Mutation is restricted.
updateWordlist(input: UpdateWordlistInput!): UpdateWordlistResponse!
# Ignore comments by another user
ignoreUser(id: ID!): IgnoreUserResponse
+3
View File
@@ -16,6 +16,8 @@ module.exports = {
UPDATE_CONFIG: 'UPDATE_CONFIG',
CREATE_TOKEN: 'CREATE_TOKEN',
REVOKE_TOKEN: 'REVOKE_TOKEN',
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
UPDATE_WORDLIST: 'UPDATE_WORDLIST',
// queries
SEARCH_ASSETS: 'SEARCH_ASSETS',
@@ -27,6 +29,7 @@ module.exports = {
LIST_OWN_TOKENS: 'LIST_OWN_TOKENS',
SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
VIEW_SUSPENSION_INFO: 'VIEW_SUSPENSION_INFO',
VIEW_PROTECTED_SETTINGS: 'VIEW_PROTECTED_SETTINGS',
// subscriptions
SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED',
+2 -5
View File
@@ -41,11 +41,8 @@ const findGrant = (user, perms) => {
*/
module.exports = (user, ...perms) => {
// make sure all the passed permissions are not typos
const missingPerms = perms.filter((perm) => {
return allPermissions.indexOf(perm) === -1;
});
// Make sure all the passed permissions are not typos.
const missingPerms = perms.filter((perm) => !allPermissions.includes(perm));
if (missingPerms.length > 0) {
throw new Error(`${missingPerms.join(' ')} are not valid permissions.`);
}
+2 -12
View File
@@ -4,33 +4,23 @@ const types = require('./constants');
module.exports = (user, perm) => {
switch (perm) {
case types.CREATE_COMMENT:
return true;
case types.CREATE_ACTION:
return true;
case types.DELETE_ACTION:
return true;
case types.EDIT_NAME:
return true;
case types.EDIT_COMMENT:
return true;
case types.UPDATE_USER_ROLES:
return check(user, ['ADMIN']);
case types.REJECT_USERNAME:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SET_USER_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SUSPEND_USER:
return check(user, ['ADMIN', 'MODERATOR']);
case types.SET_COMMENT_STATUS:
return check(user, ['ADMIN', 'MODERATOR']);
case types.ADD_COMMENT_TAG:
return check(user, ['ADMIN', 'MODERATOR']);
case types.REMOVE_COMMENT_TAG:
return check(user, ['ADMIN', 'MODERATOR']);
case types.UPDATE_CONFIG:
case types.UPDATE_SETTINGS:
case types.UPDATE_WORDLIST:
return check(user, ['ADMIN', 'MODERATOR']);
case types.CREATE_TOKEN:
return check(user, ['ADMIN']);
case types.REVOKE_TOKEN:
return check(user, ['ADMIN']);
default:
+2
View File
@@ -21,6 +21,8 @@ module.exports = (user, perm) => {
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']);
default:
break;
}
+13
View File
@@ -42,6 +42,19 @@ module.exports = class SettingsService {
});
}
/**
* updateWordlist will update the wordlists.
*
* @param {Object} wordlist the Wordlist object
*/
static updateWordlist(wordlist) {
return SettingModel.findOneAndUpdate(selector, {
$set: {
wordlist,
},
});
}
/**
* This is run once when the app starts to ensure settings are populated.
*/
@@ -0,0 +1,74 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const UserModel = require('../../../../models/user');
const SettingsService = require('../../../../services/settings');
const {expect} = require('chai');
describe('graph.mutations.updateSettings', () => {
beforeEach(async () => {
await SettingsService.init();
});
const QUERY = `
mutation UpdateSettings($settings: UpdateSettingsInput!) {
updateSettings(input: $settings) {
errors {
translation_key
}
}
}
`;
describe('context with different user roles', () => {
[
{error: 'NOT_AUTHORIZED'},
{error: 'NOT_AUTHORIZED', roles: []},
{roles: ['ADMIN']},
{roles: ['ADMIN', 'MODERATOR']},
{roles: ['MODERATOR']},
].forEach(({roles, error}) => {
it(roles ? roles.join(', ') : '<None>', async () => {
let user;
if (roles != null) {
user = new UserModel({roles});
}
const ctx = new Context({user});
const newSettings = {
premodLinksEnable: false,
moderation: 'POST',
questionBoxEnable: true,
questionBoxContent: 'Question?',
questionBoxIcon: '<Icon>',
};
const res = await graphql(schema, QUERY, {}, ctx, {
settings: newSettings,
});
if (res.errors) {
console.error(res.errors);
}
expect(res.errors).to.be.empty;
if (error) {
expect(res.data.updateSettings.errors).to.not.be.empty;
expect(res.data.updateSettings.errors[0]).to.have.property('translation_key', error);
} else {
if (res.data.updateSettings.errors) {
console.error(res.data.updateSettings.errors);
}
expect(res.data.updateSettings.errors).to.be.null;
const retrievedSettings = await SettingsService.retrieve();
Object.keys(newSettings).forEach((key) => {
expect(retrievedSettings).to.have.property(key, newSettings[key]);
});
}
});
});
});
});
@@ -0,0 +1,82 @@
const {graphql} = require('graphql');
const schema = require('../../../../graph/schema');
const Context = require('../../../../graph/context');
const UserModel = require('../../../../models/user');
const SettingsService = require('../../../../services/settings');
const {expect} = require('chai');
describe('graph.mutations.updateWordlist', () => {
beforeEach(async () => {
await SettingsService.init();
});
const QUERY = `
mutation UpdateWordlist($wordlist: UpdateWordlistInput!) {
updateWordlist(input: $wordlist) {
errors {
translation_key
}
}
}
`;
describe('context with different user roles', () => {
[
{error: 'NOT_AUTHORIZED'},
{error: 'NOT_AUTHORIZED', roles: []},
{roles: ['ADMIN']},
{roles: ['ADMIN', 'MODERATOR']},
{roles: ['MODERATOR']},
].forEach(({roles, error}) => {
it(roles && roles.length > 0 ? roles.join(', ') : '<None>', async () => {
let user;
if (roles != null) {
user = new UserModel({roles});
}
const ctx = new Context({user});
const wordlist = {
banned: [
'happy',
],
suspect: [
'sad',
],
};
const res = await graphql(schema, QUERY, {}, ctx, {
wordlist,
});
if (res.errors) {
console.error(res.errors);
}
expect(res.errors).to.be.empty;
if (error) {
expect(res.data.updateWordlist.errors).to.not.be.empty;
expect(res.data.updateWordlist.errors[0]).to.have.property('translation_key', error);
const {wordlist: retrievedWordlist} = await SettingsService.retrieve();
expect(retrievedWordlist).to.have.property('banned');
expect(retrievedWordlist.banned).to.have.members([]);
expect(retrievedWordlist).to.have.property('suspect');
expect(retrievedWordlist.suspect).to.have.members([]);
} else {
if (res.data.updateWordlist.errors) {
console.error(res.data.updateWordlist.errors);
}
expect(res.data.updateWordlist.errors).to.be.null;
const {wordlist: retrievedWordlist} = await SettingsService.retrieve();
expect(retrievedWordlist).to.have.property('banned');
expect(retrievedWordlist.banned).to.have.members(wordlist.banned);
expect(retrievedWordlist).to.have.property('suspect');
expect(retrievedWordlist.suspect).to.have.members(wordlist.suspect);
}
});
});
});
});
+97
View File
@@ -0,0 +1,97 @@
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 {expect} = require('chai');
const defaultSettings = {
organizationName: 'The Coral Project'
};
describe('graph.queries.settings', () => {
let settings;
beforeEach(async () => {
settings = await SettingsService.init(defaultSettings);
});
const QUERY = `
{
settings {
moderation
requireEmailConfirmation
infoBoxEnable
infoBoxContent
questionBoxEnable
questionBoxContent
premodLinksEnable
questionBoxIcon
autoCloseStream
customCssUrl
closedTimeout
closedMessage
charCountEnable
charCount
organizationName
wordlist {
banned
suspect
}
domains {
whitelist
}
}
}
`;
describe('context with different user roles', () => {
const BLACKLISTED_PROPERTIES = [
'premodLinksEnable',
'autoCloseStream',
'wordlist',
'domains',
];
[
{bl: true},
{bl: true, roles: []},
{bl: false, roles: ['ADMIN']},
{bl: false, roles: ['ADMIN', 'MODERATOR']},
{bl: false, roles: ['MODERATOR']},
].forEach(({bl, roles}) => {
it(roles && roles.length > 0 ? roles.join(', ') : '<None>', async () => {
let user;
if (roles != null) {
user = new UserModel({roles});
}
const ctx = new Context({user});
const res = await graphql(schema, QUERY, {}, ctx);
if (res.errors) {
console.error(res.errors);
}
expect(res.errors).to.be.empty;
expect(res.data.settings).to.be.object;
Object.keys(res.data.settings).forEach((key) => {
if (bl && BLACKLISTED_PROPERTIES.includes(key)) {
expect(res.data.settings).to.have.property(key, null);
return;
}
if (typeof settings[key] !== 'object') {
expect(res.data.settings).to.have.property(key, settings[key]);
} else {
expect(res.data.settings).to.have.property(key);
expect(res.data.settings[key]).to.not.be.null;
}
});
});
});
});
});