diff --git a/.gitignore b/.gitignore index 666223666..2ec55accd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ dump.rdb gaba.cfg .idea/ coverage/ +yarn.lock diff --git a/app.js b/app.js index aca80a4b5..b128eb283 100644 --- a/app.js +++ b/app.js @@ -5,6 +5,7 @@ const path = require('path'); const helmet = require('helmet'); const passport = require('./services/passport'); const session = require('express-session'); +const enabled = require('debug').enabled; const RedisStore = require('connect-redis')(session); const redis = require('./services/redis'); const csrf = require('csurf'); @@ -120,7 +121,7 @@ app.use((req, res, next) => { // returning a status code that makes sense. app.use('/api', (err, req, res, next) => { if (err !== ErrNotFound) { - if (app.get('env') !== 'test') { + if (app.get('env') !== 'test' || enabled('talk:errors')) { console.error(err); } } diff --git a/bin/cli b/bin/cli index 131614e86..c5c61fbf6 100755 --- a/bin/cli +++ b/bin/cli @@ -1,16 +1,12 @@ #!/usr/bin/env node -// Perform rewrites to the runtime environment variables based on the contents -// of the process.env.REWRITE_ENV if it exists. This is done here as it is the -// entrypoint for the entire application. -require('env-rewrite').rewrite(); - /** * Module dependencies. */ const program = require('commander'); const pkg = require('../package.json'); +const dotenv = require('dotenv'); //============================================================================== // Setting up the program command line arguments. @@ -18,6 +14,25 @@ const pkg = require('../package.json'); program .version(pkg.version) + .option('-c, --config [path]', 'Specify the configuration file to load') + .parse(process.argv); + +if (program.config) { + let r = dotenv.config({ + path: program.config + }); + + if (r.error) { + throw r.error; + } +} + +// Perform rewrites to the runtime environment variables based on the contents +// of the process.env.REWRITE_ENV if it exists. This is done here as it is the +// entrypoint for the entire application. +require('env-rewrite').rewrite(); + +program .command('serve', 'serve the application') .command('assets', 'interact with assets') .command('settings', 'work with the application settings') diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json index 4142c109f..0fbd1e7b9 100644 --- a/client/coral-admin/src/translations.json +++ b/client/coral-admin/src/translations.json @@ -48,7 +48,7 @@ "include-text": "Include your text here.", "comment-settings": "Comment Settings", "embed-comment-stream": "Embed Comment Stream", - "banned-word-header": "Write the bannned words list", + "banned-word-header": "Write the banned words list", "suspect-word-header": "Write the suspect words list", "banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.", "suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.", diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index 106224c25..0f65e14c2 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -82,7 +82,9 @@ export const fetchSignUp = formData => (dispatch) => { dispatch(changeView('SIGNIN')); }, 3000); }) - .catch(() => dispatch(signUpFailure(lang.t('error.emailInUse')))); // We need to inprove error handling. TODO (bc) + .catch(error => { + dispatch(signUpFailure(lang.t(`error.${error.message}`))); + }); }; // Forgot Password Actions diff --git a/client/coral-framework/helpers/response.js b/client/coral-framework/helpers/response.js index d612aefb9..e4e6e7c37 100644 --- a/client/coral-framework/helpers/response.js +++ b/client/coral-framework/helpers/response.js @@ -37,7 +37,13 @@ const handleResp = res => { if (res.status === 401) { throw new Error('Not Authorized to make this request'); } else if (res.status > 399) { - throw new Error('Error! Status ', res.status); + return res.json().then(err => { + let message = err.message || res.status; + if (err.error && err.error.translation_key) { + message = err.error.translation_key; + } + throw new Error(message); + }); } else if (res.status === 204) { return res.text(); } else { diff --git a/client/coral-framework/helpers/validate.js b/client/coral-framework/helpers/validate.js index 8c5ebd36f..80efb1b1f 100644 --- a/client/coral-framework/helpers/validate.js +++ b/client/coral-framework/helpers/validate.js @@ -2,5 +2,5 @@ export default { email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)), password: pass => (/^(?=.{8,}).*$/.test(pass)), confirmPassword: () => true, - displayName: displayName => (/^(?=.{3,}).*$/.test(displayName)) + displayName: displayName => (/^[a-z0-9_]+$/.test(displayName)) }; diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json index 06a39944a..112f7db1a 100644 --- a/client/coral-framework/translations.json +++ b/client/coral-framework/translations.json @@ -7,10 +7,16 @@ "error": { "email": "Not a valid E-Mail", "password": "Password must be at least 8 characters", - "displayName": "Display name is too short", - "confirmPassword": "Passwords don`t match. Please, check again", + "displayName": "Display names can contain letters, numbers and _ only", + "confirmPassword": "Passwords don't match. Please, check again", "emailPasswordError": "Email and/or password combination incorrect.", - "emailInUse": "Email address already in use" + "EMAIL_REQUIRED": "An email address is required", + "PASSWORD_REQUIRED": "Must input a password", + "PASSWORD_LENGTH": "Password is too short", + "EMAIL_IN_USE": "Email address already in use", + "DISPLAY_NAME_REQUIRED": "Must input a display name", + "NO_SPECIAL_CHARACTERS": "Display names can contain letters, numbers and _ only", + "PROFANITY_ERROR": "Display names must not contain profanity. Please contact the administrator if you believe this to be in error." } }, "es": { @@ -21,10 +27,16 @@ "error": { "email": "No es un email válido", "password": "La contraseña debe tener por lo menos 8 caracteres", - "displayName": "El nombre es muy corto", + "displayName": "Los nombres pueden contener letras, números y _", "confirmPassword": "Las contraseñas no coinciden", "emailPasswordError": "Email y/o contraseña incorrecta.", - "emailInUse": "Email address already in use" + "EMAIL_REQUIRED": "Se requiere una dirección de correo electrónico", + "PASSWORD_REQUIRED": "Debe ingresar una contraseña", + "PASSWORD_LENGTH": "La contraseña es muy corta", + "EMAIL_IN_USE": "La dirección de correo electrónico se encuentra en uso", + "DISPLAY_NAME_REQUIRED": "Debe ingresar un nombre", + "NO_SPECIAL_CHARACTERS": "Los nombres pueden contener letras, números y _", + "PROFANITY_ERROR": "Los nombres no pueden contener blasfemias. Por favor contacte al administrador si cree que esto es un error" } } } diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js index 23943318d..8fc747ce1 100644 --- a/client/coral-plugin-flags/FlagButton.js +++ b/client/coral-plugin-flags/FlagButton.js @@ -47,7 +47,7 @@ class FlagButton extends Component { case 'comments': item_id = id; break; - case 'user': + case 'users': item_id = author_id; break; } @@ -72,7 +72,7 @@ class FlagButton extends Component { onPopupOptionClick = (sets) => (e) => { // If flagging a user, indicate that this is referencing the username rather than the bio - if(sets === 'itemType' && e.target.value === 'user') { + if(sets === 'itemType' && e.target.value === 'users') { this.setState({field: 'username'}); } diff --git a/client/coral-plugin-flags/FlagComment.js b/client/coral-plugin-flags/FlagComment.js index 57d02f719..54dc954d9 100644 --- a/client/coral-plugin-flags/FlagComment.js +++ b/client/coral-plugin-flags/FlagComment.js @@ -10,7 +10,7 @@ const getPopupMenu = [ return { header: lang.t('step-1-header'), options: [ - {val: 'user', text: lang.t('flag-username')}, + {val: 'users', text: lang.t('flag-username')}, {val: 'comments', text: lang.t('flag-comment')} ], button: lang.t('continue'), diff --git a/client/coral-sign-in/translations.js b/client/coral-sign-in/translations.js index 200cb91f1..9155a6789 100644 --- a/client/coral-sign-in/translations.js +++ b/client/coral-sign-in/translations.js @@ -21,6 +21,7 @@ export default { emailInUse: 'Email address already in use', requiredField: 'This field is required', passwordsDontMatch: 'Passwords don\'t match.', + specialCharacters: 'Display names can contain letters, numbers and _ only', checkTheForm: 'Invalid Form. Please, check the fields' } }, @@ -46,6 +47,7 @@ export default { emailInUse: 'Este email se encuentra en uso', requiredField: 'Este campo es requerido', passwordsDontMatch: 'Las contraseñas no coinciden', + specialCharacters: 'Los nombres pueden contener letras, números y _', checkTheForm: 'Formulario Inválido. Por favor, completa los campos' } } diff --git a/docs/frontend/DEBUG.md b/docs/frontend/DEBUG.md new file mode 100644 index 000000000..36a7ac0fa --- /dev/null +++ b/docs/frontend/DEBUG.md @@ -0,0 +1,22 @@ +# Debug +How we debug errors at Coral + +## React Debugging +For debugging React + +### React Developer Tools +Another amazing tool for debugging React Applications. You can see where the props are, and much more. + +[React Developer Tools Extension](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en) + + +## Redux Debugging +For debugging Redux + +### Redux Devtool Extension +Redux Devtool is an amazing debug tool. You can easily see what' happening with the state, the payloads, and more. + +[Redux Devtool Chrome Extension](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en) + +[Redux Devtool Github Repo](https://github.com/zalmoxisus/redux-devtools-extension) + diff --git a/docs/frontend/IMMUTABLEJS.md b/docs/frontend/IMMUTABLEJS.md new file mode 100644 index 000000000..76a9323cf --- /dev/null +++ b/docs/frontend/IMMUTABLEJS.md @@ -0,0 +1,390 @@ +# InmutableJS +InmutableJS is a library from Facebook that provides a series of inmutable data structures. They are always immutable. The reference to them can change but the data inside of them cannot which means you can build predictable and reliable state models. +We use ImmutableJS in Talk and it becomes really easy to manage Talk’s application state. [Immutable.js](https://facebook.github.io/immutable-js/) + +More about Immutable Data and React: +[React.js Conf 2015 - Immutable Data and React - YouTube](https://www.youtube.com/watch?v=I7IdS-PbEgI&feature=youtu.be) + +## Why ImmutableJS? +- __Immutable Data is faster__ +* Tracking mutation and Maintaining state is difficult +* Encourages you to think differently about how data flows through your application + +## Getting Started +ImmutableJS API is pretty expense. We will try to cover the basics and more to show its power. + +ImmutableJS provides many Persistent Immutable data structures including: `List()`, `Stack()`, `Map()`, `OrderedMap()`, `Set()`, `OrderedSet()` and `Record()`. + +We will cover the most common data structures. `Map()` , `List()` and `Record()` and also we will describe the behaviour of `Seq()` with `Range()` + + +## Map() +- [Map()](https://facebook.github.io/immutable-js/docs/#/Map) + * Read values + * [get()](https://facebook.github.io/immutable-js/docs/#/Map/get) + * [has()](https://facebook.github.io/immutable-js/docs/#/Map/has) + * [first()](https://facebook.github.io/immutable-js/docs/#/Map/first) + * [last()](https://facebook.github.io/immutable-js/docs/#/Map/last) + * Read deep values + * [getIn()](https://facebook.github.io/immutable-js/docs/#/Map/getIn) + * Change Values + - [set()](https://facebook.github.io/immutable-js/docs/#/Map/set) + * [merge()](https://facebook.github.io/immutable-js/docs/#/Map/merge) + * [update()](https://facebook.github.io/immutable-js/docs/#/Map/update) + * [clear()](https://facebook.github.io/immutable-js/docs/#/Map/clear) + * [delete()](https://facebook.github.io/immutable-js/docs/#/Map/delete) + * Change deep values + * [setIn()](https://facebook.github.io/immutable-js/docs/#/Map/getIn) + * Conversion to JavaScript types + * [toJS()](https://facebook.github.io/immutable-js/docs/#/Map/toJS) + * [toArray()](https://facebook.github.io/immutable-js/docs/#/Map/toArray) + * [toObject](https://facebook.github.io/immutable-js/docs/#/Map/toObject) + * Member + * [size](https://facebook.github.io/immutable-js/docs/#/Map/size) + + +Creates a new Immutable Map. An Object graph. [Map - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Map) + +```js + +const data = { + ‘one’: { + title: ‘One’, + value: 1 + }, + ‘two’: { + title: ‘Two’, + value: 2 + } +} + +let map = Inmutable.Map(data) +``` + +### get() +Returns the value associated with the provided key, Since inmutable data cannot be mutated they create a new reference to the new data. +[get() - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Map/get) + +```js +map.get(‘one’).title +``` + + +```js +let obj = { 1: “one” }; +Object.keys(obj); // [ “1” ] +obj[“1”]; // “one” +obj[1]; // “one” + +let map = Map(obj); +map.get(“1”); // “one” +map.get(1); // undefined +``` + +### getIn() +To get data from a deeply nested structure. +[getIn() - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Map/getIn) + +*With a Map()* +```js + +let map = Inmutable.Map({ + title: ‘Todo One’, + text: ‘Do todo’ + category: { + title: ‘Some category’, + order: 1 + } +}) + +map.getIn([‘category’, ‘title’]) // ‘Some Category’ + +``` + +### length - size +To get the size of a Map() or a List() +```js +map.size +``` + +### set() +```js +map.set(‘three’, {title: ‘three’, value: 3}) +``` + +### delete() +```js +map.delete(‘three’, {title: ‘three’, value: 3}) +``` + +### update() +```js +map.update(‘one’, item => ‘’) +``` + +### clear() +Returns a new Map containing no keys or values. + +```js +map.clear() +``` + +### merge() +Returns a new Map resulting from merging the provided iterables. +```js + +let mapX = Inmutable.Map({a: 10, b: 20, c: 30}) +let mapY = Inmutable.Map({a: 10, b: 20, c: 30}) + +mapX.merge(mapY) // { a: 50, b: 40, c: 30, d: 60 } +``` + +### Querying Methods + +#### has +Returns a boolean if it finds the id key + +```js +map.has(item.id) +``` + +#### first +Returns the first element of a Map +```js +map.first() +``` + +### Iteration Methods +We can use methods like `.filter`, `.map`, `.reduce` . However it’s not recommended to use `.forEach` since it can mutate the data producing side effects. + +#### groupBy + +Returns the first element of a Map +```js +items.groupBy(item => { + return todo.completed +}); +``` + +### Working with Subsets of a Map() + +#### slice() +Returns the last two items of a Map() +slice(, ) +```js +items.slice(items.size-2, todos.size); +``` + +#### takeLast() +Returns the last two items of a Map() +```js +items.takeLast(2); +``` + +#### butLast() +Returns the last item +```js +items.butLast(); +``` + +#### rest() +```js +items.rest(); +``` + +#### skip() +Returns a Map() skipping the first 5 items +```js +items.skip(5); +``` + +#### skipUntil() +Returns a Map() skipping until it finds the value +```js +items.skipUntil(item => item.value === 1); +``` + +#### skipWhile() +Returns a Map() up until it finds 1 included. +```js +items.skipWhile(item => item.value === 1); +``` + +### Equality Methods + +#### is() +```js +let mapX = Inmutable.Map({a: 10, b: 20, c: 30}) +let mapY = Inmutable.Map({a: 10, b: 20, c: 30}) + +Immutable.is(mapX, mapY); // true +``` + +### FromJS + +#### Object to Map() +Creates deeply nested Map() from a plain Javascript Object + +```js +let object = {a: 10, b: 20, c: 30}; + +Immutable.fromJS(object); // Map() +``` + + +#### Array to List() +Creates List() from a JS Array + +```js +let array = [10,20,30]; + +Immutable.fromJS(object); // List() +``` + +#### Usage of the reviver function +The reviver function takes a key and a value. Converting JS to Map() or List() + +```js +let array = [10,20,30]; + +Immutable.fromJS(array, (key, value) => { + return value.toMap(); +}); // Map() +``` + +*Note: the getIn will be index based instead of object based if it comes from an array* + +### List() + +Most of the __Map()__ methods can be used with __List()__ +But there are some differences. + +### Differences between the Immutable Map() and List() +List() have the same methods that a JS Array has. But instead of mutating the array it returns a new one. + +Usually we wouldn’t use the push method in immutable data structures but with Immutable.List()s push methods are safe to be used. + +```js +let list = Immutable.List() +list.push(3) +list.toArray() // [3] +``` + +#### get() and getIn() +The get method with Map() is _key_ based and with List() is _index_ based. + +```js +// get() +let list = Immutable.List(); +list.push(3); +list.get(0); // 3 + +let map = Immutable.Map(); +list.set('active', true); +list.get('active'); // true + +// getIn() +let map = Inmutable.List([10, 20, 30, [40, 50]]) +map.getIn([3, 1]) // 50 +``` + +#### of() +We can create a __List()__ by using the _of_ method + +```js +const items = []; +const list = Immutable.List.of('red', 'green', 'blue'); +``` + +*Using the spread operator:* +```js +const items = ['red', 'green', 'blue']; +const list = Immutable.List.of(...items); +``` + +### Sequences +Represents a sequence of values. [Seq() - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Seq) + +- Sequences are immutable — Once a sequence is created, it cannot be changed. +- Sequences are Lazy + +Creating sequences with _of()_ +```js +let range = [0, 1, 2 ... 999] +let sequence = Immutable.Seq.of(...range) +``` + +For Example: the following performs no work, because the resulting of the sequence values are never iterated: + +```js + +let operations = 0; + +let squared = sequence.map(num => { + operations++; + return num * num; +}) +operations; // 0 + +// Now using the sequence +squared.take(10).toArray(); +operations; // 10 + +``` + +Once the sequence is used, it performs only the work necessary. It will return it only when you ask for them. + +This is really powerful because it doesn’t produce an overflow with infinite an infinite range. + +```js +let squaredRange = Immutable.Range(1, Infinity); + +squaredRange.size; // Infinity + +first1000squared = squaredRange + .take(1000) + .map(n => n * n); + +first1000squared.size; // 1000 +``` + +__Seq()__ allows for the efficient chaining of operations + +```js +let squaredOdds = Immutable.Range(0, Infinity) + .filter(n => n % 2 !== 0) + .map(n => n * n) + .take(1000); + +console.log( + squaredOdds.toArray() +) + +``` + +You can fin this example here: [Sequences - JS Bin](http://jsbin.com/nilekuj/edit?js,console) + +[image:12FACC54-0BAF-4C93-A782-F77DB7CD04D3-813-00001ABD60F45CC4/Screen Shot 2016-12-22 at 8.23.33 AM.png] + +## Memoization with Immutable JS +Immutable JS provides advanced memoization. + +```js +const seq = Immutable.Range(1, Infinity) + .map(n => ({ + value: n + })) + +console.time(‘First Run’); +seq.take(1000); +console.timeEnd(‘First Run’); // First Run: 0.577ms + +console.time(‘Second Run’); +seq.take(1000); +console.timeEnd(‘Second Run’); // Second Run: 0.165ms +``` + + +### Play with Immutable JS +[JS Bin - Collaborative JavaScript Debugging](http://jsbin.com/nilekuj/edit?js,console) + diff --git a/docs/frontend/README.md b/docs/frontend/README.md new file mode 100644 index 000000000..4e7f34fd6 --- /dev/null +++ b/docs/frontend/README.md @@ -0,0 +1,121 @@ +# Frontend Architecture +## The Stack + - [React](#react) + - [Redux](#redux) + - [ImmutableJS](#immutablejs) + + +## The Architecture +Our frontend lives within [talk/client](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client) folder. Every folder contains a plugin. In [coral-framework](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework) you will find the core architecture of Talk. +Here is where our Redux Application, translations, components, and helpers live. + + +## Presentational and Container Components +We use a common simple pattern called +__Presentational and Container Components__ + +It basically consist in having two types of components: + - Presentational + - Containers + +### Presentational Components +- __How our UI looks like__ +- Are stateless components +- Render props +- Allow containment of children via `this.props.children` +- They have DOM Markup + +### Container Components +* __How things work__ +* They don’t have markup nor styles +* They provide data and behaviour to Presentational or Container Components +* They connect via `react-redux`’s `connect()` to the state. +* They `mapStateToProps` the state to the Presentational Container. +* They `mapDispatchToProps` to send actions to the Presentational Container. +* Name Convention `Container.js` + +How a container looks like: +```js +/* +* mapStateToProps +* We map the part of the state that we want to use +*/ + +const mapStateToProps = state => ({ + auth: state.auth.toJS() +}); + +/* +* mapDispatchToProps +* We map the actions that we want to use +*/ + +const mapDispatchToProps = dispatch => ({ + checkLogin: () => dispatch(checkLogin()) +}); + + +/* +* connect +* We wrap our container in a connect() function +*/ + +export default connect( + mapStateToProps, + mapDispatchToProps +)(SignInContainer); +```` + +How our SignInContainer works: [talk/SignInContainer.js · GitHub](https://github.com/coralproject/talk/blob/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-sign-in/containers/SignInContainer.js) + +Within our plugins we create two folders `containers` and `components` so we can differentiate them: +``` +coral-sign-in/ +├── containers/ +│ └── SignInContainer.js +└── components/ + ├── SignInContent.js + └── SignUpContent.js +``` + +More about this architecture: + +[Container Components – Learn React with chantastic – Medium](https://medium.com/@learnreact/container-components-c0e67432e005#.w8mzgndcg) + + +[Presentational and Container Components – Dan Abramov – Medium](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.ai4ih55v3) + + +## React +## Redux +We use Redux to handle the state container of Talk. + +[How we to use Redux, and how we use it with Talk](https://github.com/coralproject/talk/blob/frontenddocs/docs/frontend/REDUX.md) + + +## ImmutableJS +We use Immutable JS to maintain our state immutable. +We found some really good tradeoffs while building Talk. + +[How to use ImmutableJS and how we use it with Talk](https://github.com/coralproject/talk/blob/frontenddocs/docs/frontend/IMMUTABLEJS.md) + + +## Test +[How we do testing at Coral with Talk](https://github.com/coralproject/talk/blob/frontenddocs/docs/frontend/DEBUG.md) + + +## Lint +For linting in Talk we use `eslint:recommended` + +You can find more info about the rules and best practices here: +http://eslint.org/docs/rules/#best-practices + +## Lint the code +```js +npm run lint +``` + + +## The Future of the Frontend +- Preact +- Reselect diff --git a/docs/frontend/REDUX.md b/docs/frontend/REDUX.md new file mode 100644 index 000000000..f82d88520 --- /dev/null +++ b/docs/frontend/REDUX.md @@ -0,0 +1,223 @@ +# Redux +Redux is a predictable state container for JavaScript apps. + +To understand Redux we need to dive into a few concepts. + +- [Actions](#actions) +- [Action Creators](#actions) +- [Action Types](#actions) +- [Reducers](#reducers) +- [Stores](#store) + +## The three principles +These are the three principles to build Redux applications. The following are specified in the Redux Documentation [Three Principles · Redux](http://redux.js.org/docs/introduction/ThreePrinciples.html) + +### Single source of truth +The state of your whole application is stored in an object tree within a single store. We are going to represent the whole state of our application in a single Javascript Object. + +### State is read-only +The only way to change the state is to emit an action, an object describing what happened. + +### Changes are made with pure functions +To specify how the state tree is transformed by actions, you write pure reducers. + +## Actions +Actions describe that something happened in our application. They are payloads of information that send data to your store. __They are the only source of information for the store.__ + +Here is an example: +```js +const ADD_COMMENT = 'ADD_COMMENT'; + +{ + type: ADD_COMMENT, + comment: 'This is my comment.' +} +``` + +Actions are JavaScript objects. Every action must have a `type` property that indicates the type of action being performed. Types should be defined as constants. + +Once an app becomes big enough, you may want to move them into a separate module. We store them in a `contants.js` file. [auth.js Constants](https://github.com/coralproject/talk/blob/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/constants/auth.js) + +```js +import { ADD_COMMENT, REMOVE_COMMENT } from './constants' +``` + +We can dispatch an action by using `dispatch()`. + +Our actions live within the `coral-framework/actions` folder. [talk/client/coral-framework/actions](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/actions) + +More about Actions: [Actions · Redux](http://redux.js.org/docs/basics/Actions.html) + +### Async Actions +For our async operations we dispatch three actions. + +- `_REQUEST` + +- `_SUCCESS` + +- `_FAILURE` + +#### Request +We use the postfix `_REQUEST` to know that the resource is being requested. + +#### Success +We use the postfix `_SUCCESS` to know that the resource response came back successfully. + +#### Failure +We use the postfix `_FAILURE` to know that the resource request failed. + +## Action Creators +Action Creators are functions that return actions. This makes it easier to use, portable and testable. + +```js +function addComment(comment) { + return { + type: ADD_COMMENT, + comment + } +} +``` + +So we can later trigger those actions by using `dispatch()` + +```js +dispatch(addComment(comment)) +dispatch(removeComment(comment.id)) +``` + + +## Dispatch Function +The `dispatch()` function can be accessed directly from the store as `store.dispatch()`, but more likely you'll access it using a helper like react-redux's`connect()`. + +We use `connect()`in our containers. More about this in Architecture. + +## Reducers +With Actions we describe that something happened in our application. But we don’t specify how our state will be modified with this change. + +In a Reducer we will specify how the state of our application change when an action has been dispatched. + +Here we also will want to specify the `initialState` + +Before building reducers it’s important to that you: + - Don’t mutate the state + - Return the previous state in the default case. + +Here is an example of an auth reducer: +```js +const initialState = { + isLoading: false, + loggedIn: false, + user: null, + error: '' +}; + +function auth (state = initialState, action) { + switch (action.type) { + case actions.CHECK_LOGIN_REQUEST: + return Object.assign({}, state, { + isLoading: true + }); + case actions.CHECK_LOGIN_SUCCESS: + return Object.assign({}, state, { + isLoading: false, + loggedIn: true, + user: action.user, + error: '' + }); + case actions.CHECK_LOGIN_FAILURE: + return Object.assign({}, state, { + isLoading: false, + error: action.error, + loggedIn: false, + user: null + }); + default: + return state + } +} +``` + +Notice that a reducer takes the `state` as first argument and when it’s not defined it returns the `initialState`. As a second argument it takes the `action`. We have our state and we have the action. This is the time to specify how we modify the state. + +### Reducers using ImmutableJS +We are using ImmutableJS to maintain our app state. Here is a guide on how to use ImmutableJS. + +This is how a simplified version of our [auth reducer](https://github.com/coralproject/talk/blob/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/reducers/auth.js) looks like: +```js +const initialState = Map({ + isLoading: false, + loggedIn: false, + user: null, + error: ‘’ +}); + +function auth (state = initialState, action) { + switch (action.type) { + case CHECK_LOGIN_REQUEST: + return state + .set('isLoading', true); + case CHECK_LOGIN_SUCCESS: + return state + .set('isLoading', false) + .set('loggedIn', true) + .set('user', action.user) + .set('error', ''); + }); + case CHECK_LOGIN_FAILURE: + return state + .set('isLoading', false) + .set('error', action.error) + .set('loggedIn', false) + .set('user', null) + }); + default: + return state + } +} +``` + +Looks cleaner, right? + +It’s pretty easy to follow. Here it says if a `CHECK_LOGIN_REQUEST` action has been dispatched set the `isLoading` from our state to `true`. And we can show a tiny loader to let the user now we are requesting something to the server. + +Our actions live within the `coral-framework/reducers` folder. [talk/client/coral-framework/reducers ](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/reducers) + +More about Reducers: [Reducers · Redux](http://redux.js.org/docs/basics/Reducers.html) + +And the last thing we need to see is the __Store__ + +### Store + The `Store` is what holds the application state. Here we can access and update the state. + +It’s important to note that we will only have a single store in our application called `rootReducer` and we will use reducer composition instead of many stores. + +Here is an example of how create a store with [createStore()](http://redux.js.org/docs/api/createStore.html) using a reducer: +```js +import { createStore } from 'redux' +import authReducer from './auth' +let store = createStore(authReducer) +``` + +We do have a lot of stores so we will need to combine all our reducers with [combineReducers()](http://redux.js.org/docs/api/combineReducers.html) within a single store + +```js +import {combineReducers} from 'redux'; + +import authReducer from './auth' +import configReducer from './config' +import userReducer from './user' + +const rootReducer = combineReducers({ + authReducer, + configReducer, + userReducer + ... +}); +``` + +More about Stores: [Store · Redux](http://redux.js.org/docs/basics/Store.html) + +## Useful Resources +[Redux Documentation · Redux](http://redux.js.org/) +[Getting Started with Redux](https://egghead.io/courses/getting-started-with-redux) +[Usage with React · Redux](http://redux.js.org/docs/basics/UsageWithReact.html) diff --git a/docs/frontend/TEST.md b/docs/frontend/TEST.md new file mode 100644 index 000000000..92a1f1998 --- /dev/null +++ b/docs/frontend/TEST.md @@ -0,0 +1,123 @@ +# Test +How we do testing at Coral with Talk. + +We use Nightwatch and Selenium for our E2E tests and Enzyme for our React Components. + +## E2E tests +For our E2E Test we use Nightwatch and Selenium. + +#### Selenium Server Setup + +Selenium Server is a Java application which Nightwatch uses to connect to the various browsers. + +You will need to have the Java Development Kit (JDK) installed. +[Java SE Development Kit 8 - Downloads](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html) + +The minimum required version is 7. +You can check this by running `java -version` + +#### Folder Structure + +``` +e2e/ + ├── pages + | ├── adminPage.js + | └── embedStreamPage.js + ├── reports + ├── tests + | ├── Admin + | ├── Commenter + | ├── Moderator + | └── Visitor + +``` + +#### Pages +Here we will have all the selectors and commands for a Page + +#### Reports +The folder that Nightwatch will use after running the tests + +#### Tests +Within `tests` folder we have 4 Folders and a couple of files. +`Admin`, `Commenter`, `Moderator`, `Visitor` contains all the group tests based on the user role and their actions. + +## Tests +The `pree2e` script will create 3 users: a Commenter, a Moderator, and an Admin + +* Commenter + * Login + - Post a comment + * Likes a comment + * Flag a comment + * Flag a username + * Gets Permalink + * Visits Permalink + +- Moderator + * Login + +- Admin + * Login + - Approve Comment + - Reject Comment + * Ban User + +- Visitor + * Tries to like a comment + * Tries to flag a comment + - Tries to flag a username + * Signs up + +## Run the tests +Run Talk +`dotenv npm run start` + +Run e2e tests +`npm run e2e` + + +## Advanced Nightwatch and Selenium Settings + +### Adding an Integration Environment +```json +{ + … + “test_settings” : { + “default” : { + “launch_url” : “http://localhost”, + “globals” : { + “myGlobalVar” : “some value”, + “otherGlobal” : “some other value” + } + }, + “integration” : { + “launch_url” : “http://staging.host”, + “globals” : { + “myGlobalVar” : “other value” + } + } + } +} +``` + +`nightwatch —env integration` + +### Chrome Options +[List of Chromium Command Line Switches « Peter Beverloo](http://peter.sh/experiments/chromium-command-line-switches/) + +## Tags +You'll notice that each test file starts with tags. This is useful to selectively target tests to run. + +_i.e nightwatch --tag login will only run login tests tagged with login_ +```js +module.exports { + '@tags': ['login'], + 'Test': browser => { + [...] + } +} +``` + +Source: http://nightwatchjs.org/guide#test-tags + diff --git a/errors.js b/errors.js new file mode 100644 index 000000000..0651b4fff --- /dev/null +++ b/errors.js @@ -0,0 +1,45 @@ +// ErrPasswordTooShort is returned when the password length is too short. +const ErrPasswordTooShort = new Error('password must be at least 8 characters'); +ErrPasswordTooShort.translation_key = 'PASSWORD_LENGTH'; +ErrPasswordTooShort.status = 400; + +const ErrMissingEmail = new Error('email is required'); +ErrMissingEmail.translation_key = 'EMAIL_REQUIRED'; +ErrMissingEmail.status = 400; + +const ErrMissingPassword = new Error('password is required'); +ErrMissingPassword.translation_key = 'PASSWORD_REQUIRED'; +ErrMissingPassword.status = 400; + +const ErrEmailTaken = new Error('Email address already in use'); +ErrEmailTaken.translation_key = 'EMAIL_IN_USE'; +ErrEmailTaken.status = 400; + +const ErrSpecialChars = new Error('No special characters are allowed in a display name'); +ErrSpecialChars.translation_key = 'NO_SPECIAL_CHARACTERS'; +ErrSpecialChars.status = 400; + +const ErrMissingDisplay = new Error('A display name is required to create a user'); +ErrMissingDisplay.translation_key = 'DISPLAY_NAME_REQUIRED'; +ErrMissingDisplay.status = 400; + +// ErrMissingToken is returned in the event that the password reset is requested +// without a token. +const ErrMissingToken = new Error('token is required'); +ErrMissingToken.status = 400; + +// ErrContainsProfanity is returned in the event that the middleware detects +// profanity/wordlisted words in the payload. +const ErrContainsProfanity = new Error('Suspected profanity. If you think this in error, please let us know!'); +ErrContainsProfanity.translation_key = 'PROFANITY_ERROR'; +ErrContainsProfanity.status = 400; + +module.exports = { + ErrPasswordTooShort, + ErrMissingEmail, + ErrMissingPassword, + ErrEmailTaken, + ErrSpecialChars, + ErrMissingDisplay, + ErrContainsProfanity +}; diff --git a/init.js b/init.js index ec675d5a4..c29684b23 100644 --- a/init.js +++ b/init.js @@ -3,5 +3,5 @@ const Setting = require('./models/setting'); module.exports = () => Promise.all([ // Upsert the settings object. - Setting.init({id: '1', moderation: 'pre'}) + Setting.init({id: '1', moderation: 'pre', wordlist: {banned: [], suspect: []}}) ]); diff --git a/models/comment.js b/models/comment.js index b2bd0367c..3f9ae1a65 100644 --- a/models/comment.js +++ b/models/comment.js @@ -201,7 +201,7 @@ CommentSchema.statics.findByActionType = (action_type) => Action * @return {Promise} */ CommentSchema.statics.findIdsByActionType = (action_type) => Action - .findCommentsIdByActionType(action_type, 'comment') + .findCommentsIdByActionType(action_type, 'comments') .then((actions) => actions.map(a => a.item_id)); /** diff --git a/models/user.js b/models/user.js index 5a0e7a640..a0993c55a 100644 --- a/models/user.js +++ b/models/user.js @@ -5,6 +5,8 @@ const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); const Action = require('./action'); const Comment = require('./comment'); +const Wordlist = require('../services/wordlist'); +const errors = require('../errors'); const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; @@ -309,6 +311,27 @@ UserService.createLocalUsers = (users) => { })); }; +/** + * Check the requested displayname for naughty words (currently in English) and special chars + * @param {String} displayName word to be checked for profanity + * @return {Promise} rejected if the machine's sensibilites are offended + */ +const isValidDisplayName = (displayName) => { + const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/; + + if (!displayName) { + return Promise.reject(errors.ErrMissingDisplay); + } + + if (!onlyLettersNumbersUnderscore.test(displayName)) { + + return Promise.reject(errors.ErrSpecialChars); + } + + // check for profanity + return Wordlist.displayNameCheck(displayName); +}; + /** * Creates the local user with a given email, password, and name. * @param {String} email email of the new user @@ -317,49 +340,54 @@ UserService.createLocalUsers = (users) => { * @param {Function} done callback */ UserService.createLocalUser = (email, password, displayName) => { + if (!email) { - return Promise.reject('email is required'); + return Promise.reject(errors.ErrMissingEmail); } - email = email.toLowerCase(); + email = email.toLowerCase().trim(); + displayName = displayName.toLowerCase().trim(); if (!password) { - return Promise.reject('password is required'); + return Promise.reject(errors.ErrMissingPassword); } - if (!displayName) { - return Promise.reject('displayName is required'); + if (password.length < 8) { + return Promise.reject(errors.ErrPasswordTooShort); } - return new Promise((resolve, reject) => { - bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { - if (err) { - return reject(err); - } - - let user = new UserModel({ - displayName: displayName, - password: hashedPassword, - roles: [], - profiles: [ - { - id: email, - provider: 'local' + return isValidDisplayName(displayName) + .then(() => { // displayName is valid + return new Promise((resolve, reject) => { + bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => { + if (err) { + return reject(err); } - ] - }); - user.save((err) => { - if (err) { - if (err.code === 11000) { - return reject('Email address already in use'); - } - return reject(err); - } - return resolve(user); + let user = new UserModel({ + displayName: displayName, + password: hashedPassword, + roles: [], + profiles: [ + { + id: email, + provider: 'local' + } + ] + }); + + user.save((err) => { + if (err) { + if (err.code === 11000) { + return reject(errors.ErrEmailTaken); + } + return reject(err); + } + return resolve(user); + }); + }); }); }); - }); }; /** diff --git a/package.json b/package.json index 495ac74fe..e6e211d07 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "app.js", "scripts": { "start": "./bin/cli serve --jobs", + "dev-start": "nodemon --exec \"./bin/cli -c .env serve --jobs\"", "build": "NODE_ENV=production webpack --config webpack.config.js --bail", "build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch", "lint": "eslint bin/* .", @@ -54,6 +55,7 @@ "connect-redis": "^3.1.0", "csurf": "^1.9.0", "debug": "^2.2.0", + "dotenv": "^4.0.0", "ejs": "^2.5.2", "env-rewrite": "^1.0.2", "express": "^4.14.0", @@ -121,6 +123,7 @@ "mocha-junit-reporter": "^1.12.1", "nightwatch": "^0.9.11", "node-fetch": "^1.6.3", + "nodemon": "^1.11.0", "postcss-loader": "^1.1.0", "postcss-modules": "^0.5.2", "postcss-smart-import": "^0.5.1", diff --git a/routes/api/account/index.js b/routes/api/account/index.js index 645b9c652..3f699a51d 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -3,15 +3,7 @@ const router = express.Router(); const User = require('../../../models/user'); const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); - -// ErrPasswordTooShort is returned when the password length is too short. -const ErrPasswordTooShort = new Error('password must be at least 8 characters'); -ErrPasswordTooShort.status = 400; - -// ErrMissingToken is returned in the event that the password reset is requested -// without a token. -const ErrMissingToken = new Error('token is required'); -ErrMissingToken.status = 400; +const errors = require('../../../errors'); //============================================================================== // ROUTES @@ -31,7 +23,7 @@ router.post('/email/confirm', (req, res, next) => { } = req.body; if (!token) { - return next(ErrMissingToken); + return next(errors.ErrMissingToken); } User @@ -73,7 +65,7 @@ router.post('/password/reset', (req, res, next) => { token, rootURL: process.env.TALK_ROOT_URL }, - subject: 'Password Reset Requested - Talk', + subject: 'Password Reset', to: email }); }) @@ -101,11 +93,11 @@ router.put('/password/reset', (req, res, next) => { } = req.body; if (!token) { - return next(ErrMissingToken); + return next(errors.ErrMissingToken); } if (!password || password.length < 8) { - return next(ErrPasswordTooShort); + return next(errors.ErrPasswordTooShort); } User.verifyPasswordResetToken(token) diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index c7cfde9b5..6a84c6049 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -17,7 +17,7 @@ router.get('/', (req, res, next) => { }, (req, res) => { // Send back the user object. - res.json({user: req.user.toObject()}); + res.json({user: req.user}); }); /** diff --git a/routes/api/users/index.js b/routes/api/users/index.js index 1fadc7857..756293c16 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -52,6 +52,34 @@ router.post('/:user_id/status', (req, res, next) => { .catch(next); }); +// /** +// * SendEmailConfirmation sends a confirmation email to the user. +// * @param {Request} req express request object +// * @param {String} email user email address +// */ + +/** + * SendEmailConfirmation sends a confirmation email to the user. + * @param {ExpressApp} app the instance of the express app + * @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 = (app, userID, email) => User + .createEmailConfirmToken(userID, email) + .then((token) => { + return mailer.sendSimple({ + app, // needed to render the templates. + template: 'email/email-confirm', // needed to know which template to render! + locals: { // specifies the template locals. + token, + rootURL: process.env.TALK_ROOT_URL, + email + }, + subject: 'Email Confirmation', + to: email + }); + }); + router.post('/', (req, res, next) => { const { email, @@ -70,23 +98,7 @@ router.post('/', (req, res, next) => { if (requireEmailConfirmation) { - // Email confirmation is required, let's generate that token and send - // the email. - return User - .createEmailConfirmToken(user.id, email) - .then((token) => { - return mailer.sendSimple({ - app: req.app, // needed to render the templates. - template: 'email/email-confirm', // needed to know which template to render! - locals: { // specifies the template locals. - token, - rootURL: process.env.TALK_ROOT_URL, - email - }, - subject: 'Email Confirmation - Talk', - to: email - }); - }) + SendEmailConfirmation(req.app, user.id, email) .then(() => { // Then send back the user. @@ -121,4 +133,37 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { }); }); +router.post('/:user_id/email/confirm', authorization.needed('admin'), (req, res, next) => { + const { + user_id + } = req.params; + + User + .findById(user_id) + .then((user) => { + if (!user) { + res.status(404).end(); + return; + } + + // Find the first local profile. + let localProfile = user.profiles.find((profile) => profile.provider === 'local'); + + // If there was no local profile for the user, error out. + if (!localProfile) { + res.status(404).end(); + return; + } + + // Send the email to the first local profile that was found. + return SendEmailConfirmation(req.app, user.id, localProfile.id) + .then(() => { + res.status(204).end(); + }); + }) + .catch((err) => { + next(err); + }); +}); + module.exports = router; diff --git a/services/kue.js b/services/kue.js index e9fbe43e8..ac299a3d5 100644 --- a/services/kue.js +++ b/services/kue.js @@ -14,7 +14,7 @@ const Queue = module.exports.queue = kue.createQueue({ } }); -module.exports.Task = class Task { +class Task { constructor({name, attempts = 3, delay = 1000}) { this.name = name; @@ -76,4 +76,59 @@ module.exports.Task = class Task { }); }); } -}; +} + +/** + * Stores the tasks during testing. + * @type {Array} + */ +const TestQueue = []; + +/** + * TestTask is a Task queue that is implemented for when the application is in + * test mode, and does not send the jobs to redis, instead it queues them in + * an array which can be inspected. + */ +class TestTask { + + constructor({name}) { + this.name = name; + } + + /** + * Push the task into the fake queue. + */ + create(task) { + let id = TestQueue.push({ + name: this.name, + task + }); + + return Promise.resolve({id}); + } + + // This is a NO-OP action simply provided to match the Task interface. + process() { return null; } + + /** + * Returns the current tasks for this queue. + * @return {Array} the tasks in the queue + */ + get tasks() { + return TestQueue + .filter((testTask) => testTask.name === this.name) + .map((testTask) => testTask.task); + } + + static shutdown() { + return Task.shutdown(); + } + +} + +if (process.env.NODE_ENV === 'test') { + module.exports.Task = TestTask; + module.exports.TestQueue = TestQueue; +} else { + module.exports.Task = Task; +} diff --git a/services/mailer.js b/services/mailer.js index 6fef876d6..6615ee9dc 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -65,13 +65,16 @@ const mailer = module.exports = { return Promise.reject('sendSimple requires a subject for the email'); } + // Prefix the subject with `[Talk]`. + subject = `[Talk] ${subject}`; + return Promise.all([ // Render the HTML version of the email. - mailer.render(app, template, locals), + mailer.render(app, `${template}.ejs`, locals), // Render the TEXT version of the email. - mailer.render(app, `${template}.txt`, locals) + mailer.render(app, `${template}.txt.ejs`, locals) ]) .then(([html, text]) => { diff --git a/services/wordlist.js b/services/wordlist.js index 96e0c3b1f..e912abc43 100644 --- a/services/wordlist.js +++ b/services/wordlist.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const natural = require('natural'); const tokenizer = new natural.WordTokenizer(); const Setting = require('../models/setting'); +const Errors = require('../errors'); /** * The root wordlist object. @@ -143,7 +144,7 @@ class Wordlist { if (this.match(this.lists.banned, phrase)) { debug(`the field "${field}" contained a phrase "${phrase}" which contained a banned word/phrase`); - errors.banned = ErrContainsProfanity; + errors.banned = Errors.ErrContainsProfanity; // Stop looping through the fields now, we discovered the worst possible // situation (a banned word). @@ -154,7 +155,7 @@ class Wordlist { if (this.match(this.lists.suspect, phrase)) { debug(`the field "${field}" contained a phrase "${phrase}" which contained a suspected word/phrase`); - errors.suspect = ErrContainsProfanity; + errors.suspect = Errors.ErrContainsProfanity; // Continue looping through the fields now, we discovered a possible bad // word (suspect). @@ -165,6 +166,28 @@ class Wordlist { return errors; } + /** + * check potential username for banned words, special characters + */ + static displayNameCheck(displayName) { + const wl = new Wordlist(); + return wl.load() + .then(() => { + displayName = displayName.replace(/_/g, ''); + + // test each word, and fail if we find a match + const hasBadWords = wl.lists.banned.some(phrase => { + return displayName.indexOf(phrase.join('')) !== -1; + }); + + if (hasBadWords) { + throw Errors.ErrContainsProfanity; + } else { + return Promise.resolve(displayName); + } + }); + } + /** * Connect middleware for scanning request bodies for wordlisted words and * attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise @@ -194,10 +217,4 @@ class Wordlist { } } -// ErrContainsProfanity is returned in the event that the middleware detects -// profanity/wordlisted words in the payload. -const ErrContainsProfanity = new Error('contains profanity'); -ErrContainsProfanity.status = 400; - module.exports = Wordlist; -module.exports.ErrContainsProfanity = ErrContainsProfanity; diff --git a/tests/kue.js b/tests/kue.js new file mode 100644 index 000000000..504760b82 --- /dev/null +++ b/tests/kue.js @@ -0,0 +1,7 @@ +const kue = require('../services/kue'); + +beforeEach(() => { + + // Empty the test tasks before finishing. + kue.TestQueue.splice(0, kue.TestQueue.length); +}); diff --git a/tests/models/comment.js b/tests/models/comment.js index 3db7d67a2..91f51ccb8 100644 --- a/tests/models/comment.js +++ b/tests/models/comment.js @@ -3,7 +3,7 @@ const User = require('../../models/user'); const Action = require('../../models/action'); const Setting = require('../../models/setting'); -const settings = {id: '1', moderation: 'pre'}; +const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; const expect = require('chai').expect; @@ -63,31 +63,34 @@ describe('models.Comment', () => { const users = [{ email: 'stampi@gmail.com', displayName: 'Stampi', - password: '1Coral!' + password: '1Coral!!' }, { email: 'sockmonster@gmail.com', displayName: 'Sockmonster', - password: '2Coral!' + password: '2Coral!!' }]; const actions = [{ action_type: 'flag', item_id: '3', - item_type: 'comment', + item_type: 'comments', user_id: '123' }, { action_type: 'like', item_id: '1', - item_type: 'comment', + item_type: 'comments', user_id: '456' }]; - beforeEach(() => Promise.all([ - Setting.init(settings), - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ])); + beforeEach(() => { + return Setting.init(settings).then(() => { + return Promise.all([ + Comment.create(comments), + User.createLocalUsers(users), + Action.create(actions) + ]); + }); + }); describe('#publicCreate()', () => { diff --git a/tests/models/user.js b/tests/models/user.js index 7484880bf..69be314c4 100644 --- a/tests/models/user.js +++ b/tests/models/user.js @@ -1,25 +1,30 @@ const User = require('../../models/user'); const Comment = require('../../models/comment'); +const Setting = require('../../models/setting'); const expect = require('chai').expect; describe('models.User', () => { let mockUsers; beforeEach(() => { - return User.createLocalUsers([{ - email: 'stampi@gmail.com', - displayName: 'Stampi', - password: '1Coral!' - }, { - email: 'sockmonster@gmail.com', - displayName: 'Sockmonster', - password: '2Coral!' - }, { - email: 'marvel@gmail.com', - displayName: 'Marvel', - password: '3Coral!' - }]).then((users) => { - mockUsers = users; + const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; + + return Setting.init(settings).then(() => { + return User.createLocalUsers([{ + email: 'stampi@gmail.com', + displayName: 'Stampi', + password: '1Coral!-' + }, { + email: 'sockmonster@gmail.com', + displayName: 'Sockmonster', + password: '2Coral!2' + }, { + email: 'marvel@gmail.com', + displayName: 'Marvel', + password: '3Coral!3' + }]).then((users) => { + mockUsers = users; + }); }); }); @@ -29,7 +34,7 @@ describe('models.User', () => { .findById(mockUsers[0].id) .then((user) => { expect(user).to.have.property('displayName') - .and.to.equal('Stampi'); + .and.to.equal('stampi'); }); }); }); @@ -54,7 +59,7 @@ describe('models.User', () => { return 0; }); expect(sorted[0]).to.have.property('displayName') - .and.to.equal('Marvel'); + .and.to.equal('marvel'); }); }); }); @@ -63,16 +68,16 @@ describe('models.User', () => { it('should find a user when we give the right credentials', () => { return User - .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!') + .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') .then((user) => { expect(user).to.have.property('displayName') - .and.to.equal(mockUsers[0].displayName); + .and.to.equal(mockUsers[0].displayName.toLowerCase()); }); }); it('should not find the user when we give the wrong credentials', () => { return User - .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!') + .findLocalUser(mockUsers[0].profiles[0].id, '1Coral!-') .then((user) => { expect(user).to.equal(false); }); diff --git a/tests/routes/api/auth/index.js b/tests/routes/api/auth/index.js index e3907575a..6b39e0c8e 100644 --- a/tests/routes/api/auth/index.js +++ b/tests/routes/api/auth/index.js @@ -24,14 +24,18 @@ const Setting = require('../../../../models/setting'); describe('/api/v1/auth/local', () => { let mockUser; - beforeEach(() => User.createLocalUser('maria@gmail.com', 'password!', 'Maria').then((user) => { - mockUser = user; - })); + beforeEach(() => { + const settings = {requireEmailConfirmation: false, wordlist: {banned: ['bad'], suspect: ['naughty']}}; + return Setting.init(settings).then(() => { + return User.createLocalUser('maria@gmail.com', 'password!', 'Maria') + .then((user) => { + mockUser = user; + }); + }); + }); describe('email confirmation disabled', () => { - beforeEach(() => Setting.init({requireEmailConfirmation: false})); - describe('#post', () => { it('should send back the user on a successful login', () => { return chai.request(app) @@ -41,7 +45,7 @@ describe('/api/v1/auth/local', () => { expect(res2).to.have.status(200); expect(res2).to.be.json; expect(res2.body).to.have.property('user'); - expect(res2.body.user).to.have.property('displayName', 'Maria'); + expect(res2.body.user).to.have.property('displayName', 'maria'); }); }); @@ -84,7 +88,7 @@ describe('/api/v1/auth/local', () => { expect(res).to.have.status(200); expect(res).to.be.json; expect(res.body).to.have.property('user'); - expect(res.body.user).to.have.property('displayName', 'Maria'); + expect(res.body.user).to.have.property('displayName', 'maria'); }); }); }); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index 5aaad43db..360daa5dc 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -48,21 +48,21 @@ describe('/api/v1/comments', () => { const users = [{ displayName: 'Ana', email: 'ana@gmail.com', - password: '123' + password: '123456789' }, { displayName: 'Maria', email: 'maria@gmail.com', - password: '123' + password: '123456789' }]; const actions = [{ action_type: 'flag', item_id: 'abc', - item_type: 'comment' + item_type: 'comments' }, { action_type: 'like', item_id: 'hij', - item_type: 'comment' + item_type: 'comments' }]; beforeEach(() => { @@ -328,11 +328,11 @@ describe('/api/v1/comments/:comment_id', () => { const users = [{ displayName: 'Ana', email: 'ana@gmail.com', - password: '123' + password: '123456789' }, { displayName: 'Maria', email: 'maria@gmail.com', - password: '123' + password: '123456789' }]; const actions = [{ @@ -346,11 +346,13 @@ describe('/api/v1/comments/:comment_id', () => { }]; beforeEach(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); + return Setting.init(settings).then(() => { + return Promise.all([ + Comment.create(comments), + User.createLocalUsers(users), + Action.create(actions) + ]); + }); }); describe('#get', () => { @@ -437,11 +439,11 @@ describe('/api/v1/comments/:comment_id/actions', () => { const users = [{ displayName: 'Ana', email: 'ana@gmail.com', - password: '123' + password: '123456789' }, { displayName: 'Maria', email: 'maria@gmail.com', - password: '123' + password: '123456789' }]; const actions = [{ @@ -453,11 +455,13 @@ describe('/api/v1/comments/:comment_id/actions', () => { }]; beforeEach(() => { - return Promise.all([ - Comment.create(comments), - User.createLocalUsers(users), - Action.create(actions) - ]); + return Setting.init(settings).then(() => { + return Promise.all([ + Comment.create(comments), + User.createLocalUsers(users), + Action.create(actions) + ]); + }); }); describe('#post', () => { diff --git a/tests/routes/api/queue/index.js b/tests/routes/api/queue/index.js index 77f96bd9a..378899967 100644 --- a/tests/routes/api/queue/index.js +++ b/tests/routes/api/queue/index.js @@ -13,7 +13,7 @@ const Action = require('../../../../models/action'); const User = require('../../../../models/user'); const Setting = require('../../../../models/setting'); -const settings = {id: '1', moderation: 'pre'}; +const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['banned'], suspect: ['suspect']}}; describe('/api/v1/queue', () => { const comments = [{ @@ -44,11 +44,11 @@ describe('/api/v1/queue', () => { const users = [{ displayName: 'Ana', email: 'ana@gmail.com', - password: '123' + password: '123456789' }, { displayName: 'Maria', email: 'maria@gmail.com', - password: '123' + password: '123456789' }]; const actions = [{ @@ -62,23 +62,25 @@ describe('/api/v1/queue', () => { }]; beforeEach(() => { - return User.createLocalUsers(users) - .then((u) => { - comments[0].author_id = u[0].id; - comments[1].author_id = u[1].id; - comments[2].author_id = u[1].id; + return Setting.init(settings).then(() => { + return User.createLocalUsers(users) + .then((u) => { + comments[0].author_id = u[0].id; + comments[1].author_id = u[1].id; + comments[2].author_id = u[1].id; - return Comment.create(comments); - }) - .then((c) => { - actions[0].item_id = c[0].id; - actions[1].item_id = c[1].id; + return Comment.create(comments); + }) + .then((c) => { + actions[0].item_id = c[0].id; + actions[1].item_id = c[1].id; - return Promise.all([ - Action.create(actions), - Setting.init(settings) - ]); - }); + return Promise.all([ + Action.create(actions), + Setting.init(settings) + ]); + }); + }); }); it('should return all the pending comments, users and actions', () => { diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js index 67088e37e..5d192e976 100644 --- a/tests/routes/api/stream/index.js +++ b/tests/routes/api/stream/index.js @@ -17,7 +17,11 @@ describe('/api/v1/stream', () => { describe('#get', () => { const settings = { id: '1', - moderation: 'post' + moderation: 'post', + wordlist: { + banned: ['banned'], + suspect: ['suspect'] + } }; const comments = [{ @@ -55,11 +59,11 @@ describe('/api/v1/stream', () => { const users = [{ displayName: 'Ana', email: 'ana@gmail.com', - password: '123' + password: '123456789' }, { displayName: 'Maria', email: 'maria@gmail.com', - password: '123' + password: '123456789' }]; const actions = [{ @@ -71,34 +75,35 @@ describe('/api/v1/stream', () => { }]; beforeEach(() => { - return Promise.all([ - User.createLocalUsers(users), - Asset.findOrCreateByUrl('http://test.com'), - Asset - .findOrCreateByUrl('http://coralproject.net/asset2') - .then((asset) => { - return Asset - .overrideSettings(asset.id, {moderation: 'pre'}) - .then(() => asset); - }) - ]) - .then(([users, asset1, asset2]) => { - - comments[0].author_id = users[0].id; - comments[1].author_id = users[1].id; - comments[2].author_id = users[0].id; - comments[3].author_id = users[1].id; - - comments[0].asset_id = asset1.id; - comments[1].asset_id = asset1.id; - comments[2].asset_id = asset2.id; - comments[3].asset_id = asset2.id; - + return Setting.init(settings).then(() => { return Promise.all([ - Comment.create(comments), - Action.create(actions), - Setting.init(settings) - ]); + User.createLocalUsers(users), + Asset.findOrCreateByUrl('http://test.com'), + Asset + .findOrCreateByUrl('http://coralproject.net/asset2') + .then((asset) => { + return Asset + .overrideSettings(asset.id, {moderation: 'pre'}) + .then(() => asset); + }) + ]) + .then(([users, asset1, asset2]) => { + + comments[0].author_id = users[0].id; + comments[1].author_id = users[1].id; + comments[2].author_id = users[0].id; + comments[3].author_id = users[1].id; + + comments[0].asset_id = asset1.id; + comments[1].asset_id = asset1.id; + comments[2].asset_id = asset2.id; + comments[3].asset_id = asset2.id; + + return Promise.all([ + Comment.create(comments), + Action.create(actions) + ]); + }); }); }); diff --git a/tests/routes/api/user/index.js b/tests/routes/api/user/index.js index 01c48d2de..3aa6cbe3c 100644 --- a/tests/routes/api/user/index.js +++ b/tests/routes/api/user/index.js @@ -1,29 +1,71 @@ const passport = require('../../../passport'); const app = require('../../../../app'); +const mailer = require('../../../../services/mailer'); const chai = require('chai'); const expect = chai.expect; +const Setting = require('../../../../models/setting'); +const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; + // Setup chai. chai.should(); chai.use(require('chai-http')); const User = require('../../../../models/user'); +describe('/api/v1/users/:user_id/email/confirm', () => { + + let mockUser; + + beforeEach(() => Setting.init(settings).then(() => { + return User.createLocalUser('ana@gmail.com', '123321123', 'Ana'); + }) + .then((user) => { + mockUser = user; + })); + + describe('#post', () => { + it('should send an email when we hit the endpoint', () => { + expect(mailer.task.tasks).to.have.length(0); + + return chai.request(app) + .post(`/api/v1/users/${mockUser.id}/email/confirm`) + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(204); + expect(mailer.task.tasks).to.have.length(1); + }); + }); + + it('should send a 404 on not matching a user', () => { + return chai.request(app) + .post(`/api/v1/users/${mockUser.id}/email/confirm`) + .set(passport.inject({roles: ['admin']})) + .then((res) => { + expect(res).to.have.status(204); + expect(mailer.task.tasks).to.have.length(1); + }); + }); + }); +}); + describe('/api/v1/users/:user_id/actions', () => { const users = [{ displayName: 'Ana', email: 'ana@gmail.com', - password: '123' + password: '123456789' }, { displayName: 'Maria', email: 'maria@gmail.com', - password: '123' + password: '123456789' }]; beforeEach(() => { - return User.createLocalUsers(users); + return Setting.init(settings).then(() => { + return User.createLocalUsers(users); + }); }); describe('#post', () => { diff --git a/tests/services/wordlist.js b/tests/services/wordlist.js index 4edfef0c7..5bb1d32ab 100644 --- a/tests/services/wordlist.js +++ b/tests/services/wordlist.js @@ -1,6 +1,7 @@ const expect = require('chai').expect; - +const Errors = require('../../errors'); const Wordlist = require('../../services/wordlist'); +const Setting = require('../../models/setting'); describe('wordlist: services', () => { @@ -16,6 +17,9 @@ describe('wordlist: services', () => { }; let wordlist = new Wordlist(); + const settings = {id: '1', moderation: 'pre', wordlist: {banned: ['bad words'], suspect: ['suspect words']}}; + + beforeEach(() => Setting.init(settings)); describe('#init', () => { @@ -67,7 +71,7 @@ describe('wordlist: services', () => { content: 'how to do really bad things?' }, 'content'); - expect(errors).to.have.property('banned', Wordlist.ErrContainsProfanity); + expect(errors).to.have.property('banned', Errors.ErrContainsProfanity); }); it('does not match on bodies not containing bad words', () => {