├── .editorconfig ├── .env-sample ├── .gitignore ├── .travis.yml ├── LICENSE ├── Procfile ├── README.md ├── config.js ├── first-time-setup.js ├── manifest.js ├── package-lock.json ├── package.json ├── server.js ├── server ├── api │ ├── accounts.js │ ├── admin-groups.js │ ├── admins.js │ ├── contact.js │ ├── login.js │ ├── logout.js │ ├── main.js │ ├── sessions.js │ ├── signup.js │ ├── statuses.js │ └── users.js ├── auth.js ├── emails │ ├── contact.hbs.md │ ├── forgot-password.hbs.md │ └── welcome.hbs.md ├── mailer.js ├── models │ ├── account.js │ ├── admin-group.js │ ├── admin.js │ ├── auth-attempt.js │ ├── note-entry.js │ ├── session.js │ ├── status-entry.js │ ├── status.js │ └── user.js ├── preware.js └── web │ └── main.js └── test ├── config.js ├── manifest.js ├── server.js └── server ├── api ├── accounts.js ├── admin-groups.js ├── admins.js ├── contact.js ├── login.js ├── logout.js ├── main.js ├── sessions.js ├── signup.js ├── statuses.js └── users.js ├── auth.js ├── fixtures ├── creds.js ├── db.js ├── hapi.js └── index.js ├── mailer.js ├── models ├── account.js ├── admin-group.js ├── admin.js ├── auth-attempt.js ├── note-entry.js ├── session.js ├── status-entry.js ├── status.js └── user.js ├── preware.js └── web └── main.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # 4 space indentation 2 | [*.js] 3 | indent_style = space 4 | indent_size = 4 5 | -------------------------------------------------------------------------------- /.env-sample: -------------------------------------------------------------------------------- 1 | SMTP_PASSWORD=secret 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | test/artifacts/* 3 | .env 4 | .eslintrc.js 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "11" 5 | - "12" 6 | services: 7 | - mongodb 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2014 Reza Akhavan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node server.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # No longer maintained 2 | 3 | Boilerplates can be a huge time sink to maintain and I've decieded to archive 4 | this project. 5 | 6 | Thanks for your interest in my projects. 7 | 8 | - - - - - - - - - - 9 | 10 | # Frame 11 | 12 | A user system API starter. Bring your own front-end. 13 | 14 | [![Build Status](https://travis-ci.org/jedireza/frame.svg?branch=master)](https://travis-ci.org/jedireza/frame) 15 | [![Dependency Status](https://david-dm.org/jedireza/frame.svg?style=flat)](https://david-dm.org/jedireza/frame) 16 | [![devDependency Status](https://david-dm.org/jedireza/frame/dev-status.svg?style=flat)](https://david-dm.org/jedireza/frame#info=devDependencies) 17 | 18 | 19 | ## Features 20 | 21 | - Login system with forgot password and reset password 22 | - Abusive login attempt detection 23 | - User roles for accounts and admins 24 | - Admins only notes and status history for accounts 25 | - Admin groups with shared permissions 26 | - Admin level permissions that override group permissions 27 | 28 | 29 | ## Technology 30 | 31 | Frame is built with the [hapi](https://hapijs.com/) framework. We're 32 | using [MongoDB](http://www.mongodb.org/) as a data store. 33 | 34 | 35 | ## Bring your own front-end 36 | 37 | Frame is only a restful JSON API. If you'd like a ready made front-end, 38 | checkout [Aqua](https://github.com/jedireza/aqua). Or better yet, fork 39 | this repo and build one on top of Frame. 40 | 41 | 42 | ## Live demo 43 | 44 | | url | username | password | 45 | |:-------------------------------------------------------------------------------------------------- |:-------- |:-------- | 46 | | [https://getframe.herokuapp.com/](https://getframe.herokuapp.com/) | root | root | 47 | | [https://getframe.herokuapp.com/documentation](https://getframe.herokuapp.com/documentation) | | | 48 | 49 | [Postman](http://www.getpostman.com/) is a great tool for testing and 50 | developing APIs. See the wiki for details on [how to 51 | login](https://github.com/jedireza/frame/wiki/How-to-login). 52 | 53 | 54 | ## Requirements 55 | 56 | You need [Node.js](http://nodejs.org/download/) `>=8.x` and you'll need a 57 | [MongoDB](http://www.mongodb.org/downloads) `>=2.6` server running. 58 | 59 | 60 | ## Installation 61 | 62 | ```bash 63 | $ git clone https://github.com/jedireza/frame.git 64 | $ cd frame 65 | $ npm install 66 | ``` 67 | 68 | 69 | ## Configuration 70 | 71 | Simply edit `config.js`. The configuration uses 72 | [`confidence`](https://github.com/hapijs/confidence) which makes it easy to 73 | manage configuration settings across environments. __Don't store secrets in 74 | this file or commit them to your repository.__ 75 | 76 | __Instead, access secrets via environment variables.__ We use 77 | [`dotenv`](https://github.com/motdotla/dotenv) to help make setting local 78 | environment variables easy (not to be used in production). 79 | 80 | Simply copy `.env-sample` to `.env` and edit as needed. __Don't commit `.env` 81 | to your repository.__ 82 | 83 | 84 | ## First time setup 85 | 86 | __WARNING__: This will clear all data in the following MongoDB collections if 87 | they exist: `accounts`, `adminGroups`, `admins`, `authAttempts`, `sessions`, 88 | `statuses`, and `users`. 89 | 90 | ```bash 91 | $ npm run first-time-setup 92 | 93 | # > frame@0.0.0 first-time-setup /home/jedireza/projects/frame 94 | # > node first-time-setup.js 95 | 96 | # MongoDB URL: (mongodb://localhost:27017/frame) 97 | # Root user email: jedireza@gmail.com 98 | # Root user password: 99 | # Setup complete. 100 | ``` 101 | 102 | 103 | ## Running the app 104 | 105 | ```bash 106 | $ npm start 107 | 108 | # > frame@0.0.0 start /home/jedireza/projects/frame 109 | # > ./node_modules/nodemon/bin/nodemon.js -e js,md server 110 | 111 | # 09 Sep 03:47:15 - [nodemon] v1.10.2 112 | # ... 113 | ``` 114 | 115 | Now you should be able to point your browser to http://127.0.0.1:9000/ and 116 | see the welcome message. 117 | 118 | [`nodemon`](https://github.com/remy/nodemon) watches for changes in server 119 | code and restarts the app automatically. 120 | 121 | ### With the debugger 122 | 123 | ```bash 124 | $ npm run inspect 125 | 126 | # > frame@0.0.0 inspect /home/jedireza/projects/frame 127 | # > nodemon --inspect -e js,md server.js 128 | 129 | # [nodemon] 1.14.12 130 | # [nodemon] to restart at any time, enter `rs` 131 | # [nodemon] watching: *.* 132 | # [nodemon] starting `node --inspect server.js` 133 | # Debugger listening on ws://127.0.0.1:9229/3d706d9a-b3e0-4fc6-b64e-e7968b7f94d0 134 | # For help see https://nodejs.org/en/docs/inspector 135 | # 180203/193534.071, [log,info,mongodb] data: HapiMongoModels: successfully connected to the db. 136 | # 180203/193534.127, [log,info,mongodb] data: HapiMongoModels: finished processing auto indexes. 137 | # Server started on port 9000 138 | ``` 139 | 140 | Once started with the debuger you can open Google Chrome and go to 141 | [chrome://inspect](chrome://inspect). See https://nodejs.org/en/docs/inspector/ 142 | for more details. 143 | 144 | 145 | ## Running in production 146 | 147 | ```bash 148 | $ node server.js 149 | ``` 150 | 151 | Unlike `$ npm start` this doesn't watch for file changes. Also be sure to set 152 | these environment variables in your production environment: 153 | 154 | - `NODE_ENV=production` - This is important for many different 155 | optimizations. 156 | - `NPM_CONFIG_PRODUCTION=false` - This tells `$ npm install` to not skip 157 | installing `devDependencies`, which we may need to run the first time 158 | setup script. 159 | 160 | 161 | ## Have a question? 162 | 163 | Any issues or questions (no matter how basic), open an issue. Please take the 164 | initiative to read relevant documentation and be pro-active with debugging. 165 | 166 | 167 | ## Want to contribute? 168 | 169 | Contributions are welcome. If you're changing something non-trivial, you may 170 | want to submit an issue before creating a large pull request. 171 | 172 | 173 | ## Running tests 174 | 175 | [Lab](https://github.com/hapijs/lab) is part of the hapi ecosystem and what we 176 | use to write all of our tests. 177 | 178 | ```bash 179 | $ npm test 180 | 181 | # > frame@0.0.0 test /home/jedireza/projects/frame 182 | # > lab -c -L 183 | 184 | # .................................................. 185 | # .................................................. 186 | # .................................................. 187 | # .............. 188 | 189 | # 164 tests complete 190 | # Test duration: 14028 ms 191 | # No global variable leaks detected 192 | # Coverage: 100.00% 193 | # Linting results: No issues 194 | ``` 195 | 196 | ### Targeted tests 197 | 198 | If you'd like to run a specific test or subset of tests you can use the 199 | `test-server` npm script. 200 | 201 | You specificy the path(s) via the `TEST_TARGET` environment variable like: 202 | 203 | ```bash 204 | $ TEST_TARGET=test/server/web/main.js npm run test-server 205 | ``` 206 | 207 | ## License 208 | 209 | MIT 210 | 211 | 212 | ## Don't forget 213 | 214 | What you build with Frame is more important than Frame. 215 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Confidence = require('confidence'); 4 | const Dotenv = require('dotenv'); 5 | 6 | 7 | Dotenv.config({ silent: true }); 8 | 9 | const criteria = { 10 | env: process.env.NODE_ENV 11 | }; 12 | 13 | 14 | const config = { 15 | $meta: 'This file configures the plot device.', 16 | projectName: 'Frame', 17 | port: { 18 | web: { 19 | $filter: 'env', 20 | test: 9090, 21 | production: process.env.PORT, 22 | $default: 9000 23 | } 24 | }, 25 | authAttempts: { 26 | forIp: 50, 27 | forIpAndUser: 7 28 | }, 29 | hapiMongoModels: { 30 | mongodb: { 31 | connection: { 32 | uri: { 33 | $filter: 'env', 34 | production: process.env.MONGODB_URI, 35 | $default: process.env.MONGODB_URI || 'mongodb://localhost:27017/' 36 | }, 37 | db: { 38 | $filter: 'env', 39 | production: process.env.MONGODB_DB_NAME, 40 | test: 'frame-test', 41 | $default: 'frame' 42 | } 43 | } 44 | }, 45 | autoIndex: false 46 | }, 47 | nodemailer: { 48 | host: 'smtp.gmail.com', 49 | port: 465, 50 | secure: true, 51 | auth: { 52 | user: 'jedireza@gmail.com', 53 | pass: process.env.SMTP_PASSWORD 54 | } 55 | }, 56 | system: { 57 | fromAddress: { 58 | name: 'Frame', 59 | address: 'jedireza@gmail.com' 60 | }, 61 | toAddress: { 62 | name: 'Frame', 63 | address: 'jedireza@gmail.com' 64 | } 65 | } 66 | }; 67 | 68 | 69 | const store = new Confidence.Store(config); 70 | 71 | 72 | exports.get = function (key) { 73 | 74 | return store.get(key, criteria); 75 | }; 76 | 77 | 78 | exports.meta = function (key) { 79 | 80 | return store.meta(key, criteria); 81 | }; 82 | -------------------------------------------------------------------------------- /first-time-setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Account = require('./server/models/account'); 4 | const Admin = require('./server/models/admin'); 5 | const AdminGroup = require('./server/models/admin-group'); 6 | const AuthAttempt = require('./server/models/auth-attempt'); 7 | const MongoModels = require('mongo-models'); 8 | const Promptly = require('promptly'); 9 | const Session = require('./server/models/session'); 10 | const Status = require('./server/models/status'); 11 | const User = require('./server/models/user'); 12 | 13 | 14 | const main = async function () { 15 | 16 | let options = {}; 17 | 18 | // get mongodb connection info 19 | 20 | options = { 21 | default: process.env.MONGODB_URI || 'mongodb://localhost:27017/' 22 | }; 23 | const mongodbUri = await Promptly.prompt(`MongoDB URI: (${options.default})`, options); 24 | 25 | options = { 26 | default: 'frame' 27 | }; 28 | const mongodbName = await Promptly.prompt(`MongoDB name: (${options.default})`, options); 29 | 30 | // connect to db 31 | 32 | const db = await MongoModels.connect({ uri: mongodbUri, db: mongodbName }); 33 | 34 | if (!db) { 35 | throw Error('Could not connect to MongoDB.'); 36 | } 37 | 38 | // get root user creds 39 | 40 | const rootEmail = await Promptly.prompt('Root user email:'); 41 | const rootPassword = await Promptly.password('Root user password:'); 42 | 43 | // clear tables 44 | 45 | await Promise.all([ 46 | Account.deleteMany({}), 47 | AdminGroup.deleteMany({}), 48 | Admin.deleteMany({}), 49 | AuthAttempt.deleteMany({}), 50 | Session.deleteMany({}), 51 | Status.deleteMany({}), 52 | User.deleteMany({}) 53 | ]); 54 | 55 | // setup root group 56 | 57 | await AdminGroup.create('Root'); 58 | 59 | // setup root admin and user 60 | 61 | await Admin.insertOne(new Admin({ 62 | _id: Admin.ObjectId('111111111111111111111111'), 63 | groups: { 64 | root: 'Root' 65 | }, 66 | name: { 67 | first: 'Root', 68 | middle: '', 69 | last: 'Admin' 70 | }, 71 | user: { 72 | id: '000000000000000000000000', 73 | name: 'root' 74 | } 75 | })); 76 | 77 | const passwordHash = await User.generatePasswordHash(rootPassword); 78 | 79 | await User.insertOne(new User({ 80 | _id: User.ObjectId('000000000000000000000000'), 81 | email: rootEmail.toLowerCase(), 82 | password: passwordHash.hash, 83 | roles: { 84 | admin: { 85 | id: '111111111111111111111111', 86 | name: 'Root Admin' 87 | } 88 | }, 89 | username: 'root' 90 | })); 91 | 92 | // all done 93 | 94 | MongoModels.disconnect(); 95 | 96 | console.log('First time setup complete.'); 97 | 98 | process.exit(0); 99 | }; 100 | 101 | 102 | main().catch((err) => { 103 | 104 | console.log('First time setup failed.'); 105 | console.error(err); 106 | 107 | MongoModels.disconnect(); 108 | 109 | process.exit(1); 110 | }); 111 | -------------------------------------------------------------------------------- /manifest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Confidence = require('confidence'); 4 | const Config = require('./config'); 5 | const Package = require('./package.json'); 6 | const Path = require('path'); 7 | 8 | 9 | const criteria = { 10 | env: process.env.NODE_ENV 11 | }; 12 | 13 | 14 | const manifest = { 15 | $meta: 'This file defines the plot device.', 16 | server: { 17 | debug: { 18 | request: ['error'] 19 | }, 20 | routes: { 21 | security: true 22 | }, 23 | port: Config.get('/port/web') 24 | }, 25 | register: { 26 | plugins: [ 27 | { 28 | plugin: '@hapi/good', 29 | options: { 30 | reporters: { 31 | myConsoleReporter: [ 32 | { 33 | module: '@hapi/good-squeeze', 34 | name: 'Squeeze', 35 | args: [{ 36 | error: '*', 37 | log: '*', 38 | request: '*', 39 | response:'*' 40 | }] 41 | }, 42 | { 43 | module: '@hapi/good-console', 44 | args: [{ 45 | color: { 46 | $filter: 'env', 47 | production: false, 48 | $default: true 49 | } 50 | }] 51 | }, 52 | 'stdout' 53 | ] 54 | } 55 | } 56 | }, 57 | { 58 | plugin: '@hapi/basic' 59 | }, 60 | { 61 | plugin: 'hapi-remote-address' 62 | }, 63 | { 64 | plugin: '@hapi/inert' 65 | }, 66 | { 67 | plugin: '@hapi/vision' 68 | }, 69 | { 70 | plugin:'hapi-swagger', 71 | options: { 72 | securityDefinitions: { 73 | 'basic': { 74 | 'type': 'apiKey', 75 | 'name': 'Authorization', 76 | 'in': 'header' 77 | } 78 | }, 79 | security: [{ 'basic': [] }], 80 | info: { 81 | title: 'Frame API Documentation', 82 | version: Package.version, 83 | description: `Check out the **[Github Wiki](https://github.com/jedireza/frame/wiki)** for common questions and how-tos. 84 | 85 | A few key things to be aware of: 86 | The core User model found in the /api/users/ endpoints have these basic fields: _id, email, username, password, isActive, roles, timeCreated. 87 | 88 | This framework decorates the core User models with additional role specific fields via mapping it to 1 or more roles. Frame comes with 2 default roles, customers and admins. 89 | 90 | /api/accounts/ is the "customer account" role. 91 | When users sign up via /api/signup the framework automatically creates a new User and a new Account (aka customer role) and links the two. Users can have multiple roles but each new instance of a role model can only be mapped to one user. 92 | The customer Account role adds these additional fields for users who are customers: "name" (first, middle last), "notes", and "status". "Notes" allows admins to add notes to accounts. 93 | 94 | /api/admins/ is the "admin" role. 95 | This role contains a "name" (first, middle, last), "permissions", and "groups" property allowing you to assign multiple admin-groups. The first admin-group is "root" which is allowed to perform the "Root Scope" actions. 96 | 97 | More details on [Users, Roles & Groups](https://github.com/jedireza/frame/wiki/Users,-Roles-&-Groups) 98 | More details on [Admin & Admin Group Permissions](https://github.com/jedireza/frame/wiki/Admin-&-Admin-Group-Permissions)` 99 | }, 100 | grouping: 'tags', 101 | sortTags: 'alpha', 102 | tags: [ 103 | { 104 | name: 'accounts', 105 | description: 'endpoints to interact with customer role.' 106 | },{ 107 | name: 'admin-groups', 108 | description: 'endpoints to interact with admin groups.' 109 | },{ 110 | name: 'admins', 111 | description: 'endpoints to interact with admin roles.' 112 | },{ 113 | name: 'contact' 114 | },{ 115 | name: 'login', 116 | description: 'endpoints for login flow.' 117 | },{ 118 | name: 'logout' 119 | },{ 120 | name: 'main' 121 | },{ 122 | name: 'session', 123 | description: 'endpoints to interact with user sessions.' 124 | },{ 125 | name: 'signup' 126 | },{ 127 | name: 'statuses', 128 | description: 'endpoints to interact with customer role (account) statuses.' 129 | },{ 130 | name: 'users', 131 | description: 'endpoints to interact with users (outside of roles)' 132 | } 133 | ] 134 | } 135 | }, 136 | { 137 | plugin: 'hapi-mongo-models', 138 | options: { 139 | mongodb: Config.get('/hapiMongoModels/mongodb'), 140 | models: [ 141 | Path.resolve(__dirname, './server/models/account'), 142 | Path.resolve(__dirname, './server/models/admin-group'), 143 | Path.resolve(__dirname, './server/models/admin'), 144 | Path.resolve(__dirname, './server/models/auth-attempt'), 145 | Path.resolve(__dirname, './server/models/session'), 146 | Path.resolve(__dirname, './server/models/status'), 147 | Path.resolve(__dirname, './server/models/user') 148 | ], 149 | autoIndex: Config.get('/hapiMongoModels/autoIndex') 150 | } 151 | }, 152 | { 153 | plugin: './server/auth' 154 | }, 155 | { 156 | plugin: './server/api/accounts' 157 | }, 158 | { 159 | plugin: './server/api/admin-groups' 160 | }, 161 | { 162 | plugin: './server/api/admins' 163 | }, 164 | { 165 | plugin: './server/api/contact' 166 | }, 167 | { 168 | plugin: './server/api/main' 169 | }, 170 | { 171 | plugin: './server/api/login' 172 | }, 173 | { 174 | plugin: './server/api/logout' 175 | }, 176 | { 177 | plugin: './server/api/sessions' 178 | }, 179 | { 180 | plugin: './server/api/signup' 181 | }, 182 | { 183 | plugin: './server/api/statuses' 184 | }, 185 | { 186 | plugin: './server/api/users' 187 | }, 188 | { 189 | plugin: './server/web/main' 190 | } 191 | ] 192 | } 193 | }; 194 | 195 | 196 | const store = new Confidence.Store(manifest); 197 | 198 | 199 | exports.get = function (key) { 200 | 201 | return store.get(key, criteria); 202 | }; 203 | 204 | 205 | exports.meta = function (key) { 206 | 207 | return store.meta(key, criteria); 208 | }; 209 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frame", 3 | "version": "14.0.0", 4 | "description": "A user system API starter", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon -e js,md server.js", 8 | "inspect": "nodemon --inspect -e js,md server.js", 9 | "first-time-setup": "node first-time-setup.js", 10 | "test": "lab -c -L", 11 | "test-server": "lab -c -L -v $TEST_TARGET" 12 | }, 13 | "author": "Reza Akhavan (http://reza.akhavan.me/)", 14 | "license": "MIT", 15 | "engines": { 16 | "node": "9.x.x" 17 | }, 18 | "dependencies": { 19 | "bcrypt": "3.x.x", 20 | "@hapi/boom": "7.x.x", 21 | "confidence": "4.x.x", 22 | "dotenv": "8.x.x", 23 | "@hapi/eslint-config-hapi": "12.x.x", 24 | "@hapi/eslint-plugin-hapi": "4.x.x", 25 | "@hapi/glue": "6.x.x", 26 | "@hapi/good": "8.x.x", 27 | "@hapi/good-console": "8.x.x", 28 | "@hapi/good-squeeze": "5.x.x", 29 | "handlebars": "4.x.x", 30 | "@hapi/hapi": "18.x.x", 31 | "@hapi/basic": "5.x.x", 32 | "hapi-mongo-models": "8.x.x", 33 | "hapi-remote-address": "1.x.x", 34 | "hapi-swagger": "10.x.x", 35 | "@hapi/hoek": "7.x.x", 36 | "@hapi/inert": "5.x.x", 37 | "@hapi/joi": "15.x.x", 38 | "joistick": "1.x.x", 39 | "mongo-models": "3.x.x", 40 | "mongodb": "3.x.x", 41 | "nodemailer": "6.x.x", 42 | "nodemailer-markdown": "1.x.x", 43 | "slug": "1.x.x", 44 | "useragent": "2.x.x", 45 | "uuid": "3.x.x", 46 | "@hapi/vision": "5.x.x" 47 | }, 48 | "devDependencies": { 49 | "@hapi/code": "5.x.x", 50 | "@hapi/lab": "19.x.x", 51 | "nodemon": "1.x.x", 52 | "promptly": "3.x.x" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Glue = require('@hapi/glue'); 4 | const Manifest = require('./manifest'); 5 | 6 | 7 | process.on('unhandledRejection', (reason, promise) => { 8 | 9 | console.error(`Unhandled Rejection at: ${promise} reason: ${reason}`); 10 | }); 11 | 12 | 13 | const main = async function () { 14 | 15 | const options = { relativeTo: __dirname }; 16 | const server = await Glue.compose(Manifest.get('/'), options); 17 | 18 | await server.start(); 19 | 20 | console.log(`Server started on port ${Manifest.get('/server/port')}`); 21 | }; 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /server/api/admin-groups.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AdminGroup = require('../models/admin-group'); 4 | const Boom = require('@hapi/boom'); 5 | const Joi = require('@hapi/joi'); 6 | const Preware = require('../preware'); 7 | 8 | 9 | const register = function (server, serverOptions) { 10 | 11 | server.route({ 12 | method: 'GET', 13 | path: '/api/admin-groups', 14 | options: { 15 | tags: ['api','admin-groups'], 16 | description: 'Get a paginated list of all admin groups. [Root Scope]', 17 | notes: 'Get a paginated list of all admin groups.', 18 | auth: { 19 | scope: 'admin' 20 | }, 21 | validate: { 22 | query: { 23 | sort: Joi.string().default('_id'), 24 | limit: Joi.number().default(20), 25 | page: Joi.number().default(1) 26 | } 27 | }, 28 | pre: [ 29 | Preware.requireAdminGroup('root') 30 | ] 31 | }, 32 | handler: async function (request, h) { 33 | 34 | const query = {}; 35 | const limit = request.query.limit; 36 | const page = request.query.page; 37 | const options = { 38 | sort: AdminGroup.sortAdapter(request.query.sort) 39 | }; 40 | 41 | return await AdminGroup.pagedFind(query, limit, page, options); 42 | } 43 | }); 44 | 45 | 46 | server.route({ 47 | method: 'POST', 48 | path: '/api/admin-groups', 49 | options: { 50 | tags: ['api','admin-groups'], 51 | description: 'Create a new admin group. [Root Scope]', 52 | notes: 'Create a new admin group.', 53 | auth: { 54 | scope: 'admin' 55 | }, 56 | validate: { 57 | payload: { 58 | name: Joi.string().required() 59 | } 60 | }, 61 | pre: [ 62 | Preware.requireAdminGroup('root') 63 | ] 64 | }, 65 | handler: async function (request, h) { 66 | 67 | return await AdminGroup.create(request.payload.name); 68 | } 69 | }); 70 | 71 | 72 | server.route({ 73 | method: 'GET', 74 | path: '/api/admin-groups/{id}', 75 | options: { 76 | tags: ['api','admin-groups'], 77 | description: 'Get an admin group by ID. [Root Scope]', 78 | notes: 'Get an admin group by ID.', 79 | validate: { 80 | params: { 81 | id : Joi.string().required().description('the id to get an admin group') 82 | } 83 | }, 84 | auth: { 85 | scope: 'admin' 86 | }, 87 | pre: [ 88 | Preware.requireAdminGroup('root') 89 | ] 90 | }, 91 | handler: async function (request, h) { 92 | 93 | const adminGroup = await AdminGroup.findById(request.params.id); 94 | 95 | if (!adminGroup) { 96 | throw Boom.notFound('AdminGroup not found.'); 97 | } 98 | 99 | return adminGroup; 100 | } 101 | }); 102 | 103 | 104 | server.route({ 105 | method: 'PUT', 106 | path: '/api/admin-groups/{id}', 107 | options: { 108 | tags: ['api','admin-groups'], 109 | description: 'Update an admin group by ID. [Root Scope]', 110 | notes: 'Update an admin group by ID.', 111 | auth: { 112 | scope: 'admin' 113 | }, 114 | validate: { 115 | params: { 116 | id: Joi.string().invalid('root') 117 | }, 118 | payload: { 119 | name: Joi.string().required() 120 | } 121 | }, 122 | pre: [ 123 | Preware.requireAdminGroup('root') 124 | ] 125 | }, 126 | handler: async function (request, h) { 127 | 128 | const id = request.params.id; 129 | const update = { 130 | $set: { 131 | name: request.payload.name 132 | } 133 | }; 134 | const adminGroup = await AdminGroup.findByIdAndUpdate(id, update); 135 | 136 | if (!adminGroup) { 137 | throw Boom.notFound('AdminGroup not found.'); 138 | } 139 | 140 | return adminGroup; 141 | } 142 | }); 143 | 144 | 145 | server.route({ 146 | method: 'DELETE', 147 | path: '/api/admin-groups/{id}', 148 | options: { 149 | tags: ['api','admin-groups'], 150 | description: 'Delete an admin group by ID. [Root Scope]', 151 | notes: 'Delete an admin group by ID.', 152 | auth: { 153 | scope: 'admin' 154 | }, 155 | validate: { 156 | params: { 157 | id: Joi.string().invalid('root') 158 | } 159 | }, 160 | pre: [ 161 | Preware.requireAdminGroup('root') 162 | ] 163 | }, 164 | handler: async function (request, h) { 165 | 166 | const adminGroup = await AdminGroup.findByIdAndDelete(request.params.id); 167 | 168 | if (!adminGroup) { 169 | throw Boom.notFound('AdminGroup not found.'); 170 | } 171 | 172 | return { message: 'Success.' }; 173 | } 174 | }); 175 | 176 | 177 | server.route({ 178 | method: 'PUT', 179 | path: '/api/admin-groups/{id}/permissions', 180 | options: { 181 | tags: ['api','admin-groups'], 182 | description: 'Update an admin group\'s permissions. [Root Scope]', 183 | notes: 'Update an admin group\'s permissions.', 184 | auth: { 185 | scope: 'admin' 186 | }, 187 | validate: { 188 | params: { 189 | id: Joi.string().invalid('root') 190 | }, 191 | payload: { 192 | permissions: Joi.object().required() 193 | } 194 | }, 195 | pre: [ 196 | Preware.requireAdminGroup('root') 197 | ] 198 | }, 199 | handler: async function (request, h) { 200 | 201 | const id = request.params.id; 202 | const update = { 203 | $set: { 204 | permissions: request.payload.permissions 205 | } 206 | }; 207 | const adminGroup = await AdminGroup.findByIdAndUpdate(id, update); 208 | 209 | if (!adminGroup) { 210 | throw Boom.notFound('AdminGroup not found.'); 211 | } 212 | 213 | return adminGroup; 214 | } 215 | }); 216 | }; 217 | 218 | 219 | module.exports = { 220 | name: 'api-admin-groups', 221 | dependencies: [ 222 | 'auth', 223 | 'hapi-mongo-models' 224 | ], 225 | register 226 | }; 227 | -------------------------------------------------------------------------------- /server/api/admins.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Admin = require('../models/admin'); 4 | const Boom = require('@hapi/boom'); 5 | const Joi = require('@hapi/joi'); 6 | const Preware = require('../preware'); 7 | const User = require('../models/user'); 8 | 9 | 10 | const register = function (server, serverOptions) { 11 | 12 | server.route({ 13 | method: 'GET', 14 | path: '/api/admins', 15 | options: { 16 | tags: ['api','admins'], 17 | description: 'Get a paginated list of all admin accounts. [Root Scope]', 18 | notes: 'Get a paginated list of all admin accounts.', 19 | auth: { 20 | scope: 'admin' 21 | }, 22 | validate: { 23 | query: { 24 | sort: Joi.string().default('_id'), 25 | limit: Joi.number().default(20), 26 | page: Joi.number().default(1) 27 | } 28 | }, 29 | pre: [ 30 | Preware.requireAdminGroup('root') 31 | ] 32 | }, 33 | handler: async function (request, h) { 34 | 35 | const query = {}; 36 | const limit = request.query.limit; 37 | const page = request.query.page; 38 | const options = { 39 | sort: Admin.sortAdapter(request.query.sort) 40 | }; 41 | 42 | return await Admin.pagedFind(query, page, limit, options); 43 | } 44 | }); 45 | 46 | 47 | server.route({ 48 | method: 'POST', 49 | path: '/api/admins', 50 | options: { 51 | tags: ['api','admins'], 52 | description: 'Create a new admin account. [Root Scope]', 53 | notes: 'Create a new admin account.', 54 | auth: { 55 | scope: 'admin' 56 | }, 57 | validate: { 58 | payload: { 59 | name: Joi.string().required() 60 | } 61 | }, 62 | pre: [ 63 | Preware.requireAdminGroup('root') 64 | ] 65 | }, 66 | handler: async function (request, h) { 67 | 68 | return await Admin.create(request.payload.name); 69 | } 70 | }); 71 | 72 | 73 | server.route({ 74 | method: 'GET', 75 | path: '/api/admins/{id}', 76 | options: { 77 | tags: ['api','admins'], 78 | description: 'Get an admin account by ID. [Root Scope]', 79 | notes: 'Get an admin account by ID.', 80 | validate: { 81 | params: { 82 | id : Joi.string().required().description('the id to get an admin') 83 | } 84 | }, 85 | auth: { 86 | scope: 'admin' 87 | }, 88 | pre: [ 89 | Preware.requireAdminGroup('root') 90 | ] 91 | }, 92 | handler: async function (request, h) { 93 | 94 | const admin = await Admin.findById(request.params.id); 95 | 96 | if (!admin) { 97 | throw Boom.notFound('Admin not found.'); 98 | } 99 | 100 | return admin; 101 | } 102 | }); 103 | 104 | 105 | server.route({ 106 | method: 'PUT', 107 | path: '/api/admins/{id}', 108 | options: { 109 | tags: ['api','admins'], 110 | description: 'Update an admin account by ID. [Root Scope]', 111 | notes: 'Update an admin account by ID.', 112 | auth: { 113 | scope: 'admin' 114 | }, 115 | validate: { 116 | params: { 117 | id: Joi.string().invalid('111111111111111111111111') 118 | }, 119 | payload: { 120 | name: Joi.object({ 121 | first: Joi.string().required(), 122 | middle: Joi.string().allow(''), 123 | last: Joi.string().required() 124 | }).required() 125 | } 126 | }, 127 | pre: [ 128 | Preware.requireAdminGroup('root') 129 | ] 130 | }, 131 | handler: async function (request, h) { 132 | 133 | const id = request.params.id; 134 | const update = { 135 | $set: { 136 | name: request.payload.name 137 | } 138 | }; 139 | const admin = await Admin.findByIdAndUpdate(id, update); 140 | 141 | if (!admin) { 142 | throw Boom.notFound('Admin not found.'); 143 | } 144 | 145 | return admin; 146 | } 147 | }); 148 | 149 | 150 | server.route({ 151 | method: 'DELETE', 152 | path: '/api/admins/{id}', 153 | options: { 154 | tags: ['api','admins'], 155 | description: 'Delete an admin account by ID. [Root Scope]', 156 | notes: 'Delete an admin account by ID.', 157 | validate: { 158 | params: { 159 | id : Joi.string().required().description('the id to delete an admin') 160 | } 161 | }, 162 | auth: { 163 | scope: 'admin' 164 | }, 165 | pre: [ 166 | Preware.requireAdminGroup('root') 167 | ] 168 | }, 169 | handler: async function (request, h) { 170 | 171 | const admin = await Admin.findByIdAndDelete(request.params.id); 172 | 173 | if (!admin) { 174 | throw Boom.notFound('Admin not found.'); 175 | } 176 | 177 | return { message: 'Success.' }; 178 | } 179 | }); 180 | 181 | 182 | server.route({ 183 | method: 'PUT', 184 | path: '/api/admins/{id}/groups', 185 | options: { 186 | tags: ['api','admins'], 187 | description: 'Update an admin account\'s groups by ID. [Root Scope]', 188 | notes: 'Update an admin account\'s groups by ID.', 189 | auth: { 190 | scope: 'admin' 191 | }, 192 | validate: { 193 | params: { 194 | id: Joi.string().invalid('111111111111111111111111') 195 | }, 196 | payload: { 197 | groups: Joi.object().required() 198 | } 199 | }, 200 | pre: [ 201 | Preware.requireAdminGroup('root') 202 | ] 203 | }, 204 | handler: async function (request, h) { 205 | 206 | const id = request.params.id; 207 | const update = { 208 | $set: { 209 | groups: request.payload.groups 210 | } 211 | }; 212 | const admin = await Admin.findByIdAndUpdate(id, update); 213 | 214 | if (!admin) { 215 | throw Boom.notFound('Admin not found.'); 216 | } 217 | 218 | return admin; 219 | } 220 | }); 221 | 222 | 223 | server.route({ 224 | method: 'PUT', 225 | path: '/api/admins/{id}/permissions', 226 | options: { 227 | tags: ['api','admins'], 228 | description: 'Update an admin account\'s custom permissions by ID. [Root Scope]', 229 | notes: 'Update an admin account\'s custom permissions by ID.', 230 | auth: { 231 | scope: 'admin' 232 | }, 233 | validate: { 234 | params: { 235 | id: Joi.string().invalid('111111111111111111111111') 236 | }, 237 | payload: { 238 | permissions: Joi.object().required() 239 | } 240 | }, 241 | pre: [ 242 | Preware.requireAdminGroup('root') 243 | ] 244 | }, 245 | handler: async function (request, h) { 246 | 247 | const id = request.params.id; 248 | const update = { 249 | $set: { 250 | permissions: request.payload.permissions 251 | } 252 | }; 253 | const admin = await Admin.findByIdAndUpdate(id, update); 254 | 255 | if (!admin) { 256 | throw Boom.notFound('Admin not found.'); 257 | } 258 | 259 | return admin; 260 | } 261 | }); 262 | 263 | 264 | server.route({ 265 | method: 'PUT', 266 | path: '/api/admins/{id}/user', 267 | options: { 268 | tags: ['api','admins'], 269 | description: 'Link an admin account to a user account. [Root Scope]', 270 | notes: 'Link an admin account to a user account.', 271 | auth: { 272 | scope: 'admin' 273 | }, 274 | validate: { 275 | params: { 276 | id: Joi.string().invalid('111111111111111111111111') 277 | }, 278 | payload: { 279 | username: Joi.string().lowercase().required() 280 | } 281 | }, 282 | pre: [ 283 | Preware.requireAdminGroup('root'), 284 | { 285 | assign: 'admin', 286 | method: async function (request, h) { 287 | 288 | const admin = await Admin.findById(request.params.id); 289 | 290 | if (!admin) { 291 | throw Boom.notFound('Admin not found.'); 292 | } 293 | 294 | return admin; 295 | } 296 | }, { 297 | assign: 'user', 298 | method: async function (request, h) { 299 | 300 | const user = await User.findByUsername(request.payload.username); 301 | 302 | if (!user) { 303 | throw Boom.notFound('User not found.'); 304 | } 305 | 306 | if (user.roles.admin && 307 | user.roles.admin.id !== request.params.id) { 308 | 309 | throw Boom.conflict('User is linked to an admin. Unlink first.'); 310 | } 311 | 312 | if (request.pre.admin.user && 313 | request.pre.admin.user.id !== `${user._id}`) { 314 | 315 | throw Boom.conflict('Admin is linked to a user. Unlink first.'); 316 | } 317 | 318 | return user; 319 | } 320 | } 321 | ] 322 | }, 323 | handler: async function (request, h) { 324 | 325 | const user = request.pre.user; 326 | let admin = request.pre.admin; 327 | 328 | [admin] = await Promise.all([ 329 | admin.linkUser(`${user._id}`, user.username), 330 | user.linkAdmin(`${admin._id}`, admin.fullName()) 331 | ]); 332 | 333 | return admin; 334 | } 335 | }); 336 | 337 | 338 | server.route({ 339 | method: 'DELETE', 340 | path: '/api/admins/{id}/user', 341 | options: { 342 | tags: ['api','admins'], 343 | description: 'Unlink an admin account from a user account. [Root Scope]', 344 | notes: 'Unlink an admin account from a user account.', 345 | auth: { 346 | scope: 'admin' 347 | }, 348 | validate: { 349 | params: { 350 | id: Joi.string().invalid('111111111111111111111111') 351 | } 352 | }, 353 | pre: [ 354 | Preware.requireAdminGroup('root'), 355 | { 356 | assign: 'admin', 357 | method: async function (request, h) { 358 | 359 | let admin = await Admin.findById(request.params.id); 360 | 361 | if (!admin) { 362 | throw Boom.notFound('Admin not found.'); 363 | } 364 | 365 | if (!admin.user || !admin.user.id) { 366 | admin = await admin.unlinkUser(); 367 | 368 | return h.response(admin).takeover(); 369 | } 370 | 371 | return admin; 372 | } 373 | }, { 374 | assign: 'user', 375 | method: async function (request, h) { 376 | 377 | const user = await User.findById(request.pre.admin.user.id); 378 | 379 | if (!user) { 380 | throw Boom.notFound('User not found.'); 381 | } 382 | 383 | return user; 384 | } 385 | } 386 | ] 387 | }, 388 | handler: async function (request, h) { 389 | 390 | const [admin] = await Promise.all([ 391 | request.pre.admin.unlinkUser(), 392 | request.pre.user.unlinkAdmin() 393 | ]); 394 | 395 | return admin; 396 | } 397 | }); 398 | }; 399 | 400 | 401 | module.exports = { 402 | name: 'api-admins', 403 | dependencies: [ 404 | 'auth', 405 | 'hapi-mongo-models' 406 | ], 407 | register 408 | }; 409 | -------------------------------------------------------------------------------- /server/api/contact.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Config = require('../../config'); 4 | const Joi = require('@hapi/joi'); 5 | const Mailer = require('../mailer'); 6 | 7 | 8 | const register = function (server, serverOptions) { 9 | 10 | server.route({ 11 | method: 'POST', 12 | path: '/api/contact', 13 | options: { 14 | tags: ['api','contact'], 15 | description: 'Generate a contact email. [No Scope]', 16 | notes: 'Generate a contact email.', 17 | auth: false, 18 | validate: { 19 | payload: { 20 | name: Joi.string().required(), 21 | email: Joi.string().email().required(), 22 | message: Joi.string().required() 23 | } 24 | } 25 | }, 26 | handler: async function (request, h) { 27 | 28 | const emailOptions = { 29 | subject: Config.get('/projectName') + ' contact form', 30 | to: Config.get('/system/toAddress'), 31 | replyTo: { 32 | name: request.payload.name, 33 | address: request.payload.email 34 | } 35 | }; 36 | const template = 'contact'; 37 | 38 | await Mailer.sendEmail(emailOptions, template, request.payload); 39 | 40 | return { message: 'Success.' }; 41 | } 42 | }); 43 | }; 44 | 45 | 46 | module.exports = { 47 | name: 'api-contact', 48 | dependencies: [], 49 | register 50 | }; 51 | -------------------------------------------------------------------------------- /server/api/login.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AuthAttempt = require('../models/auth-attempt'); 4 | const Bcrypt = require('bcrypt'); 5 | const Boom = require('@hapi/boom'); 6 | const Config = require('../../config'); 7 | const Joi = require('@hapi/joi'); 8 | const Mailer = require('../mailer'); 9 | const Session = require('../models/session'); 10 | const User = require('../models/user'); 11 | 12 | 13 | const register = function (server, serverOptions) { 14 | 15 | server.route({ 16 | method: 'POST', 17 | path: '/api/login', 18 | options: { 19 | tags: ['api','login'], 20 | description: 'Log in with username and password. [No Scope]', 21 | notes: 'Log in with username and password.', 22 | auth: false, 23 | validate: { 24 | payload: { 25 | username: Joi.string().lowercase().required(), 26 | password: Joi.string().required() 27 | } 28 | }, 29 | pre: [{ 30 | assign: 'abuseDetected', 31 | method: async function (request, h) { 32 | 33 | const ip = request.remoteAddress; 34 | const username = request.payload.username; 35 | const detected = await AuthAttempt.abuseDetected(ip, username); 36 | 37 | if (detected) { 38 | throw Boom.badRequest('Maximum number of auth attempts reached.'); 39 | } 40 | 41 | return h.continue; 42 | } 43 | }, { 44 | assign: 'user', 45 | method: async function (request, h) { 46 | 47 | const ip = request.remoteAddress; 48 | const username = request.payload.username; 49 | const password = request.payload.password; 50 | const user = await User.findByCredentials(username, password); 51 | 52 | if (!user) { 53 | await AuthAttempt.create(ip, username); 54 | 55 | throw Boom.badRequest('Credentials are invalid or account is inactive.'); 56 | } 57 | 58 | return user; 59 | } 60 | }, { 61 | assign: 'session', 62 | method: async function (request, h) { 63 | 64 | const userId = `${request.pre.user._id}`; 65 | const ip = request.remoteAddress; 66 | const userAgent = request.headers['user-agent']; 67 | 68 | return await Session.create(userId, ip, userAgent); 69 | } 70 | }] 71 | }, 72 | handler: function (request, h) { 73 | 74 | const sessionId = request.pre.session._id; 75 | const sessionKey = request.pre.session.key; 76 | const credentials = `${sessionId}:${sessionKey}`; 77 | const authHeader = `Basic ${Buffer.from(credentials).toString('base64')}`; 78 | 79 | return { 80 | user: { 81 | _id: request.pre.user._id, 82 | username: request.pre.user.username, 83 | email: request.pre.user.email, 84 | roles: request.pre.user.roles 85 | }, 86 | session: request.pre.session, 87 | authHeader 88 | }; 89 | } 90 | }); 91 | 92 | 93 | server.route({ 94 | method: 'POST', 95 | path: '/api/login/forgot', 96 | options: { 97 | tags: ['api','login'], 98 | description: 'Trigger forgot password email. [No Scope]', 99 | notes: 'Trigger forgot password email.', 100 | auth: false, 101 | validate: { 102 | payload: { 103 | email: Joi.string().email().lowercase().required() 104 | } 105 | }, 106 | pre: [{ 107 | assign: 'user', 108 | method: async function (request, h) { 109 | 110 | const query = { email: request.payload.email }; 111 | const user = await User.findOne(query); 112 | 113 | if (!user) { 114 | const response = h.response({ message: 'Success.' }); 115 | 116 | return response.takeover(); 117 | } 118 | 119 | return user; 120 | } 121 | }] 122 | }, 123 | handler: async function (request, h) { 124 | 125 | // set reset token 126 | 127 | const keyHash = await Session.generateKeyHash(); 128 | const update = { 129 | $set: { 130 | resetPassword: { 131 | token: keyHash.hash, 132 | expires: Date.now() + 10000000 133 | } 134 | } 135 | }; 136 | 137 | await User.findByIdAndUpdate(request.pre.user._id, update); 138 | 139 | // send email 140 | 141 | const projectName = Config.get('/projectName'); 142 | const emailOptions = { 143 | subject: `Reset your ${projectName} password`, 144 | to: request.payload.email 145 | }; 146 | const template = 'forgot-password'; 147 | const context = { key: keyHash.key }; 148 | 149 | await Mailer.sendEmail(emailOptions, template, context); 150 | 151 | return { message: 'Success.' }; 152 | } 153 | }); 154 | 155 | 156 | server.route({ 157 | method: 'POST', 158 | path: '/api/login/reset', 159 | options: { 160 | tags: ['api','login'], 161 | description: 'Reset password with forgot password key. [No Scope]', 162 | notes: 'Reset password with forgot password key.', 163 | auth: false, 164 | validate: { 165 | payload: { 166 | email: Joi.string().email().lowercase().required(), 167 | key: Joi.string().required(), 168 | password: Joi.string().required() 169 | } 170 | }, 171 | pre: [{ 172 | assign: 'user', 173 | method: async function (request, h) { 174 | 175 | const query = { 176 | email: request.payload.email, 177 | 'resetPassword.expires': { $gt: Date.now() } 178 | }; 179 | const user = await User.findOne(query); 180 | 181 | if (!user) { 182 | throw Boom.badRequest('Invalid email or key.'); 183 | } 184 | 185 | return user; 186 | } 187 | }] 188 | }, 189 | handler: async function (request, h) { 190 | 191 | // validate reset token 192 | 193 | const key = request.payload.key; 194 | const token = request.pre.user.resetPassword.token; 195 | const keyMatch = await Bcrypt.compare(key, token); 196 | 197 | if (!keyMatch) { 198 | throw Boom.badRequest('Invalid email or key.'); 199 | } 200 | 201 | // update user 202 | 203 | const password = request.payload.password; 204 | const passwordHash = await User.generatePasswordHash(password); 205 | const update = { 206 | $set: { 207 | password: passwordHash.hash 208 | }, 209 | $unset: { 210 | resetPassword: undefined 211 | } 212 | }; 213 | 214 | await User.findByIdAndUpdate(request.pre.user._id, update); 215 | 216 | return { message: 'Success.' }; 217 | } 218 | }); 219 | }; 220 | 221 | 222 | module.exports = { 223 | name: 'api-login', 224 | dependencies: [ 225 | 'hapi-mongo-models', 226 | 'hapi-remote-address' 227 | ], 228 | register 229 | }; 230 | -------------------------------------------------------------------------------- /server/api/logout.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Session = require('../models/session'); 4 | 5 | 6 | const register = function (server, serverOptions) { 7 | 8 | server.route({ 9 | method: 'DELETE', 10 | path: '/api/logout', 11 | options: { 12 | tags: ['api','logout'], 13 | description: 'Delete the user\'s logged-in session. [User Account Scope]', 14 | notes: 'Delete the user\'s logged-in session.', 15 | auth: { 16 | mode: 'try' 17 | } 18 | }, 19 | handler: function (request, h) { 20 | 21 | const credentials = request.auth.credentials; 22 | 23 | if (!credentials) { 24 | return { message: 'Success.' }; 25 | } 26 | 27 | Session.findByIdAndDelete(credentials.session._id); 28 | 29 | return { message: 'Success.' }; 30 | } 31 | }); 32 | }; 33 | 34 | 35 | module.exports = { 36 | name: 'api-logout', 37 | dependencies: [ 38 | 'auth', 39 | 'hapi-mongo-models' 40 | ], 41 | register 42 | }; 43 | -------------------------------------------------------------------------------- /server/api/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const register = function (server, serverOptions) { 5 | 6 | server.route({ 7 | method: 'GET', 8 | path: '/api', 9 | options: { 10 | tags: ['api','main'], 11 | description: 'Test if API is accessible. [No Scope]', 12 | notes: 'Test if API is accessible.', 13 | auth: false 14 | }, 15 | handler: function (request, h) { 16 | 17 | return { 18 | message: 'Welcome to the API.' 19 | }; 20 | } 21 | }); 22 | }; 23 | 24 | 25 | module.exports = { 26 | name: 'api-main', 27 | dependencies: [], 28 | register 29 | }; 30 | -------------------------------------------------------------------------------- /server/api/sessions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('@hapi/boom'); 4 | const Joi = require('@hapi/joi'); 5 | const Preware = require('../preware'); 6 | const Session = require('../models/session'); 7 | 8 | 9 | const register = function (server, serverOptions) { 10 | 11 | server.route({ 12 | method: 'GET', 13 | path: '/api/sessions', 14 | options: { 15 | tags: ['api','session'], 16 | description: 'Get a paginated list of all user sessions. [Root Scope]', 17 | notes: 'Get a paginated list of all user sessions.', 18 | auth: { 19 | scope: 'admin' 20 | }, 21 | validate: { 22 | query: { 23 | sort: Joi.string().default('_id'), 24 | limit: Joi.number().default(20), 25 | page: Joi.number().default(1) 26 | } 27 | }, 28 | pre: [ 29 | Preware.requireAdminGroup('root') 30 | ] 31 | }, 32 | handler: async function (request, h) { 33 | 34 | const query = {}; 35 | const limit = request.query.limit; 36 | const page = request.query.page; 37 | const options = { 38 | sort: Session.sortAdapter(request.query.sort) 39 | }; 40 | 41 | return await Session.pagedFind(query, limit, page, options); 42 | } 43 | }); 44 | 45 | 46 | server.route({ 47 | method: 'GET', 48 | path: '/api/sessions/{id}', 49 | options: { 50 | tags: ['api','session'], 51 | description: 'Get a user session by ID. [Root Scope]', 52 | notes: 'Get a user session by ID.', 53 | validate: { 54 | params: { 55 | id : Joi.string().required().description('the id to get session') 56 | } 57 | }, 58 | auth: { 59 | scope: 'admin' 60 | }, 61 | pre: [ 62 | Preware.requireAdminGroup('root') 63 | ] 64 | }, 65 | handler: async function (request, h) { 66 | 67 | const session = await Session.findById(request.params.id); 68 | 69 | if (!session) { 70 | throw Boom.notFound('Session not found.'); 71 | } 72 | 73 | return session; 74 | } 75 | }); 76 | 77 | 78 | server.route({ 79 | method: 'DELETE', 80 | path: '/api/sessions/{id}', 81 | options: { 82 | tags: ['api','session'], 83 | description: 'Delete a user session by ID. [Root Scope]', 84 | notes: 'Delete a user session by ID.', 85 | validate: { 86 | params: { 87 | id : Joi.string().required().description('the id to delete a session') 88 | } 89 | }, 90 | auth: { 91 | scope: 'admin' 92 | }, 93 | pre: [ 94 | Preware.requireAdminGroup('root') 95 | ] 96 | }, 97 | handler: async function (request, h) { 98 | 99 | const session = await Session.findByIdAndDelete(request.params.id); 100 | 101 | if (!session) { 102 | throw Boom.notFound('Session not found.'); 103 | } 104 | 105 | return { message: 'Success.' }; 106 | } 107 | }); 108 | 109 | 110 | server.route({ 111 | method: 'GET', 112 | path: '/api/sessions/my', 113 | options: { 114 | tags: ['api','session'], 115 | description: 'Get the logged-in user\'s session. [User Account Scope]', 116 | notes: 'Get the logged-in user\'s session.', 117 | auth: { 118 | scope: ['admin', 'account'] 119 | } 120 | }, 121 | handler: async function (request, h) { 122 | 123 | const query = { 124 | userId: `${request.auth.credentials.user._id}` 125 | }; 126 | 127 | return await Session.find(query); 128 | } 129 | }); 130 | 131 | 132 | server.route({ 133 | method: 'DELETE', 134 | path: '/api/sessions/my/{id}', 135 | handler: async function (request, h) { 136 | 137 | const currentSession = `${request.auth.credentials.session._id}`; 138 | 139 | if (currentSession === request.params.id) { 140 | throw Boom.badRequest( 141 | 'Cannot destroy your current session. Also see `/api/logout`.' 142 | ); 143 | } 144 | 145 | const query = { 146 | _id: Session.ObjectID(request.params.id), 147 | userId: `${request.auth.credentials.user._id}` 148 | }; 149 | 150 | await Session.findOneAndDelete(query); 151 | 152 | return { message: 'Success.' }; 153 | } 154 | }); 155 | }; 156 | 157 | 158 | module.exports = { 159 | name: 'api-sessions', 160 | dependencies: [ 161 | 'auth', 162 | 'hapi-mongo-models' 163 | ], 164 | register 165 | }; 166 | -------------------------------------------------------------------------------- /server/api/signup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Account = require('../models/account'); 4 | const Boom = require('@hapi/boom'); 5 | const Config = require('../../config'); 6 | const Joi = require('@hapi/joi'); 7 | const Mailer = require('../mailer'); 8 | const Session = require('../models/session'); 9 | const User = require('../models/user'); 10 | 11 | 12 | const register = function (server, serverOptions) { 13 | 14 | server.route({ 15 | method: 'POST', 16 | path: '/api/signup', 17 | options: { 18 | tags: ['api','signup'], 19 | description: 'Sign up for a new user account. [No Scope]', 20 | notes: 'Sign up for a new user account. Creates a new User, new Account, and links the two.', 21 | auth: false, 22 | validate: { 23 | payload: { 24 | name: Joi.string().required(), 25 | email: Joi.string().email().lowercase().required(), 26 | username: Joi.string().token().lowercase().required(), 27 | password: Joi.string().required() 28 | } 29 | }, 30 | pre: [{ 31 | assign: 'usernameCheck', 32 | method: async function (request, h) { 33 | 34 | const user = await User.findByUsername(request.payload.username); 35 | 36 | if (user) { 37 | throw Boom.conflict('Username already in use.'); 38 | } 39 | 40 | return h.continue; 41 | } 42 | }, { 43 | assign: 'emailCheck', 44 | method: async function (request, h) { 45 | 46 | const user = await User.findByEmail(request.payload.email); 47 | 48 | if (user) { 49 | throw Boom.conflict('Email already in use.'); 50 | } 51 | 52 | return h.continue; 53 | } 54 | }] 55 | }, 56 | handler: async function (request, h) { 57 | 58 | // create and link account and user documents 59 | 60 | let [account, user] = await Promise.all([ 61 | Account.create(request.payload.name), 62 | User.create( 63 | request.payload.username, 64 | request.payload.password, 65 | request.payload.email 66 | ) 67 | ]); 68 | 69 | [account, user] = await Promise.all([ 70 | account.linkUser(`${user._id}`, user.username), 71 | user.linkAccount(`${account._id}`, account.fullName()) 72 | ]); 73 | 74 | // send welcome email 75 | 76 | const emailOptions = { 77 | subject: `Your ${Config.get('/projectName')} account`, 78 | to: { 79 | name: request.payload.name, 80 | address: request.payload.email 81 | } 82 | }; 83 | 84 | try { 85 | await Mailer.sendEmail(emailOptions, 'welcome', request.payload); 86 | } 87 | catch (err) { 88 | request.log(['mailer', 'error'], err); 89 | } 90 | 91 | // create session 92 | 93 | const userAgent = request.headers['user-agent']; 94 | const ip = request.remoteAddress; 95 | const session = await Session.create(`${user._id}`, ip, userAgent); 96 | 97 | // create auth header 98 | 99 | const credentials = `${session._id}:${session.key}`; 100 | const authHeader = `Basic ${Buffer.from(credentials).toString('base64')}`; 101 | 102 | return { 103 | user: { 104 | _id: user._id, 105 | username: user.username, 106 | email: user.email, 107 | roles: user.roles 108 | }, 109 | session, 110 | authHeader 111 | }; 112 | } 113 | }); 114 | }; 115 | 116 | 117 | module.exports = { 118 | name: 'api-signup', 119 | dependencies: [ 120 | 'hapi-mongo-models', 121 | 'hapi-remote-address' 122 | ], 123 | register 124 | }; 125 | -------------------------------------------------------------------------------- /server/api/statuses.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('@hapi/boom'); 4 | const Joi = require('@hapi/joi'); 5 | const Preware = require('../preware'); 6 | const Status = require('../models/status'); 7 | 8 | 9 | const register = function (server, serverOptions) { 10 | 11 | server.route({ 12 | method: 'GET', 13 | path: '/api/statuses', 14 | options: { 15 | tags: ['api','statuses'], 16 | description: 'Get a paginated list of all statuses. [Root Scope]', 17 | notes: 'Get a paginated list of all statuses.', 18 | auth: { 19 | scope: 'admin' 20 | }, 21 | validate: { 22 | query: { 23 | sort: Joi.string().default('_id'), 24 | limit: Joi.number().default(20), 25 | page: Joi.number().default(1) 26 | } 27 | }, 28 | pre: [ 29 | Preware.requireAdminGroup('root') 30 | ] 31 | }, 32 | handler: async function (request, h) { 33 | 34 | const query = {}; 35 | const limit = request.query.limit; 36 | const page = request.query.page; 37 | const options = { 38 | sort: Status.sortAdapter(request.query.sort) 39 | }; 40 | 41 | return await Status.pagedFind(query, limit, page, options); 42 | } 43 | }); 44 | 45 | 46 | server.route({ 47 | method: 'POST', 48 | path: '/api/statuses', 49 | options: { 50 | tags: ['api','statuses'], 51 | description: 'Add a new status. [Root Scope]', 52 | notes: 'Add a new status.', 53 | auth: { 54 | scope: 'admin' 55 | }, 56 | validate: { 57 | payload: { 58 | name: Joi.string().required(), 59 | pivot: Joi.string().required() 60 | } 61 | }, 62 | pre: [ 63 | Preware.requireAdminGroup('root') 64 | ] 65 | }, 66 | handler: async function (request, h) { 67 | 68 | return await Status.create(request.payload.pivot, request.payload.name); 69 | } 70 | }); 71 | 72 | 73 | server.route({ 74 | method: 'GET', 75 | path: '/api/statuses/{id}', 76 | options: { 77 | tags: ['api','statuses'], 78 | description: 'Get a status by ID. [Root Scope]', 79 | notes: 'Get a status by ID.', 80 | validate: { 81 | params: { 82 | id : Joi.string().required().description('the id to get status') 83 | } 84 | }, 85 | auth: { 86 | scope: 'admin' 87 | }, 88 | pre: [ 89 | Preware.requireAdminGroup('root') 90 | ] 91 | }, 92 | handler: async function (request, h) { 93 | 94 | const status = await Status.findById(request.params.id); 95 | 96 | if (!status) { 97 | throw Boom.notFound('Status not found.'); 98 | } 99 | 100 | return status; 101 | } 102 | }); 103 | 104 | 105 | server.route({ 106 | method: 'PUT', 107 | path: '/api/statuses/{id}', 108 | options: { 109 | tags: ['api','statuses'], 110 | description: 'Update a status by ID. [Root Scope]', 111 | notes: 'Update a status by ID.', 112 | auth: { 113 | scope: 'admin' 114 | }, 115 | validate: { 116 | payload: { 117 | name: Joi.string().required() 118 | }, 119 | params: { 120 | id : Joi.string().required().description('the id to update a status') 121 | } 122 | }, 123 | pre: [ 124 | Preware.requireAdminGroup('root') 125 | ] 126 | }, 127 | handler: async function (request, h) { 128 | 129 | const id = request.params.id; 130 | const update = { 131 | $set: { 132 | name: request.payload.name 133 | } 134 | }; 135 | const status = await Status.findByIdAndUpdate(id, update); 136 | 137 | if (!status) { 138 | throw Boom.notFound('Status not found.'); 139 | } 140 | 141 | return status; 142 | } 143 | }); 144 | 145 | 146 | server.route({ 147 | method: 'DELETE', 148 | path: '/api/statuses/{id}', 149 | options: { 150 | tags: ['api','statuses'], 151 | description: 'Delete a status by ID. [Root Scope]', 152 | notes: 'Delete a status by ID.', 153 | validate: { 154 | params: { 155 | id : Joi.string().required().description('the id to delete a status') 156 | } 157 | }, 158 | auth: { 159 | scope: 'admin' 160 | }, 161 | pre: [ 162 | Preware.requireAdminGroup('root') 163 | ] 164 | }, 165 | handler: async function (request, h) { 166 | 167 | const status = await Status.findByIdAndDelete(request.params.id); 168 | 169 | if (!status) { 170 | throw Boom.notFound('Status not found.'); 171 | } 172 | 173 | return { message: 'Success.' }; 174 | } 175 | }); 176 | }; 177 | 178 | 179 | module.exports = { 180 | name: 'api-statuses', 181 | dependencies: [ 182 | 'auth', 183 | 'hapi-mongo-models' 184 | ], 185 | register 186 | }; 187 | -------------------------------------------------------------------------------- /server/api/users.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Admin = require('../models/admin'); 4 | const Account = require('../models/account'); 5 | const Boom = require('@hapi/boom'); 6 | const Joi = require('@hapi/joi'); 7 | const Preware = require('../preware'); 8 | const User = require('../models/user'); 9 | 10 | 11 | const register = function (server, serverOptions) { 12 | 13 | server.route({ 14 | method: 'GET', 15 | path: '/api/users', 16 | options: { 17 | tags: ['api','users'], 18 | description: 'Get a paginated list of all users. [Root Scope]', 19 | notes: 'Get a paginated list of all users.', 20 | auth: { 21 | scope: 'admin' 22 | }, 23 | validate: { 24 | query: { 25 | sort: Joi.string().default('_id'), 26 | limit: Joi.number().default(20), 27 | page: Joi.number().default(1) 28 | } 29 | }, 30 | pre: [ 31 | Preware.requireAdminGroup('root') 32 | ] 33 | }, 34 | handler: async function (request, h) { 35 | 36 | const query = {}; 37 | const limit = request.query.limit; 38 | const page = request.query.page; 39 | const options = { 40 | sort: User.sortAdapter(request.query.sort) 41 | }; 42 | 43 | return await User.pagedFind(query, page, limit, options); 44 | } 45 | }); 46 | 47 | 48 | server.route({ 49 | method: 'POST', 50 | path: '/api/users', 51 | options: { 52 | tags: ['api','users'], 53 | description: 'Create a new user. [Root Scope]', 54 | notes: 'Create a new user. This does not map this user to an account.', 55 | auth: { 56 | scope: 'admin' 57 | }, 58 | validate: { 59 | payload: { 60 | username: Joi.string().token().lowercase().required(), 61 | password: Joi.string().required(), 62 | email: Joi.string().email().lowercase().required() 63 | } 64 | }, 65 | pre: [ 66 | Preware.requireAdminGroup('root'), 67 | { 68 | assign: 'usernameCheck', 69 | method: async function (request, h) { 70 | 71 | const user = await User.findByUsername(request.payload.username); 72 | 73 | if (user) { 74 | throw Boom.conflict('Username already in use.'); 75 | } 76 | 77 | return h.continue; 78 | } 79 | }, { 80 | assign: 'emailCheck', 81 | method: async function (request, h) { 82 | 83 | const user = await User.findByEmail(request.payload.email); 84 | 85 | if (user) { 86 | throw Boom.conflict('Email already in use.'); 87 | } 88 | 89 | return h.continue; 90 | } 91 | } 92 | ] 93 | }, 94 | handler: async function (request, h) { 95 | 96 | const username = request.payload.username; 97 | const password = request.payload.password; 98 | const email = request.payload.email; 99 | 100 | return await User.create(username, password, email); 101 | } 102 | }); 103 | 104 | 105 | server.route({ 106 | method: 'GET', 107 | path: '/api/users/{id}', 108 | options: { 109 | tags: ['api','users'], 110 | description: 'Get a user by ID. [Root Scope]', 111 | notes: 'Get a user by ID.', 112 | validate: { 113 | params: { 114 | id : Joi.string().required().description('the id to get the user') 115 | } 116 | }, 117 | auth: { 118 | scope: 'admin' 119 | }, 120 | pre: [ 121 | Preware.requireAdminGroup('root') 122 | ] 123 | }, 124 | handler: async function (request, h) { 125 | 126 | const user = await User.findById(request.params.id); 127 | 128 | if (!user) { 129 | throw Boom.notFound('User not found.'); 130 | } 131 | 132 | return user; 133 | } 134 | }); 135 | 136 | 137 | server.route({ 138 | method: 'PUT', 139 | path: '/api/users/{id}', 140 | options: { 141 | tags: ['api','users'], 142 | description: 'Update a user by ID. [Root Scope]', 143 | notes: 'Update a user by ID.', 144 | auth: { 145 | scope: 'admin' 146 | }, 147 | validate: { 148 | params: { 149 | id: Joi.string().invalid('000000000000000000000000') 150 | }, 151 | payload: { 152 | isActive: Joi.boolean().required(), 153 | username: Joi.string().token().lowercase().required(), 154 | email: Joi.string().email().lowercase().required() 155 | } 156 | }, 157 | pre: [ 158 | Preware.requireAdminGroup('root'), 159 | { 160 | assign: 'usernameCheck', 161 | method: async function (request, h) { 162 | 163 | const conditions = { 164 | username: request.payload.username, 165 | _id: { $ne: User._idClass(request.params.id) } 166 | }; 167 | const user = await User.findOne(conditions); 168 | 169 | if (user) { 170 | throw Boom.conflict('Username already in use.'); 171 | } 172 | 173 | return h.continue; 174 | } 175 | }, { 176 | assign: 'emailCheck', 177 | method: async function (request, h) { 178 | 179 | const conditions = { 180 | email: request.payload.email, 181 | _id: { $ne: User._idClass(request.params.id) } 182 | }; 183 | const user = await User.findOne(conditions); 184 | 185 | if (user) { 186 | throw Boom.conflict('Email already in use.'); 187 | } 188 | 189 | return h.continue; 190 | } 191 | } 192 | ] 193 | }, 194 | handler: async function (request, h) { 195 | 196 | const updateUser = { 197 | $set: { 198 | isActive: request.payload.isActive, 199 | username: request.payload.username, 200 | email: request.payload.email 201 | } 202 | }; 203 | const queryByUserId = { 204 | 'user.id': request.params.id 205 | }; 206 | const updateRole = { 207 | $set: { 208 | 'user.name': request.payload.username 209 | } 210 | }; 211 | const user = await User.findByIdAndUpdate(request.params.id, updateUser); 212 | 213 | if (!user) { 214 | throw Boom.notFound('User not found.'); 215 | } 216 | 217 | await Promise.all([ 218 | Account.findOneAndUpdate(queryByUserId, updateRole), 219 | Admin.findOneAndUpdate(queryByUserId, updateRole) 220 | ]); 221 | 222 | return user; 223 | } 224 | }); 225 | 226 | 227 | server.route({ 228 | method: 'DELETE', 229 | path: '/api/users/{id}', 230 | options: { 231 | tags: ['api','users'], 232 | description: 'Delete a user by ID. [Root Scope]', 233 | notes: 'Delete a user by ID.', 234 | auth: { 235 | scope: 'admin' 236 | }, 237 | validate: { 238 | params: { 239 | id: Joi.string().invalid('000000000000000000000000') 240 | } 241 | }, 242 | pre: [ 243 | Preware.requireAdminGroup('root') 244 | ] 245 | }, 246 | handler: async function (request, h) { 247 | 248 | const user = await User.findByIdAndDelete(request.params.id); 249 | 250 | if (!user) { 251 | throw Boom.notFound('User not found.'); 252 | } 253 | 254 | return { message: 'Success.' }; 255 | } 256 | }); 257 | 258 | 259 | server.route({ 260 | method: 'PUT', 261 | path: '/api/users/{id}/password', 262 | options: { 263 | tags: ['api','users'], 264 | description: 'Update a user password. [Root Scope]', 265 | notes: 'Update a user password.', 266 | auth: { 267 | scope: 'admin' 268 | }, 269 | validate: { 270 | params: { 271 | id: Joi.string().invalid('000000000000000000000000') 272 | }, 273 | payload: { 274 | password: Joi.string().required() 275 | } 276 | }, 277 | pre: [ 278 | Preware.requireAdminGroup('root') 279 | ] 280 | }, 281 | handler: async function (request, h) { 282 | 283 | const password = await User.generatePasswordHash(request.payload.password); 284 | const update = { 285 | $set: { 286 | password: password.hash 287 | } 288 | }; 289 | const user = await User.findByIdAndUpdate(request.params.id, update); 290 | 291 | if (!user) { 292 | throw Boom.notFound('User not found.'); 293 | } 294 | 295 | return user; 296 | } 297 | }); 298 | 299 | 300 | server.route({ 301 | method: 'GET', 302 | path: '/api/users/my', 303 | options: { 304 | tags: ['api','users'], 305 | description: 'Get the logged-in user\'s user details like roles. [User Account Scope]', 306 | notes: 'Get the logged-in user\'s user details like roles.', 307 | auth: { 308 | scope: ['admin', 'account'] 309 | } 310 | }, 311 | handler: async function (request, h) { 312 | 313 | const id = request.auth.credentials.user._id; 314 | const fields = User.fieldsAdapter('username email roles'); 315 | 316 | return await User.findById(id, fields); 317 | } 318 | }); 319 | 320 | 321 | server.route({ 322 | method: 'PUT', 323 | path: '/api/users/my', 324 | options: { 325 | tags: ['api','users'], 326 | description: 'Update the logged-in user\'s user details like username and email. [User Account Scope]', 327 | notes: 'Update the logged-in user\'s user details like username and email.', 328 | auth: { 329 | scope: ['admin', 'account'] 330 | }, 331 | validate: { 332 | payload: { 333 | username: Joi.string().token().lowercase().required(), 334 | email: Joi.string().email().lowercase().required() 335 | } 336 | }, 337 | pre: [ 338 | Preware.requireNotRootUser, 339 | { 340 | assign: 'usernameCheck', 341 | method: async function (request, h) { 342 | 343 | const conditions = { 344 | username: request.payload.username, 345 | _id: { $ne: request.auth.credentials.user._id } 346 | }; 347 | const user = await User.findOne(conditions); 348 | 349 | if (user) { 350 | throw Boom.conflict('Username already in use.'); 351 | } 352 | 353 | return h.continue; 354 | } 355 | }, { 356 | assign: 'emailCheck', 357 | method: async function (request, h) { 358 | 359 | const conditions = { 360 | email: request.payload.email, 361 | _id: { $ne: request.auth.credentials.user._id } 362 | }; 363 | const user = await User.findOne(conditions); 364 | 365 | if (user) { 366 | throw Boom.conflict('Email already in use.'); 367 | } 368 | 369 | return h.continue; 370 | } 371 | } 372 | ] 373 | }, 374 | handler: async function (request, h) { 375 | 376 | const userId = `${request.auth.credentials.user._id}`; 377 | const updateUser = { 378 | $set: { 379 | username: request.payload.username, 380 | email: request.payload.email 381 | } 382 | }; 383 | const findOptions = { 384 | fields: User.fieldsAdapter('username email roles') 385 | }; 386 | const queryByUserId = { 387 | 'user.id': userId 388 | }; 389 | const updateRole = { 390 | $set: { 391 | 'user.name': request.payload.username 392 | } 393 | }; 394 | const [user] = await Promise.all([ 395 | User.findByIdAndUpdate(userId, updateUser, findOptions), 396 | Account.findOneAndUpdate(queryByUserId, updateRole), 397 | Admin.findOneAndUpdate(queryByUserId, updateRole) 398 | ]); 399 | 400 | return user; 401 | } 402 | }); 403 | 404 | 405 | server.route({ 406 | method: 'PUT', 407 | path: '/api/users/my/password', 408 | options: { 409 | tags: ['api','users'], 410 | description: 'Update the logged-in user\'s password. [User Account Scope]', 411 | notes: 'Update the logged-in user\'s password.', 412 | auth: { 413 | scope: ['admin', 'account'] 414 | }, 415 | validate: { 416 | payload: { 417 | password: Joi.string().required() 418 | } 419 | }, 420 | pre: [ 421 | Preware.requireNotRootUser 422 | ] 423 | }, 424 | handler: async function (request, h) { 425 | 426 | const userId = `${request.auth.credentials.user._id}`; 427 | const password = await User.generatePasswordHash(request.payload.password); 428 | const update = { 429 | $set: { 430 | password: password.hash 431 | } 432 | }; 433 | const findOptions = { 434 | fields: User.fieldsAdapter('username email') 435 | }; 436 | 437 | return await User.findByIdAndUpdate(userId, update, findOptions); 438 | } 439 | }); 440 | }; 441 | 442 | 443 | module.exports = { 444 | name: 'api-users', 445 | dependencies: [ 446 | 'auth', 447 | 'hapi-mongo-models' 448 | ], 449 | register 450 | }; 451 | -------------------------------------------------------------------------------- /server/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BasicAuth = require('@hapi/basic'); 4 | const Session = require('./models/session'); 5 | const User = require('./models/user'); 6 | 7 | 8 | const register = function (server, options) { 9 | 10 | // register the @hapi/basic plugin on our own otherwise 11 | // the unit tests will fail because the module name '@hapi/basic' 12 | // doesn't match the plugin name 'basic'. 13 | server.register(BasicAuth, { once: true }); 14 | 15 | server.auth.strategy('simple', 'basic', { 16 | validate: async function (request, sessionId, key, h) { 17 | 18 | const session = await Session.findByCredentials(sessionId, key); 19 | 20 | if (!session) { 21 | return { isValid: false }; 22 | } 23 | 24 | session.updateLastActive(); 25 | 26 | const user = await User.findById(session.userId); 27 | 28 | if (!user) { 29 | return { isValid: false }; 30 | } 31 | 32 | if (!user.isActive) { 33 | return { isValid: false }; 34 | } 35 | 36 | const roles = await user.hydrateRoles(); 37 | const credentials = { 38 | scope: Object.keys(user.roles), 39 | roles, 40 | session, 41 | user 42 | }; 43 | 44 | return { credentials, isValid: true }; 45 | } 46 | }); 47 | 48 | server.auth.default('simple'); 49 | }; 50 | 51 | 52 | module.exports = { 53 | name: 'auth', 54 | dependencies: [ 55 | 'hapi-mongo-models' 56 | ], 57 | register 58 | }; 59 | -------------------------------------------------------------------------------- /server/emails/contact.hbs.md: -------------------------------------------------------------------------------- 1 | ### Contact Form 2 | 3 | | Field | Value | 4 | | --------:|:----------- | 5 | | Name: | {{name}} | 6 | | Email: | {{email}} | 7 | | Message: | {{message}} | 8 | 9 | Love, 10 | 11 | The Plot Device 12 | -------------------------------------------------------------------------------- /server/emails/forgot-password.hbs.md: -------------------------------------------------------------------------------- 1 | ### Forgot your password? 2 | 3 | We received a request to reset the password for your account. You'll 4 | need this key to do it. 5 | 6 | __Key:__ 7 | {{key}} 8 | 9 | Love, 10 | 11 | The Plot Device 12 | -------------------------------------------------------------------------------- /server/emails/welcome.hbs.md: -------------------------------------------------------------------------------- 1 | ### Welcome Aboard 2 | 3 | Thanks for signing up. As requested, your account has been created. 4 | Here are your login credentials: 5 | 6 | | Details | | 7 | | ---------:|:------------ | 8 | | Username: | {{username}} | 9 | | Email: | {{email}} | 10 | 11 | Love, 12 | 13 | The Plot Device 14 | -------------------------------------------------------------------------------- /server/mailer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Config = require('../config'); 4 | const Fs = require('fs'); 5 | const Handlebars = require('handlebars'); 6 | const Hoek = require('@hapi/hoek'); 7 | const Markdown = require('nodemailer-markdown').markdown; 8 | const Nodemailer = require('nodemailer'); 9 | const Path = require('path'); 10 | const Util = require('util'); 11 | 12 | 13 | const readFile = Util.promisify(Fs.readFile); 14 | 15 | 16 | class Mailer { 17 | static async renderTemplate(signature, context) { 18 | 19 | if (this.templateCache[signature]) { 20 | return this.templateCache[signature](context); 21 | } 22 | 23 | const filePath = Path.resolve(__dirname, `./emails/${signature}.hbs.md`); 24 | const options = { encoding: 'utf-8' }; 25 | const source = await readFile(filePath, options); 26 | 27 | this.templateCache[signature] = Handlebars.compile(source); 28 | 29 | return this.templateCache[signature](context); 30 | } 31 | 32 | 33 | static async sendEmail(options, template, context) { 34 | 35 | const content = await this.renderTemplate(template, context); 36 | 37 | options = Hoek.applyToDefaults(options, { 38 | from: Config.get('/system/fromAddress'), 39 | markdown: content 40 | }); 41 | 42 | return await this.transport.sendMail(options); 43 | } 44 | } 45 | 46 | 47 | Mailer.templateCache = {}; 48 | Mailer.transport = Nodemailer.createTransport(Config.get('/nodemailer')); 49 | Mailer.transport.use('compile', Markdown({ useEmbeddedImages: true })); 50 | 51 | 52 | module.exports = Mailer; 53 | -------------------------------------------------------------------------------- /server/models/account.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Assert = require('assert'); 4 | const Joi = require('@hapi/joi'); 5 | const MongoModels = require('mongo-models'); 6 | const NewArray = require('joistick/new-array'); 7 | const NewDate = require('joistick/new-date'); 8 | const NoteEntry = require('./note-entry'); 9 | const StatusEntry = require('./status-entry'); 10 | 11 | 12 | const schema = Joi.object({ 13 | _id: Joi.object(), 14 | name: Joi.object({ 15 | first: Joi.string().required(), 16 | middle: Joi.string().allow(''), 17 | last: Joi.string().allow('') 18 | }), 19 | notes: Joi.array().items(NoteEntry.schema) 20 | .default(NewArray(), 'array of notes'), 21 | status: Joi.object({ 22 | current: StatusEntry.schema, 23 | log: Joi.array().items(StatusEntry.schema) 24 | .default(NewArray(), 'array of statuses') 25 | }).default(), 26 | timeCreated: Joi.date().default(NewDate(), 'time of creation'), 27 | user: Joi.object({ 28 | id: Joi.string().required(), 29 | name: Joi.string().lowercase().required() 30 | }) 31 | }); 32 | 33 | 34 | class Account extends MongoModels { 35 | static async create(name) { 36 | 37 | Assert.ok(name, 'Missing name argument.'); 38 | 39 | const document = new this({ 40 | name: this.nameAdapter(name.trim()) 41 | }); 42 | const accounts = await this.insertOne(document); 43 | 44 | return accounts[0]; 45 | } 46 | 47 | static findByUsername(username) { 48 | 49 | Assert.ok(username, 'Missing username argument.'); 50 | 51 | const query = { 'user.name': username.toLowerCase() }; 52 | 53 | return this.findOne(query); 54 | } 55 | 56 | static nameAdapter(name) { 57 | 58 | Assert.ok(name, 'Missing name argument.'); 59 | 60 | const nameParts = name.trim().split(/\s/); 61 | 62 | return { 63 | first: nameParts.shift(), 64 | middle: nameParts.length > 1 ? nameParts.shift() : '', 65 | last: nameParts.join(' ') 66 | }; 67 | } 68 | 69 | fullName() { 70 | 71 | return `${this.name.first} ${this.name.last}`.trim(); 72 | } 73 | 74 | async linkUser(id, name) { 75 | 76 | Assert.ok(id, 'Missing id argument.'); 77 | Assert.ok(name, 'Missing name argument.'); 78 | 79 | const update = { 80 | $set: { 81 | user: { id, name } 82 | } 83 | }; 84 | 85 | return await Account.findByIdAndUpdate(this._id, update); 86 | } 87 | 88 | async unlinkUser() { 89 | 90 | const update = { 91 | $unset: { 92 | user: undefined 93 | } 94 | }; 95 | 96 | return await Account.findByIdAndUpdate(this._id, update); 97 | } 98 | } 99 | 100 | 101 | Account.collectionName = 'accounts'; 102 | Account.schema = schema; 103 | Account.indexes = [ 104 | { key: { 'user.id': 1 } }, 105 | { key: { 'user.name': 1 } } 106 | ]; 107 | 108 | 109 | module.exports = Account; 110 | -------------------------------------------------------------------------------- /server/models/admin-group.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Assert = require('assert'); 4 | const Joi = require('@hapi/joi'); 5 | const MongoModels = require('mongo-models'); 6 | const Slug = require('slug'); 7 | 8 | 9 | const schema = Joi.object({ 10 | _id: Joi.string(), 11 | name: Joi.string().required(), 12 | permissions: Joi.object().description('{ permission: boolean, ... }') 13 | }); 14 | 15 | 16 | class AdminGroup extends MongoModels { 17 | static async create(name) { 18 | 19 | Assert.ok(name, 'Missing name argument.'); 20 | 21 | const document = new this({ 22 | _id: Slug(name).toLowerCase(), 23 | name 24 | }); 25 | const groups = await this.insertOne(document); 26 | 27 | return groups[0]; 28 | } 29 | 30 | hasPermissionTo(permission) { 31 | 32 | Assert.ok(permission, 'Missing permission argument.'); 33 | 34 | if (this.permissions && this.permissions.hasOwnProperty(permission)) { 35 | return this.permissions[permission]; 36 | } 37 | 38 | return false; 39 | } 40 | } 41 | 42 | 43 | AdminGroup._idClass = String; 44 | AdminGroup.collectionName = 'adminGroups'; 45 | AdminGroup.schema = schema; 46 | 47 | 48 | module.exports = AdminGroup; 49 | -------------------------------------------------------------------------------- /server/models/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AdminGroup = require('./admin-group'); 4 | const Assert = require('assert'); 5 | const Joi = require('@hapi/joi'); 6 | const MongoModels = require('mongo-models'); 7 | const NewDate = require('joistick/new-date'); 8 | 9 | 10 | const schema = Joi.object({ 11 | _id: Joi.object(), 12 | groups: Joi.object().description('{ groupId: name, ... }').default(), 13 | name: Joi.object({ 14 | first: Joi.string().required(), 15 | middle: Joi.string().allow(''), 16 | last: Joi.string().allow('') 17 | }), 18 | permissions: Joi.object().description('{ permission: boolean, ... }'), 19 | timeCreated: Joi.date().default(NewDate(), 'time of creation'), 20 | user: Joi.object({ 21 | id: Joi.string().required(), 22 | name: Joi.string().lowercase().required() 23 | }) 24 | }); 25 | 26 | 27 | class Admin extends MongoModels { 28 | static async create(name) { 29 | 30 | Assert.ok(name, 'Missing name argument.'); 31 | 32 | const document = new this({ 33 | name: this.nameAdapter(name) 34 | }); 35 | const admins = await this.insertOne(document); 36 | 37 | return admins[0]; 38 | } 39 | 40 | static findByUsername(username) { 41 | 42 | Assert.ok(username, 'Missing username argument.'); 43 | 44 | const query = { 'user.name': username.toLowerCase() }; 45 | 46 | return this.findOne(query); 47 | } 48 | 49 | static nameAdapter(name) { 50 | 51 | Assert.ok(name, 'Missing name argument.'); 52 | 53 | const nameParts = name.trim().split(/\s/); 54 | 55 | return { 56 | first: nameParts.shift(), 57 | middle: nameParts.length > 1 ? nameParts.shift() : '', 58 | last: nameParts.join(' ') 59 | }; 60 | } 61 | 62 | constructor(attrs) { 63 | 64 | super(attrs); 65 | 66 | Object.defineProperty(this, '_groups', { 67 | writable: true, 68 | enumerable: false 69 | }); 70 | } 71 | 72 | fullName() { 73 | 74 | return `${this.name.first} ${this.name.last}`.trim(); 75 | } 76 | 77 | async hasPermissionTo(permission) { 78 | 79 | Assert.ok(permission, 'Missing permission argument.'); 80 | 81 | if (this.permissions && this.permissions.hasOwnProperty(permission)) { 82 | return this.permissions[permission]; 83 | } 84 | 85 | await this.hydrateGroups(); 86 | 87 | let groupHasPermission = false; 88 | 89 | Object.keys(this._groups).forEach((group) => { 90 | 91 | if (this._groups[group].hasPermissionTo(permission)) { 92 | groupHasPermission = true; 93 | } 94 | }); 95 | 96 | return groupHasPermission; 97 | } 98 | 99 | async hydrateGroups() { 100 | 101 | if (this._groups) { 102 | return this._groups; 103 | } 104 | 105 | this._groups = {}; 106 | 107 | const groups = await AdminGroup.find({ 108 | _id: { 109 | $in: Object.keys(this.groups) 110 | } 111 | }); 112 | 113 | this._groups = groups.reduce((accumulator, group) => { 114 | 115 | accumulator[group._id] = group; 116 | 117 | return accumulator; 118 | }, {}); 119 | 120 | return this._groups; 121 | } 122 | 123 | isMemberOf(group) { 124 | 125 | Assert.ok(group, 'Missing group argument.'); 126 | 127 | return this.groups.hasOwnProperty(group); 128 | } 129 | 130 | async linkUser(id, name) { 131 | 132 | Assert.ok(id, 'Missing id argument.'); 133 | Assert.ok(name, 'Missing name argument.'); 134 | 135 | const update = { 136 | $set: { 137 | user: { id, name } 138 | } 139 | }; 140 | 141 | return await Admin.findByIdAndUpdate(this._id, update); 142 | } 143 | 144 | async unlinkUser() { 145 | 146 | const update = { 147 | $unset: { 148 | user: undefined 149 | } 150 | }; 151 | 152 | return await Admin.findByIdAndUpdate(this._id, update); 153 | } 154 | } 155 | 156 | 157 | Admin.collectionName = 'admins'; 158 | Admin.schema = schema; 159 | Admin.indexes = [ 160 | { key: { 'user.id': 1 } }, 161 | { key: { 'user.name': 1 } } 162 | ]; 163 | 164 | 165 | module.exports = Admin; 166 | -------------------------------------------------------------------------------- /server/models/auth-attempt.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Assert = require('assert'); 4 | const Config = require('../../config'); 5 | const Joi = require('@hapi/joi'); 6 | const MongoModels = require('mongo-models'); 7 | const NewDate = require('joistick/new-date'); 8 | 9 | 10 | const schema = Joi.object({ 11 | _id: Joi.object(), 12 | ip: Joi.string().required(), 13 | timeCreated: Joi.date().default(NewDate(), 'time of creation'), 14 | username: Joi.string().required() 15 | }); 16 | 17 | 18 | class AuthAttempt extends MongoModels { 19 | static async abuseDetected(ip, username) { 20 | 21 | Assert.ok(ip, 'Missing ip argument.'); 22 | Assert.ok(username, 'Missing username argument.'); 23 | 24 | const [countByIp, countByIpAndUser] = await Promise.all([ 25 | this.count({ ip }), 26 | this.count({ ip, username }) 27 | ]); 28 | const config = Config.get('/authAttempts'); 29 | const ipLimitReached = countByIp >= config.forIp; 30 | const ipUserLimitReached = countByIpAndUser >= config.forIpAndUser; 31 | 32 | return ipLimitReached || ipUserLimitReached; 33 | } 34 | 35 | static async create(ip, username) { 36 | 37 | Assert.ok(ip, 'Missing ip argument.'); 38 | Assert.ok(username, 'Missing username argument.'); 39 | 40 | const document = new this({ 41 | ip, 42 | username 43 | }); 44 | const authAttempts = await this.insertOne(document); 45 | 46 | return authAttempts[0]; 47 | } 48 | } 49 | 50 | 51 | AuthAttempt.collectionName = 'authAttempts'; 52 | AuthAttempt.schema = schema; 53 | AuthAttempt.indexes = [ 54 | { key: { ip: 1, username: 1 } }, 55 | { key: { username: 1 } } 56 | ]; 57 | 58 | 59 | module.exports = AuthAttempt; 60 | -------------------------------------------------------------------------------- /server/models/note-entry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Joi = require('@hapi/joi'); 4 | const MongoModels = require('mongo-models'); 5 | const NewDate = require('joistick/new-date'); 6 | 7 | 8 | const schema = Joi.object({ 9 | adminCreated: Joi.object({ 10 | id: Joi.string().required(), 11 | name: Joi.string().required() 12 | }).required(), 13 | data: Joi.string().required(), 14 | timeCreated: Joi.date().default(NewDate(), 'time of creation') 15 | }); 16 | 17 | 18 | class NoteEntry extends MongoModels {} 19 | 20 | 21 | NoteEntry.schema = schema; 22 | 23 | 24 | module.exports = NoteEntry; 25 | -------------------------------------------------------------------------------- /server/models/session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Assert = require('assert'); 4 | const Bcrypt = require('bcrypt'); 5 | const Joi = require('@hapi/joi'); 6 | const MongoModels = require('mongo-models'); 7 | const NewDate = require('joistick/new-date'); 8 | const Useragent = require('useragent'); 9 | const Uuid = require('uuid'); 10 | 11 | 12 | const schema = Joi.object({ 13 | _id: Joi.object(), 14 | browser: Joi.string().required(), 15 | ip: Joi.string().required(), 16 | key: Joi.string().required(), 17 | lastActive: Joi.date().default(NewDate(), 'time of last activity'), 18 | os: Joi.string().required(), 19 | timeCreated: Joi.date().default(NewDate(), 'time of creation'), 20 | userId: Joi.string().required() 21 | }); 22 | 23 | 24 | class Session extends MongoModels { 25 | static async create(userId, ip, userAgent) { 26 | 27 | Assert.ok(userId, 'Missing userId argument.'); 28 | Assert.ok(ip, 'Missing ip argument.'); 29 | Assert.ok(userAgent, 'Missing userAgent argument.'); 30 | 31 | const keyHash = await this.generateKeyHash(); 32 | const agentInfo = Useragent.lookup(userAgent); 33 | const browser = agentInfo.family; 34 | const document = new this({ 35 | browser, 36 | ip, 37 | key: keyHash.hash, 38 | os: agentInfo.os.toString(), 39 | userId 40 | }); 41 | const sessions = await this.insertOne(document); 42 | 43 | sessions[0].key = keyHash.key; 44 | 45 | return sessions[0]; 46 | } 47 | 48 | static async findByCredentials(id, key) { 49 | 50 | Assert.ok(id, 'Missing id argument.'); 51 | Assert.ok(key, 'Missing key argument.'); 52 | 53 | const session = await this.findById(id); 54 | 55 | if (!session) { 56 | return; 57 | } 58 | 59 | const keyMatch = await Bcrypt.compare(key, session.key); 60 | 61 | if (keyMatch) { 62 | return session; 63 | } 64 | } 65 | 66 | static async generateKeyHash() { 67 | 68 | const key = Uuid.v4(); 69 | const salt = await Bcrypt.genSalt(10); 70 | const hash = await Bcrypt.hash(key, salt); 71 | 72 | return { key, hash }; 73 | } 74 | 75 | async updateLastActive() { 76 | 77 | const update = { 78 | $set: { 79 | lastActive: new Date() 80 | } 81 | }; 82 | 83 | await Session.findByIdAndUpdate(this._id, update); 84 | } 85 | } 86 | 87 | 88 | Session.collectionName = 'sessions'; 89 | Session.schema = schema; 90 | Session.indexes = [ 91 | { key: { userId: 1 } } 92 | ]; 93 | 94 | 95 | module.exports = Session; 96 | -------------------------------------------------------------------------------- /server/models/status-entry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Joi = require('@hapi/joi'); 4 | const MongoModels = require('mongo-models'); 5 | const NewDate = require('joistick/new-date'); 6 | 7 | 8 | const schema = Joi.object({ 9 | id: Joi.string().required(), 10 | name: Joi.string().required(), 11 | timeCreated: Joi.date().default(NewDate(), 'time of creation'), 12 | adminCreated: Joi.object({ 13 | id: Joi.string().required(), 14 | name: Joi.string().required() 15 | }).required() 16 | }); 17 | 18 | 19 | class StatusEntry extends MongoModels {} 20 | 21 | 22 | StatusEntry.schema = schema; 23 | 24 | 25 | module.exports = StatusEntry; 26 | -------------------------------------------------------------------------------- /server/models/status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Assert = require('assert'); 4 | const Joi = require('@hapi/joi'); 5 | const MongoModels = require('mongo-models'); 6 | const Slug = require('slug'); 7 | 8 | 9 | const schema = Joi.object({ 10 | _id: Joi.string(), 11 | name: Joi.string().required(), 12 | pivot: Joi.string().required() 13 | }); 14 | 15 | 16 | class Status extends MongoModels { 17 | static async create(pivot, name) { 18 | 19 | Assert.ok(pivot, 'Missing pivot argument.'); 20 | Assert.ok(name, 'Missing name argument.'); 21 | 22 | const document = new this({ 23 | _id: Slug(`${pivot}-${name}`).toLowerCase(), 24 | name, 25 | pivot 26 | }); 27 | const statuses = await this.insertOne(document); 28 | 29 | return statuses[0]; 30 | } 31 | } 32 | 33 | 34 | Status._idClass = String; 35 | Status.collectionName = 'statuses'; 36 | Status.schema = schema; 37 | Status.indexes = [ 38 | { key: { pivot: 1 } }, 39 | { key: { name: 1 } } 40 | ]; 41 | 42 | 43 | module.exports = Status; 44 | -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Account = require('./account'); 4 | const Admin = require('./admin'); 5 | const Assert = require('assert'); 6 | const Bcrypt = require('bcrypt'); 7 | const Joi = require('@hapi/joi'); 8 | const MongoModels = require('mongo-models'); 9 | const NewDate = require('joistick/new-date'); 10 | 11 | 12 | const schema = Joi.object({ 13 | _id: Joi.object(), 14 | email: Joi.string().email().lowercase().required(), 15 | isActive: Joi.boolean().default(true), 16 | password: Joi.string(), 17 | resetPassword: Joi.object({ 18 | token: Joi.string().required(), 19 | expires: Joi.date().required() 20 | }), 21 | roles: Joi.object({ 22 | admin: Joi.object({ 23 | id: Joi.string().required(), 24 | name: Joi.string().required() 25 | }), 26 | account: Joi.object({ 27 | id: Joi.string().required(), 28 | name: Joi.string().required() 29 | }) 30 | }).default(), 31 | timeCreated: Joi.date().default(NewDate(), 'time of creation'), 32 | username: Joi.string().token().lowercase().required() 33 | }); 34 | 35 | 36 | class User extends MongoModels { 37 | static async create(username, password, email) { 38 | 39 | Assert.ok(username, 'Missing username argument.'); 40 | Assert.ok(password, 'Missing password argument.'); 41 | Assert.ok(email, 'Missing email argument.'); 42 | 43 | const passwordHash = await this.generatePasswordHash(password); 44 | const document = new this({ 45 | email, 46 | isActive: true, 47 | password: passwordHash.hash, 48 | username 49 | }); 50 | const users = await this.insertOne(document); 51 | 52 | users[0].password = passwordHash.password; 53 | 54 | return users[0]; 55 | } 56 | 57 | static async findByCredentials(username, password) { 58 | 59 | Assert.ok(username, 'Missing username argument.'); 60 | Assert.ok(password, 'Missing password argument.'); 61 | 62 | const query = { isActive: true }; 63 | 64 | if (username.indexOf('@') > -1) { 65 | query.email = username.toLowerCase(); 66 | } 67 | else { 68 | query.username = username.toLowerCase(); 69 | } 70 | 71 | const user = await this.findOne(query); 72 | 73 | if (!user) { 74 | return; 75 | } 76 | 77 | const passwordMatch = await Bcrypt.compare(password, user.password); 78 | 79 | if (passwordMatch) { 80 | return user; 81 | } 82 | } 83 | 84 | static findByEmail(email) { 85 | 86 | Assert.ok(email, 'Missing email argument.'); 87 | 88 | const query = { email: email.toLowerCase() }; 89 | 90 | return this.findOne(query); 91 | } 92 | 93 | static findByUsername(username) { 94 | 95 | Assert.ok(username, 'Missing username argument.'); 96 | 97 | const query = { username: username.toLowerCase() }; 98 | 99 | return this.findOne(query); 100 | } 101 | 102 | static async generatePasswordHash(password) { 103 | 104 | Assert.ok(password, 'Missing password argument.'); 105 | 106 | const salt = await Bcrypt.genSalt(10); 107 | const hash = await Bcrypt.hash(password, salt); 108 | 109 | return { password, hash }; 110 | } 111 | 112 | constructor(attrs) { 113 | 114 | super(attrs); 115 | 116 | Object.defineProperty(this, '_roles', { 117 | writable: true, 118 | enumerable: false 119 | }); 120 | } 121 | 122 | canPlayRole(role) { 123 | 124 | Assert.ok(role, 'Missing role argument.'); 125 | 126 | return this.roles.hasOwnProperty(role); 127 | } 128 | 129 | async hydrateRoles() { 130 | 131 | if (this._roles) { 132 | return this._roles; 133 | } 134 | 135 | this._roles = {}; 136 | 137 | if (this.roles.account) { 138 | this._roles.account = await Account.findById(this.roles.account.id); 139 | } 140 | 141 | if (this.roles.admin) { 142 | this._roles.admin = await Admin.findById(this.roles.admin.id); 143 | } 144 | 145 | return this._roles; 146 | } 147 | 148 | async linkAccount(id, name) { 149 | 150 | Assert.ok(id, 'Missing id argument.'); 151 | Assert.ok(name, 'Missing name argument.'); 152 | 153 | const update = { 154 | $set: { 155 | 'roles.account': { id, name } 156 | } 157 | }; 158 | 159 | return await User.findByIdAndUpdate(this._id, update); 160 | } 161 | 162 | async linkAdmin(id, name) { 163 | 164 | Assert.ok(id, 'Missing id argument.'); 165 | Assert.ok(name, 'Missing name argument.'); 166 | 167 | const update = { 168 | $set: { 169 | 'roles.admin': { id, name } 170 | } 171 | }; 172 | 173 | return await User.findByIdAndUpdate(this._id, update); 174 | } 175 | 176 | async unlinkAccount() { 177 | 178 | const update = { 179 | $unset: { 180 | 'roles.account': undefined 181 | } 182 | }; 183 | 184 | return await User.findByIdAndUpdate(this._id, update); 185 | } 186 | 187 | async unlinkAdmin() { 188 | 189 | const update = { 190 | $unset: { 191 | 'roles.admin': undefined 192 | } 193 | }; 194 | 195 | return await User.findByIdAndUpdate(this._id, update); 196 | } 197 | } 198 | 199 | 200 | User.collectionName = 'users'; 201 | User.schema = schema; 202 | User.indexes = [ 203 | { key: { username: 1 }, unique: true }, 204 | { key: { email: 1 }, unique: true } 205 | ]; 206 | 207 | 208 | module.exports = User; 209 | -------------------------------------------------------------------------------- /server/preware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Boom = require('@hapi/boom'); 4 | 5 | 6 | class Preware { 7 | static requireAdminGroup(groups) { 8 | 9 | return { 10 | assign: 'ensureAdminGroup', 11 | method: function (request, h) { 12 | 13 | if (Object.prototype.toString.call(groups) !== '[object Array]') { 14 | groups = [groups]; 15 | } 16 | 17 | const admin = request.auth.credentials.roles.admin; 18 | const groupFound = groups.some((group) => admin.isMemberOf(group)); 19 | 20 | if (!groupFound) { 21 | throw Boom.forbidden('Missing required group membership.'); 22 | } 23 | 24 | return h.continue; 25 | } 26 | }; 27 | }; 28 | } 29 | 30 | 31 | Preware.requireNotRootUser = { 32 | assign: 'requireNotRootUser', 33 | method: function (request, h) { 34 | 35 | if (request.auth.credentials.user.username === 'root') { 36 | throw Boom.forbidden('Not permitted for the root user.'); 37 | } 38 | 39 | return h.continue; 40 | } 41 | }; 42 | 43 | 44 | module.exports = Preware; 45 | -------------------------------------------------------------------------------- /server/web/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | const register = function (server, options) { 5 | 6 | server.route({ 7 | method: 'GET', 8 | path: '/', 9 | options: { 10 | auth: false 11 | }, 12 | handler: function (request, h) { 13 | 14 | return '

Welcome to the website.

'; 15 | } 16 | }); 17 | }; 18 | 19 | 20 | module.exports = { 21 | name: 'web-main', 22 | dependencies: [], 23 | register 24 | }; 25 | -------------------------------------------------------------------------------- /test/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Config = require('../config'); 5 | const Lab = require('@hapi/lab'); 6 | 7 | 8 | const lab = exports.lab = Lab.script(); 9 | 10 | 11 | lab.experiment('Config', () => { 12 | 13 | lab.test('it gets config data', () => { 14 | 15 | Code.expect(Config.get('/')).to.be.an.object(); 16 | }); 17 | 18 | 19 | lab.test('it gets config meta data', () => { 20 | 21 | Code.expect(Config.meta('/')).to.match(/this file configures the plot device/i); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/manifest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Lab = require('@hapi/lab'); 5 | const Manifest = require('../manifest'); 6 | 7 | 8 | const lab = exports.lab = Lab.script(); 9 | 10 | 11 | lab.experiment('Manifest', () => { 12 | 13 | lab.test('it gets manifest data', () => { 14 | 15 | Code.expect(Manifest.get('/')).to.be.an.object(); 16 | }); 17 | 18 | 19 | lab.test('it gets manifest meta data', () => { 20 | 21 | Code.expect(Manifest.meta('/')).to.match(/this file defines the plot device/i); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | // 'use strict'; 2 | // 3 | // const Code = require('@hapi/code'); 4 | // const Composer = require('../server'); 5 | // const Lab = require('@hapi/lab'); 6 | // 7 | // 8 | // const lab = exports.lab = Lab.script(); 9 | // 10 | // 11 | // lab.experiment('App', () => { 12 | // 13 | // lab.test('it composes a server', (done) => { 14 | // 15 | // Composer((err, composedServer) => { 16 | // 17 | // Code.expect(composedServer).to.be.an.object(); 18 | // 19 | // done(err); 20 | // }); 21 | // }); 22 | // }); 23 | -------------------------------------------------------------------------------- /test/server/api/admin-groups.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AdminGroup = require('../../../server/models/admin-group'); 4 | const AdminGroups = require('../../../server/api/admin-groups'); 5 | const Auth = require('../../../server/auth'); 6 | const Code = require('@hapi/code'); 7 | const Fixtures = require('../fixtures'); 8 | const Hapi = require('@hapi/hapi'); 9 | const Lab = require('@hapi/lab'); 10 | const Manifest = require('../../../manifest'); 11 | 12 | 13 | const lab = exports.lab = Lab.script(); 14 | let server; 15 | let rootCredentials; 16 | 17 | 18 | lab.before(async () => { 19 | 20 | server = Hapi.Server(); 21 | 22 | const plugins = Manifest.get('/register/plugins') 23 | .filter((entry) => AdminGroups.dependencies.includes(entry.plugin)) 24 | .map((entry) => { 25 | 26 | entry.plugin = require(entry.plugin); 27 | 28 | return entry; 29 | }); 30 | 31 | plugins.push(Auth); 32 | plugins.push(AdminGroups); 33 | 34 | await server.register(plugins); 35 | await server.start(); 36 | await Fixtures.Db.removeAllData(); 37 | 38 | rootCredentials = await Fixtures.Creds.createRootAdminUser(); 39 | }); 40 | 41 | 42 | lab.after(async () => { 43 | 44 | await Fixtures.Db.removeAllData(); 45 | await server.stop(); 46 | }); 47 | 48 | 49 | lab.experiment('GET /api/admin-groups', () => { 50 | 51 | let request; 52 | 53 | 54 | lab.beforeEach(() => { 55 | 56 | request = { 57 | method: 'GET', 58 | url: '/api/admin-groups', 59 | auth: { 60 | strategy: 'basic', 61 | credentials: rootCredentials 62 | } 63 | }; 64 | }); 65 | 66 | 67 | lab.test('it returns HTTP 200 when all is well', async () => { 68 | 69 | const response = await server.inject(request); 70 | 71 | Code.expect(response.statusCode).to.equal(200); 72 | Code.expect(response.result.data).to.be.an.array(); 73 | Code.expect(response.result.pages).to.be.an.object(); 74 | Code.expect(response.result.items).to.be.an.object(); 75 | }); 76 | }); 77 | 78 | 79 | lab.experiment('POST /api/admin-groups', () => { 80 | 81 | let request; 82 | 83 | 84 | lab.beforeEach(() => { 85 | 86 | request = { 87 | method: 'POST', 88 | url: '/api/admin-groups', 89 | auth: { 90 | strategy: 'basic', 91 | credentials: rootCredentials 92 | } 93 | }; 94 | }); 95 | 96 | 97 | lab.test('it returns HTTP 200 when all is well', async () => { 98 | 99 | request.payload = { 100 | name: 'Sales' 101 | }; 102 | 103 | const response = await server.inject(request); 104 | 105 | Code.expect(response.statusCode).to.equal(200); 106 | Code.expect(response.result).to.be.and.object(); 107 | Code.expect(response.result.name).to.be.equal('Sales'); 108 | }); 109 | }); 110 | 111 | 112 | lab.experiment('GET /api/admin-groups/{id}', () => { 113 | 114 | let request; 115 | 116 | 117 | lab.beforeEach(() => { 118 | 119 | request = { 120 | method: 'GET', 121 | url: '/api/admin-groups/{id}', 122 | auth: { 123 | strategy: 'basic', 124 | credentials: rootCredentials 125 | } 126 | }; 127 | }); 128 | 129 | 130 | lab.test('it returns HTTP 404 when `AdminGroup.findById` misses', async () => { 131 | 132 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 133 | 134 | const response = await server.inject(request); 135 | 136 | Code.expect(response.statusCode).to.equal(404); 137 | Code.expect(response.result.message).to.match(/not found/i); 138 | }); 139 | 140 | 141 | lab.test('it returns HTTP 200 when all is well', async () => { 142 | 143 | const adminGroup = await AdminGroup.create('Support'); 144 | 145 | request.url = request.url.replace(/{id}/, adminGroup._id); 146 | 147 | const response = await server.inject(request); 148 | 149 | Code.expect(response.statusCode).to.equal(200); 150 | Code.expect(response.result).to.be.an.object(); 151 | Code.expect(response.result.name).to.equal('Support'); 152 | }); 153 | }); 154 | 155 | 156 | lab.experiment('PUT /api/admin-groups/{id}', () => { 157 | 158 | let request; 159 | 160 | 161 | lab.beforeEach(() => { 162 | 163 | request = { 164 | method: 'PUT', 165 | url: '/api/admin-groups/{id}', 166 | auth: { 167 | strategy: 'basic', 168 | credentials: rootCredentials 169 | } 170 | }; 171 | }); 172 | 173 | 174 | lab.test('it returns HTTP 404 when `AdminGroup.findByIdAndUpdate` misses', async () => { 175 | 176 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 177 | request.payload = { 178 | name: 'Wrecking Crew' 179 | }; 180 | 181 | const response = await server.inject(request); 182 | 183 | Code.expect(response.statusCode).to.equal(404); 184 | Code.expect(response.result.message).to.match(/not found/i); 185 | }); 186 | 187 | 188 | lab.test('it returns HTTP 200 when all is well', async () => { 189 | 190 | const adminGroup = await AdminGroup.create('Shipping'); 191 | 192 | request.url = request.url.replace(/{id}/, adminGroup._id); 193 | request.payload = { 194 | name: 'Fulfillment' 195 | }; 196 | 197 | const response = await server.inject(request); 198 | 199 | Code.expect(response.statusCode).to.equal(200); 200 | Code.expect(response.result).to.be.an.object(); 201 | Code.expect(response.result.name).to.equal('Fulfillment'); 202 | }); 203 | }); 204 | 205 | 206 | lab.experiment('DELETE /api/admin-groups/{id}', () => { 207 | 208 | let request; 209 | 210 | 211 | lab.beforeEach(() => { 212 | 213 | request = { 214 | method: 'DELETE', 215 | url: '/api/admin-groups/{id}', 216 | auth: { 217 | strategy: 'basic', 218 | credentials: rootCredentials 219 | } 220 | }; 221 | }); 222 | 223 | 224 | lab.test('it returns HTTP 404 when `AdminGroup.findByIdAndDelete` misses', async () => { 225 | 226 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 227 | 228 | const response = await server.inject(request); 229 | 230 | Code.expect(response.statusCode).to.equal(404); 231 | Code.expect(response.result.message).to.match(/not found/i); 232 | }); 233 | 234 | 235 | lab.test('it returns HTTP 200 when all is well', async () => { 236 | 237 | const adminGroup = await AdminGroup.create('Steve'); 238 | 239 | request.url = request.url.replace(/{id}/, adminGroup._id); 240 | 241 | const response = await server.inject(request); 242 | 243 | Code.expect(response.statusCode).to.equal(200); 244 | Code.expect(response.result).to.be.an.object(); 245 | Code.expect(response.result.message).to.match(/success/i); 246 | }); 247 | }); 248 | 249 | 250 | lab.experiment('PUT /api/admin-groups/{id}/permissions', () => { 251 | 252 | let request; 253 | 254 | 255 | lab.beforeEach(() => { 256 | 257 | request = { 258 | method: 'PUT', 259 | url: '/api/admin-groups/{id}/permissions', 260 | auth: { 261 | strategy: 'basic', 262 | credentials: rootCredentials 263 | } 264 | }; 265 | }); 266 | 267 | 268 | lab.test('it returns HTTP 404 when `AdminGroup.findByIdAndUpdate` misses', async () => { 269 | 270 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 271 | request.payload = { 272 | permissions: { 273 | CAN_CREATE_ACCOUNTS: true, 274 | CAN_DELETE_ACCOUNTS: false 275 | } 276 | }; 277 | 278 | const response = await server.inject(request); 279 | 280 | Code.expect(response.statusCode).to.equal(404); 281 | Code.expect(response.result.message).to.match(/not found/i); 282 | }); 283 | 284 | 285 | lab.test('it returns HTTP 200 when all is well', async () => { 286 | 287 | const adminGroup = await AdminGroup.create('Executive'); 288 | 289 | request.url = request.url.replace(/{id}/, adminGroup._id); 290 | request.payload = { 291 | permissions: { 292 | CAN_CREATE_ACCOUNTS: true, 293 | CAN_DELETE_ACCOUNTS: false 294 | } 295 | }; 296 | 297 | const response = await server.inject(request); 298 | 299 | Code.expect(response.statusCode).to.equal(200); 300 | Code.expect(response.result).to.be.an.object(); 301 | Code.expect(response.result.name).to.equal('Executive'); 302 | Code.expect(response.result.permissions).to.be.an.object(); 303 | Code.expect(response.result.permissions.CAN_CREATE_ACCOUNTS).to.be.true(); 304 | Code.expect(response.result.permissions.CAN_DELETE_ACCOUNTS).to.be.false(); 305 | }); 306 | }); 307 | -------------------------------------------------------------------------------- /test/server/api/contact.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Contact = require('../../../server/api/contact'); 5 | const Hapi = require('@hapi/hapi'); 6 | const Lab = require('@hapi/lab'); 7 | const Mailer = require('../../../server/mailer'); 8 | 9 | 10 | const lab = exports.lab = Lab.script(); 11 | let server; 12 | 13 | 14 | lab.before(async () => { 15 | 16 | server = Hapi.Server(); 17 | 18 | await server.register(Contact); 19 | await server.start(); 20 | }); 21 | 22 | 23 | lab.after(async () => { 24 | 25 | await server.stop(); 26 | }); 27 | 28 | 29 | lab.experiment('POST /api/contact', () => { 30 | 31 | const Mailer_sendEmail = Mailer.sendEmail; 32 | let request; 33 | 34 | 35 | lab.beforeEach(() => { 36 | 37 | request = { 38 | method: 'POST', 39 | url: '/api/contact' 40 | }; 41 | }); 42 | 43 | 44 | lab.afterEach(() => { 45 | 46 | Mailer.sendEmail = Mailer_sendEmail; 47 | }); 48 | 49 | 50 | lab.test('it returns HTTP 200 when all is good', async () => { 51 | 52 | Mailer.sendEmail = () => undefined; 53 | 54 | request.payload = { 55 | name: 'Foo Barzley', 56 | email: 'foo@stimpy.show', 57 | message: 'Hello. How are you?' 58 | }; 59 | 60 | const response = await server.inject(request); 61 | 62 | Code.expect(response.statusCode).to.equal(200); 63 | Code.expect(response.result.message).to.match(/success/i); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/server/api/login.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AuthAttempt = require('../../../server/models/auth-attempt'); 4 | const Code = require('@hapi/code'); 5 | const Fixtures = require('../fixtures'); 6 | const Hapi = require('@hapi/hapi'); 7 | const Lab = require('@hapi/lab'); 8 | const Login = require('../../../server/api/login'); 9 | const Mailer = require('../../../server/mailer'); 10 | const Manifest = require('../../../manifest'); 11 | const User = require('../../../server/models/user'); 12 | 13 | 14 | const lab = exports.lab = Lab.script(); 15 | let server; 16 | 17 | 18 | lab.before(async () => { 19 | 20 | server = Hapi.Server(); 21 | 22 | const plugins = Manifest.get('/register/plugins') 23 | .filter((entry) => Login.dependencies.includes(entry.plugin)) 24 | .map((entry) => { 25 | 26 | entry.plugin = require(entry.plugin); 27 | 28 | return entry; 29 | }); 30 | 31 | plugins.push(Login); 32 | 33 | await server.register(plugins); 34 | await server.start(); 35 | await Fixtures.Db.removeAllData(); 36 | 37 | await User.create('ren', 'baddog', 'ren@stimpy.show'); 38 | }); 39 | 40 | 41 | lab.after(async () => { 42 | 43 | await Fixtures.Db.removeAllData(); 44 | await server.stop(); 45 | }); 46 | 47 | 48 | lab.experiment('POST /api/login', () => { 49 | 50 | const AuthAttempt_abuseDetected = AuthAttempt.abuseDetected; 51 | const User_findByCredentials = User.findByCredentials; 52 | let request; 53 | 54 | 55 | lab.beforeEach(() => { 56 | 57 | request = { 58 | method: 'POST', 59 | url: '/api/login', 60 | payload: { 61 | username: 'ren', 62 | password: 'baddog' 63 | } 64 | }; 65 | }); 66 | 67 | 68 | lab.afterEach(() => { 69 | 70 | AuthAttempt.abuseDetected = AuthAttempt_abuseDetected; 71 | User.findByCredentials = User_findByCredentials; 72 | }); 73 | 74 | 75 | lab.test('it returns HTTP 400 when login abuse is detected', async () => { 76 | 77 | AuthAttempt.abuseDetected = () => true; 78 | 79 | const response = await server.inject(request); 80 | 81 | Code.expect(response.statusCode).to.equal(400); 82 | Code.expect(response.result.message) 83 | .to.match(/maximum number of auth attempts reached/i); 84 | }); 85 | 86 | 87 | lab.test('it returns HTTP 400 when a user is not found', async () => { 88 | 89 | User.findByCredentials = () => undefined; 90 | 91 | const response = await server.inject(request); 92 | 93 | Code.expect(response.statusCode).to.equal(400); 94 | Code.expect(response.result.message) 95 | .to.match(/credentials are invalid or account is inactive/i); 96 | }); 97 | 98 | 99 | lab.test('it returns HTTP 200 when all is well', async () => { 100 | 101 | const response = await server.inject(request); 102 | 103 | Code.expect(response.statusCode).to.equal(200); 104 | Code.expect(response.result).to.be.an.object(); 105 | Code.expect(response.result.user).to.be.an.object(); 106 | Code.expect(response.result.session).to.be.an.object(); 107 | Code.expect(response.result.authHeader).to.be.a.string(); 108 | }); 109 | }); 110 | 111 | 112 | lab.experiment('POST /api/login/forgot', () => { 113 | 114 | const Mailer_sendEmail = Mailer.sendEmail; 115 | const User_findOne = User.findOne; 116 | let request; 117 | 118 | 119 | lab.beforeEach(() => { 120 | 121 | request = { 122 | method: 'POST', 123 | url: '/api/login/forgot', 124 | payload: { 125 | email: 'ren@stimpy.show' 126 | } 127 | }; 128 | }); 129 | 130 | 131 | lab.afterEach(() => { 132 | 133 | Mailer.sendEmail = Mailer_sendEmail; 134 | User.findOne = User_findOne; 135 | }); 136 | 137 | 138 | lab.test('it returns HTTP 200 when the user query misses', async () => { 139 | 140 | User.findOne = () => undefined; 141 | 142 | const response = await server.inject(request); 143 | 144 | Code.expect(response.statusCode).to.equal(200); 145 | Code.expect(response.result.message).to.match(/success/i); 146 | }); 147 | 148 | 149 | lab.test('it returns HTTP 200 when all is well', async () => { 150 | 151 | Mailer.sendEmail = () => undefined; 152 | 153 | const response = await server.inject(request); 154 | 155 | Code.expect(response.statusCode).to.equal(200); 156 | Code.expect(response.result.message).to.match(/success/i); 157 | }); 158 | }); 159 | 160 | 161 | lab.experiment('POST /api/login/reset', () => { 162 | 163 | const User_findOne = User.findOne; 164 | const Mailer_sendEmail = Mailer.sendEmail; 165 | let request; 166 | let key; 167 | 168 | 169 | lab.before(async () => { 170 | 171 | Mailer.sendEmail = (_, __, context) => { 172 | 173 | key = context.key; 174 | }; 175 | 176 | await server.inject({ 177 | method: 'POST', 178 | url: '/api/login/forgot', 179 | payload: { 180 | email: 'ren@stimpy.show' 181 | } 182 | }); 183 | }); 184 | 185 | 186 | lab.beforeEach(() => { 187 | 188 | request = { 189 | method: 'POST', 190 | url: '/api/login/reset', 191 | payload: { 192 | email: 'ren@stimpy.show', 193 | key, 194 | password: 'badcat' 195 | } 196 | }; 197 | }); 198 | 199 | 200 | lab.afterEach(() => { 201 | 202 | Mailer.sendEmail = Mailer_sendEmail; 203 | User.findOne = User_findOne; 204 | }); 205 | 206 | 207 | lab.test('it returns HTTP 400 when the user query misses', async () => { 208 | 209 | User.findOne = () => undefined; 210 | 211 | const response = await server.inject(request); 212 | 213 | Code.expect(response.statusCode).to.equal(400); 214 | Code.expect(response.result.message).to.match(/invalid email or key/i); 215 | }); 216 | 217 | 218 | lab.test('it returns HTTP 400 when the key match misses', async () => { 219 | 220 | request.payload.key += 'poison'; 221 | 222 | const response = await server.inject(request); 223 | 224 | Code.expect(response.statusCode).to.equal(400); 225 | Code.expect(response.result.message).to.match(/invalid email or key/i); 226 | }); 227 | 228 | 229 | lab.test('it returns HTTP 200 when all is well', async () => { 230 | 231 | const response = await server.inject(request); 232 | 233 | Code.expect(response.statusCode).to.equal(200); 234 | Code.expect(response.result.message).to.match(/success/i); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /test/server/api/logout.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AuthPlugin = require('../../../server/auth'); 4 | const Code = require('@hapi/code'); 5 | const Fixtures = require('../fixtures'); 6 | const Hapi = require('@hapi/hapi'); 7 | const Lab = require('@hapi/lab'); 8 | const Logout = require('../../../server/api/logout'); 9 | const Manifest = require('../../../manifest'); 10 | const Session = require('../../../server/models/session'); 11 | const User = require('../../../server/models/user'); 12 | 13 | 14 | const lab = exports.lab = Lab.script(); 15 | let server; 16 | 17 | 18 | lab.before(async () => { 19 | 20 | server = Hapi.Server(); 21 | 22 | const plugins = Manifest.get('/register/plugins') 23 | .filter((entry) => Logout.dependencies.includes(entry.plugin)) 24 | .map((entry) => { 25 | 26 | entry.plugin = require(entry.plugin); 27 | 28 | return entry; 29 | }); 30 | 31 | plugins.push(AuthPlugin); 32 | plugins.push(Logout); 33 | 34 | await server.register(plugins); 35 | await server.start(); 36 | await Fixtures.Db.removeAllData(); 37 | }); 38 | 39 | 40 | lab.after(async () => { 41 | 42 | await Fixtures.Db.removeAllData(); 43 | await server.stop(); 44 | }); 45 | 46 | 47 | lab.experiment('DELETE /api/logout', () => { 48 | 49 | let request; 50 | 51 | 52 | lab.beforeEach(() => { 53 | 54 | request = { 55 | method: 'DELETE', 56 | url: '/api/logout' 57 | }; 58 | }); 59 | 60 | 61 | lab.test('it returns HTTP 200 when credentials are missing', async () => { 62 | 63 | const response = await server.inject(request); 64 | 65 | Code.expect(response.statusCode).to.equal(200); 66 | Code.expect(response.result.message).to.match(/success/i); 67 | }); 68 | 69 | 70 | lab.test('it returns HTTP 200 when credentials are present', async () => { 71 | 72 | const user = await User.create('ren', 'baddog', 'ren@stimpy.show'); 73 | const session = await Session.create('ren', 'baddog', 'ren@stimpy.show'); 74 | 75 | request.auth = { 76 | strategy: 'basic', 77 | credentials: { 78 | roles: [], 79 | session, 80 | user 81 | } 82 | }; 83 | 84 | const response = await server.inject(request); 85 | 86 | Code.expect(response.statusCode).to.equal(200); 87 | Code.expect(response.result.message).to.match(/success/i); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/server/api/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Hapi = require('@hapi/hapi'); 5 | const Lab = require('@hapi/lab'); 6 | const Main = require('../../../server/api/main'); 7 | 8 | 9 | const lab = exports.lab = Lab.script(); 10 | let server; 11 | 12 | 13 | lab.before(async () => { 14 | 15 | server = Hapi.Server(); 16 | 17 | await server.register(Main); 18 | await server.start(); 19 | }); 20 | 21 | 22 | lab.after(async () => { 23 | 24 | await server.stop(); 25 | }); 26 | 27 | 28 | lab.experiment('GET /api', () => { 29 | 30 | let request; 31 | 32 | 33 | lab.beforeEach(() => { 34 | 35 | request = { 36 | method: 'GET', 37 | url: '/api' 38 | }; 39 | }); 40 | 41 | 42 | lab.test('it returns HTTP 200 when all is good', async () => { 43 | 44 | const response = await server.inject(request); 45 | 46 | Code.expect(response.statusCode).to.equal(200); 47 | Code.expect(response.result.message).to.match(/welcome/i); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/server/api/sessions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Auth = require('../../../server/auth'); 4 | const Code = require('@hapi/code'); 5 | const Fixtures = require('../fixtures'); 6 | const Hapi = require('@hapi/hapi'); 7 | const Lab = require('@hapi/lab'); 8 | const Manifest = require('../../../manifest'); 9 | const Session = require('../../../server/models/session'); 10 | const Sessions = require('../../../server/api/sessions'); 11 | const User = require('../../../server/models/user'); 12 | 13 | 14 | const lab = exports.lab = Lab.script(); 15 | let server; 16 | let rootCredentials; 17 | let rootSession; 18 | 19 | 20 | lab.before(async () => { 21 | 22 | server = Hapi.Server(); 23 | 24 | const plugins = Manifest.get('/register/plugins') 25 | .filter((entry) => Sessions.dependencies.includes(entry.plugin)) 26 | .map((entry) => { 27 | 28 | entry.plugin = require(entry.plugin); 29 | 30 | return entry; 31 | }); 32 | 33 | plugins.push(Auth); 34 | plugins.push(Sessions); 35 | 36 | await server.register(plugins); 37 | await server.start(); 38 | await Fixtures.Db.removeAllData(); 39 | 40 | rootCredentials = await Fixtures.Creds.createRootAdminUser(); 41 | 42 | rootSession = rootCredentials.session; 43 | }); 44 | 45 | 46 | lab.after(async () => { 47 | 48 | await Fixtures.Db.removeAllData(); 49 | await server.stop(); 50 | }); 51 | 52 | 53 | lab.experiment('GET /api/sessions', () => { 54 | 55 | let request; 56 | 57 | 58 | lab.beforeEach(() => { 59 | 60 | request = { 61 | method: 'GET', 62 | url: '/api/sessions', 63 | auth: { 64 | strategy: 'basic', 65 | credentials: rootCredentials 66 | } 67 | }; 68 | }); 69 | 70 | 71 | lab.test('it returns HTTP 200 when all is well', async () => { 72 | 73 | const response = await server.inject(request); 74 | 75 | Code.expect(response.statusCode).to.equal(200); 76 | Code.expect(response.result.data).to.be.an.array(); 77 | Code.expect(response.result.pages).to.be.an.object(); 78 | Code.expect(response.result.items).to.be.an.object(); 79 | }); 80 | }); 81 | 82 | 83 | lab.experiment('GET /api/sessions/{id}', () => { 84 | 85 | let request; 86 | 87 | 88 | lab.beforeEach(() => { 89 | 90 | request = { 91 | method: 'GET', 92 | url: '/api/sessions/{id}', 93 | auth: { 94 | strategy: 'basic', 95 | credentials: rootCredentials 96 | } 97 | }; 98 | }); 99 | 100 | 101 | lab.test('it returns HTTP 404 when `Session.findById` misses', async () => { 102 | 103 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 104 | 105 | const response = await server.inject(request); 106 | 107 | Code.expect(response.statusCode).to.equal(404); 108 | Code.expect(response.result.message).to.match(/not found/i); 109 | }); 110 | 111 | 112 | lab.test('it returns HTTP 200 when all is well', async () => { 113 | 114 | const user = await User.create('darcie', 'uplate', 'darcie.late.night@github.com'); 115 | const session = await Session.create(`${user._id}`, '127.0.0.1', 'Lab'); 116 | 117 | request.url = request.url.replace(/{id}/, session._id); 118 | 119 | const response = await server.inject(request); 120 | 121 | Code.expect(response.statusCode).to.equal(200); 122 | Code.expect(response.result).to.be.an.object(); 123 | Code.expect(response.result.userId).to.equal(`${user._id}`); 124 | }); 125 | }); 126 | 127 | 128 | lab.experiment('DELETE /api/sessions/{id}', () => { 129 | 130 | let request; 131 | 132 | 133 | lab.beforeEach(() => { 134 | 135 | request = { 136 | method: 'DELETE', 137 | url: '/api/sessions/{id}', 138 | auth: { 139 | strategy: 'basic', 140 | credentials: rootCredentials 141 | } 142 | }; 143 | }); 144 | 145 | 146 | lab.test('it returns HTTP 404 when `Session.findByIdAndDelete` misses', async () => { 147 | 148 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 149 | 150 | const response = await server.inject(request); 151 | 152 | Code.expect(response.statusCode).to.equal(404); 153 | Code.expect(response.result.message).to.match(/not found/i); 154 | }); 155 | 156 | 157 | lab.test('it returns HTTP 200 when all is well', async () => { 158 | 159 | const user = await User.create('aldon', 'thirsty', 'aldon.late.night@github.com'); 160 | const session = await Session.create(`${user._id}`, '127.0.0.1', 'Lab'); 161 | 162 | request.url = request.url.replace(/{id}/, session._id); 163 | 164 | const response = await server.inject(request); 165 | 166 | Code.expect(response.statusCode).to.equal(200); 167 | Code.expect(response.result).to.be.an.object(); 168 | Code.expect(response.result.message).to.match(/success/i); 169 | }); 170 | }); 171 | 172 | 173 | lab.experiment('GET /api/sessions/my', () => { 174 | 175 | let request; 176 | 177 | 178 | lab.beforeEach(() => { 179 | 180 | request = { 181 | method: 'GET', 182 | url: '/api/sessions/my', 183 | auth: { 184 | strategy: 'basic', 185 | credentials: rootCredentials 186 | } 187 | }; 188 | }); 189 | 190 | lab.test('it returns HTTP 200 when all is well', async () => { 191 | 192 | const response = await server.inject(request); 193 | 194 | Code.expect(response.statusCode).to.equal(200); 195 | Code.expect(response.result).to.be.an.array(); 196 | Code.expect(response.result.length).to.equal(1); 197 | }); 198 | }); 199 | 200 | 201 | lab.experiment('DELETE /api/sessions/my/{id}', () => { 202 | 203 | let request; 204 | 205 | 206 | lab.beforeEach(() => { 207 | 208 | request = { 209 | method: 'DELETE', 210 | url: '/api/sessions/my/{id}', 211 | auth: { 212 | strategy: 'basic', 213 | credentials: rootCredentials 214 | } 215 | }; 216 | }); 217 | 218 | 219 | lab.test('it returns HTTP 400 when tryint to destroy current session', async () => { 220 | 221 | request.url = request.url.replace(/{id}/, rootSession._id); 222 | 223 | const response = await server.inject(request); 224 | 225 | Code.expect(response.statusCode).to.equal(400); 226 | Code.expect(response.result.message).to.match(/current session/i); 227 | }); 228 | 229 | 230 | lab.test('it returns HTTP 200 when all is well', async () => { 231 | 232 | const session = await Session.create(rootSession.userId, '127.0.0.2', 'Lab'); 233 | 234 | request.url = request.url.replace(/{id}/, session._id); 235 | 236 | const response = await server.inject(request); 237 | 238 | Code.expect(response.statusCode).to.equal(200); 239 | Code.expect(response.result).to.be.an.object(); 240 | Code.expect(response.result.message).to.match(/success/i); 241 | }); 242 | }); 243 | -------------------------------------------------------------------------------- /test/server/api/signup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Fixtures = require('../fixtures'); 5 | const Hapi = require('@hapi/hapi'); 6 | const Lab = require('@hapi/lab'); 7 | const Mailer = require('../../../server/mailer'); 8 | const Manifest = require('../../../manifest'); 9 | const Signup = require('../../../server/api/signup'); 10 | const User = require('../../../server/models/user'); 11 | 12 | 13 | const lab = exports.lab = Lab.script(); 14 | let server; 15 | 16 | 17 | lab.before(async () => { 18 | 19 | server = Hapi.Server(); 20 | 21 | const plugins = Manifest.get('/register/plugins') 22 | .filter((entry) => Signup.dependencies.includes(entry.plugin)) 23 | .map((entry) => { 24 | 25 | entry.plugin = require(entry.plugin); 26 | 27 | return entry; 28 | }); 29 | 30 | plugins.push(Signup); 31 | 32 | await server.register(plugins); 33 | await server.start(); 34 | await Fixtures.Db.removeAllData(); 35 | }); 36 | 37 | 38 | lab.after(async () => { 39 | 40 | await Fixtures.Db.removeAllData(); 41 | await server.stop(); 42 | }); 43 | 44 | 45 | lab.experiment('DELETE /api/signup', () => { 46 | 47 | const Mailer_sendEmail = Mailer.sendEmail; 48 | let request; 49 | 50 | 51 | lab.beforeEach(() => { 52 | 53 | request = { 54 | method: 'POST', 55 | url: '/api/signup' 56 | }; 57 | }); 58 | 59 | 60 | lab.afterEach(() => { 61 | 62 | Mailer.sendEmail = Mailer_sendEmail; 63 | }); 64 | 65 | 66 | lab.test('it returns HTTP 409 when the username is already in use', async () => { 67 | 68 | await User.create('ren', 'baddog', 'ren@stimpy.show'); 69 | 70 | request.payload = { 71 | name: 'Unoriginal Bill', 72 | email: 'bill@hotmail.gov', 73 | username: 'ren', 74 | password: 'pass123' 75 | }; 76 | 77 | const response = await server.inject(request); 78 | 79 | Code.expect(response.statusCode).to.equal(409); 80 | Code.expect(response.result.message).to.match(/username already in use/i); 81 | }); 82 | 83 | 84 | lab.test('it returns HTTP 409 when the email is already in use', async () => { 85 | 86 | request.payload = { 87 | name: 'Unoriginal Bill', 88 | email: 'ren@stimpy.show', 89 | username: 'bill', 90 | password: 'pass123' 91 | }; 92 | 93 | const response = await server.inject(request); 94 | 95 | Code.expect(response.statusCode).to.equal(409); 96 | Code.expect(response.result.message).to.match(/email already in use/i); 97 | }); 98 | 99 | 100 | lab.test('it returns HTTP 200 when all is well', async () => { 101 | 102 | Mailer.sendEmail = () => undefined; 103 | 104 | request.payload = { 105 | name: 'Captain Original', 106 | email: 'captain@stimpy.show', 107 | username: 'captain', 108 | password: 'allaboard' 109 | }; 110 | 111 | const response = await server.inject(request); 112 | 113 | Code.expect(response.statusCode).to.equal(200); 114 | Code.expect(response.result).to.be.an.object(); 115 | Code.expect(response.result.user).to.be.an.object(); 116 | Code.expect(response.result.session).to.be.an.object(); 117 | Code.expect(response.result.authHeader).to.be.a.string(); 118 | }); 119 | 120 | 121 | lab.test('it returns HTTP 200 when all is well and logs any mailer errors', async () => { 122 | 123 | Mailer.sendEmail = function () { 124 | 125 | throw new Error('Failed to send mail.'); 126 | }; 127 | 128 | const mailerLogEvent = server.events.once({ 129 | name: 'request', 130 | filter: ['error', 'mailer'] 131 | }); 132 | 133 | request.payload = { 134 | name: 'Assistant Manager', 135 | email: 'manager@stimpy.show', 136 | username: 'assistant', 137 | password: 'totheregionalmanager' 138 | }; 139 | 140 | const response = await server.inject(request); 141 | const [, event] = await mailerLogEvent; 142 | 143 | Code.expect(event.error.message).to.match(/failed to send mail/i); 144 | 145 | Code.expect(response.statusCode).to.equal(200); 146 | Code.expect(response.result).to.be.an.object(); 147 | Code.expect(response.result.user).to.be.an.object(); 148 | Code.expect(response.result.session).to.be.an.object(); 149 | Code.expect(response.result.authHeader).to.be.a.string(); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/server/api/statuses.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Auth = require('../../../server/auth'); 4 | const Code = require('@hapi/code'); 5 | const Fixtures = require('../fixtures'); 6 | const Hapi = require('@hapi/hapi'); 7 | const Lab = require('@hapi/lab'); 8 | const Manifest = require('../../../manifest'); 9 | const Status = require('../../../server/models/status'); 10 | const Statuses = require('../../../server/api/statuses'); 11 | 12 | 13 | const lab = exports.lab = Lab.script(); 14 | let server; 15 | let rootCredentials; 16 | 17 | 18 | lab.before(async () => { 19 | 20 | server = Hapi.Server(); 21 | 22 | const plugins = Manifest.get('/register/plugins') 23 | .filter((entry) => Statuses.dependencies.includes(entry.plugin)) 24 | .map((entry) => { 25 | 26 | entry.plugin = require(entry.plugin); 27 | 28 | return entry; 29 | }); 30 | 31 | plugins.push(Auth); 32 | plugins.push(Statuses); 33 | 34 | await server.register(plugins); 35 | await server.start(); 36 | await Fixtures.Db.removeAllData(); 37 | 38 | rootCredentials = await Fixtures.Creds.createRootAdminUser(); 39 | }); 40 | 41 | 42 | lab.after(async () => { 43 | 44 | await Fixtures.Db.removeAllData(); 45 | await server.stop(); 46 | }); 47 | 48 | 49 | lab.experiment('GET /api/statuses', () => { 50 | 51 | let request; 52 | 53 | 54 | lab.beforeEach(() => { 55 | 56 | request = { 57 | method: 'GET', 58 | url: '/api/statuses', 59 | auth: { 60 | strategy: 'basic', 61 | credentials: rootCredentials 62 | } 63 | }; 64 | }); 65 | 66 | 67 | lab.test('it returns HTTP 200 when all is well', async () => { 68 | 69 | const response = await server.inject(request); 70 | 71 | Code.expect(response.statusCode).to.equal(200); 72 | Code.expect(response.result.data).to.be.an.array(); 73 | Code.expect(response.result.pages).to.be.an.object(); 74 | Code.expect(response.result.items).to.be.an.object(); 75 | }); 76 | }); 77 | 78 | 79 | lab.experiment('POST /api/statuses', () => { 80 | 81 | let request; 82 | 83 | 84 | lab.beforeEach(() => { 85 | 86 | request = { 87 | method: 'POST', 88 | url: '/api/statuses', 89 | auth: { 90 | strategy: 'basic', 91 | credentials: rootCredentials 92 | } 93 | }; 94 | }); 95 | 96 | 97 | lab.test('it returns HTTP 200 when all is well', async () => { 98 | 99 | request.payload = { 100 | name: 'Happy', 101 | pivot: 'Account' 102 | }; 103 | 104 | const response = await server.inject(request); 105 | 106 | Code.expect(response.statusCode).to.equal(200); 107 | Code.expect(response.result).to.be.and.object(); 108 | Code.expect(response.result.name).to.be.equal('Happy'); 109 | Code.expect(response.result.pivot).to.be.equal('Account'); 110 | }); 111 | }); 112 | 113 | 114 | lab.experiment('GET /api/statuses/{id}', () => { 115 | 116 | let request; 117 | 118 | 119 | lab.beforeEach(() => { 120 | 121 | request = { 122 | method: 'GET', 123 | url: '/api/statuses/{id}', 124 | auth: { 125 | strategy: 'basic', 126 | credentials: rootCredentials 127 | } 128 | }; 129 | }); 130 | 131 | 132 | lab.test('it returns HTTP 404 when `Status.findById` misses', async () => { 133 | 134 | request.url = request.url.replace(/{id}/, 'missing-status'); 135 | 136 | const response = await server.inject(request); 137 | 138 | Code.expect(response.statusCode).to.equal(404); 139 | Code.expect(response.result.message).to.match(/not found/i); 140 | }); 141 | 142 | 143 | lab.test('it returns HTTP 200 when all is well', async () => { 144 | 145 | const status = await Status.create('Account', 'Sad'); 146 | 147 | request.url = request.url.replace(/{id}/, status._id); 148 | 149 | const response = await server.inject(request); 150 | 151 | Code.expect(response.statusCode).to.equal(200); 152 | Code.expect(response.result).to.be.an.object(); 153 | Code.expect(response.result.name).to.equal('Sad'); 154 | Code.expect(response.result.pivot).to.equal('Account'); 155 | }); 156 | }); 157 | 158 | 159 | lab.experiment('PUT /api/statuses/{id}', () => { 160 | 161 | let request; 162 | 163 | 164 | lab.beforeEach(() => { 165 | 166 | request = { 167 | method: 'PUT', 168 | url: '/api/statuses/{id}', 169 | auth: { 170 | strategy: 'basic', 171 | credentials: rootCredentials 172 | } 173 | }; 174 | }); 175 | 176 | 177 | lab.test('it returns HTTP 404 when `Status.findByIdAndUpdate` misses', async () => { 178 | 179 | request.url = request.url.replace(/{id}/, 'account-emojiface'); 180 | request.payload = { 181 | name: 'Wrecking Crew' 182 | }; 183 | 184 | const response = await server.inject(request); 185 | 186 | Code.expect(response.statusCode).to.equal(404); 187 | Code.expect(response.result.message).to.match(/not found/i); 188 | }); 189 | 190 | 191 | lab.test('it returns HTTP 200 when all is well', async () => { 192 | 193 | const status = await Status.create('Admin', 'Cold'); 194 | 195 | request.url = request.url.replace(/{id}/, status._id); 196 | request.payload = { 197 | name: 'Hot' 198 | }; 199 | 200 | const response = await server.inject(request); 201 | 202 | Code.expect(response.statusCode).to.equal(200); 203 | Code.expect(response.result).to.be.an.object(); 204 | Code.expect(response.result.name).to.equal('Hot'); 205 | Code.expect(response.result.pivot).to.equal('Admin'); 206 | }); 207 | }); 208 | 209 | 210 | lab.experiment('DELETE /api/statuses/{id}', () => { 211 | 212 | let request; 213 | 214 | 215 | lab.beforeEach(() => { 216 | 217 | request = { 218 | method: 'DELETE', 219 | url: '/api/statuses/{id}', 220 | auth: { 221 | strategy: 'basic', 222 | credentials: rootCredentials 223 | } 224 | }; 225 | }); 226 | 227 | 228 | lab.test('it returns HTTP 404 when `Status.findByIdAndDelete` misses', async () => { 229 | 230 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 231 | 232 | const response = await server.inject(request); 233 | 234 | Code.expect(response.statusCode).to.equal(404); 235 | Code.expect(response.result.message).to.match(/not found/i); 236 | }); 237 | 238 | 239 | lab.test('it returns HTTP 200 when all is well', async () => { 240 | 241 | const status = await Status.create('Account', 'Above'); 242 | 243 | request.url = request.url.replace(/{id}/, status._id); 244 | 245 | const response = await server.inject(request); 246 | 247 | Code.expect(response.statusCode).to.equal(200); 248 | Code.expect(response.result).to.be.an.object(); 249 | Code.expect(response.result.message).to.match(/success/i); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /test/server/api/users.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Auth = require('../../../server/auth'); 4 | const Code = require('@hapi/code'); 5 | const Fixtures = require('../fixtures'); 6 | const Hapi = require('@hapi/hapi'); 7 | const Lab = require('@hapi/lab'); 8 | const Manifest = require('../../../manifest'); 9 | const User = require('../../../server/models/user'); 10 | const Users = require('../../../server/api/users'); 11 | 12 | 13 | const lab = exports.lab = Lab.script(); 14 | let server; 15 | let rootCredentials; 16 | let accountCredentials; 17 | 18 | 19 | lab.before(async () => { 20 | 21 | server = Hapi.Server(); 22 | 23 | const plugins = Manifest.get('/register/plugins') 24 | .filter((entry) => Users.dependencies.includes(entry.plugin)) 25 | .map((entry) => { 26 | 27 | entry.plugin = require(entry.plugin); 28 | 29 | return entry; 30 | }); 31 | 32 | plugins.push(Auth); 33 | plugins.push(Users); 34 | 35 | await server.register(plugins); 36 | await server.start(); 37 | await Fixtures.Db.removeAllData(); 38 | 39 | [rootCredentials, accountCredentials] = await Promise.all([ 40 | Fixtures.Creds.createRootAdminUser(), 41 | Fixtures.Creds.createAccountUser('Stimpson Cat', 'stimpy', 'goodcat', 'stimpy@ren.show'), 42 | Fixtures.Creds.createAdminUser('Ren Hoek', 'ren', 'baddog', 'ren@stimpy.show') 43 | ]); 44 | }); 45 | 46 | 47 | lab.after(async () => { 48 | 49 | await Fixtures.Db.removeAllData(); 50 | await server.stop(); 51 | }); 52 | 53 | 54 | lab.experiment('GET /api/users', () => { 55 | 56 | let request; 57 | 58 | 59 | lab.beforeEach(() => { 60 | 61 | request = { 62 | method: 'GET', 63 | url: '/api/users', 64 | auth: { 65 | strategy: 'basic', 66 | credentials: rootCredentials 67 | } 68 | }; 69 | }); 70 | 71 | 72 | lab.test('it returns HTTP 200 when all is well', async () => { 73 | 74 | const response = await server.inject(request); 75 | 76 | Code.expect(response.statusCode).to.equal(200); 77 | Code.expect(response.result.data).to.be.an.array(); 78 | Code.expect(response.result.pages).to.be.an.object(); 79 | Code.expect(response.result.items).to.be.an.object(); 80 | }); 81 | }); 82 | 83 | 84 | lab.experiment('POST /api/users', () => { 85 | 86 | let request; 87 | 88 | 89 | lab.beforeEach(() => { 90 | 91 | request = { 92 | method: 'POST', 93 | url: '/api/users', 94 | auth: { 95 | strategy: 'basic', 96 | credentials: rootCredentials 97 | } 98 | }; 99 | }); 100 | 101 | 102 | lab.test('it returns HTTP 409 when the username is already in use', async () => { 103 | 104 | request.payload = { 105 | email: 'steve@stimpy.show', 106 | password: 'lovely', 107 | username: 'ren' 108 | }; 109 | 110 | const response = await server.inject(request); 111 | 112 | Code.expect(response.statusCode).to.equal(409); 113 | Code.expect(response.result.message).to.match(/username already in use/i); 114 | }); 115 | 116 | 117 | lab.test('it returns HTTP 409 when the email is already in use', async () => { 118 | 119 | request.payload = { 120 | email: 'ren@stimpy.show', 121 | password: 'lovely', 122 | username: 'steveplease' 123 | }; 124 | 125 | const response = await server.inject(request); 126 | 127 | Code.expect(response.statusCode).to.equal(409); 128 | Code.expect(response.result.message).to.match(/email already in use/i); 129 | }); 130 | 131 | 132 | lab.test('it returns HTTP 200 when all is well', async () => { 133 | 134 | request.payload = { 135 | email: 'steve@stimpy.show', 136 | password: 'lovely', 137 | username: 'steveplease' 138 | }; 139 | 140 | const response = await server.inject(request); 141 | 142 | Code.expect(response.statusCode).to.equal(200); 143 | Code.expect(response.result).to.be.and.object(); 144 | Code.expect(response.result.username).to.equal('steveplease'); 145 | }); 146 | }); 147 | 148 | 149 | lab.experiment('GET /api/users/{id}', () => { 150 | 151 | let request; 152 | 153 | 154 | lab.beforeEach(() => { 155 | 156 | request = { 157 | method: 'GET', 158 | url: '/api/users/{id}', 159 | auth: { 160 | strategy: 'basic', 161 | credentials: rootCredentials 162 | } 163 | }; 164 | }); 165 | 166 | 167 | lab.test('it returns HTTP 404 when `User.findById` misses', async () => { 168 | 169 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 170 | 171 | const response = await server.inject(request); 172 | 173 | Code.expect(response.statusCode).to.equal(404); 174 | Code.expect(response.result.message).to.match(/not found/i); 175 | }); 176 | 177 | 178 | lab.test('it returns HTTP 200 when all is well', async () => { 179 | 180 | const user = await User.create('mrcolbert', 'colbert123', 'mr.colbert.baz@github.com'); 181 | 182 | request.url = request.url.replace(/{id}/, user._id); 183 | 184 | const response = await server.inject(request); 185 | 186 | Code.expect(response.statusCode).to.equal(200); 187 | Code.expect(response.result).to.be.an.object(); 188 | Code.expect(response.result.username).to.equal('mrcolbert'); 189 | }); 190 | }); 191 | 192 | 193 | lab.experiment('PUT /api/users/{id}', () => { 194 | 195 | let request; 196 | 197 | 198 | lab.beforeEach(() => { 199 | 200 | request = { 201 | method: 'PUT', 202 | url: '/api/users/{id}', 203 | auth: { 204 | strategy: 'basic', 205 | credentials: rootCredentials 206 | } 207 | }; 208 | }); 209 | 210 | 211 | lab.test('it returns HTTP 409 when the username is already in use', async () => { 212 | 213 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 214 | request.payload = { 215 | isActive: true, 216 | email: 'ren@stimpy.show', 217 | username: 'ren' 218 | }; 219 | 220 | const response = await server.inject(request); 221 | 222 | Code.expect(response.statusCode).to.equal(409); 223 | Code.expect(response.result.message).to.match(/username already in use/i); 224 | }); 225 | 226 | 227 | lab.test('it returns HTTP 409 when the email is already in use', async () => { 228 | 229 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 230 | request.payload = { 231 | isActive: true, 232 | email: 'ren@stimpy.show', 233 | username: 'pleasesteve' 234 | }; 235 | 236 | const response = await server.inject(request); 237 | 238 | Code.expect(response.statusCode).to.equal(409); 239 | Code.expect(response.result.message).to.match(/email already in use/i); 240 | }); 241 | 242 | 243 | lab.test('it returns HTTP 404 when `User.findByIdAndUpdate` misses', async () => { 244 | 245 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 246 | request.payload = { 247 | isActive: true, 248 | email: 'pleasesteve@stimpy.show', 249 | username: 'pleasesteve' 250 | }; 251 | 252 | const response = await server.inject(request); 253 | 254 | Code.expect(response.statusCode).to.equal(404); 255 | Code.expect(response.result.message).to.match(/not found/i); 256 | }); 257 | 258 | 259 | lab.test('it returns HTTP 200 when all is well', async () => { 260 | 261 | const user = await User.create('finally', 'gue55', 'finally@made.it'); 262 | 263 | request.url = request.url.replace(/{id}/, user._id); 264 | request.payload = { 265 | isActive: true, 266 | email: 'finally@made.io', 267 | username: 'yllanif' 268 | }; 269 | 270 | const response = await server.inject(request); 271 | 272 | Code.expect(response.statusCode).to.equal(200); 273 | Code.expect(response.result).to.be.an.object(); 274 | Code.expect(response.result.username).to.equal('yllanif'); 275 | Code.expect(response.result.email).to.equal('finally@made.io'); 276 | }); 277 | }); 278 | 279 | 280 | lab.experiment('DELETE /api/users/{id}', () => { 281 | 282 | let request; 283 | 284 | 285 | lab.beforeEach(() => { 286 | 287 | request = { 288 | method: 'DELETE', 289 | url: '/api/users/{id}', 290 | auth: { 291 | strategy: 'basic', 292 | credentials: rootCredentials 293 | } 294 | }; 295 | }); 296 | 297 | 298 | lab.test('it returns HTTP 404 when `User.findByIdAndDelete` misses', async () => { 299 | 300 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 301 | 302 | const response = await server.inject(request); 303 | 304 | Code.expect(response.statusCode).to.equal(404); 305 | Code.expect(response.result.message).to.match(/not found/i); 306 | }); 307 | 308 | 309 | lab.test('it returns HTTP 200 when all is well', async () => { 310 | 311 | const user = await User.create('deleteme', '0000', 'delete.me.please@github.com'); 312 | 313 | request.url = request.url.replace(/{id}/, user._id); 314 | 315 | const response = await server.inject(request); 316 | 317 | Code.expect(response.statusCode).to.equal(200); 318 | Code.expect(response.result).to.be.an.object(); 319 | Code.expect(response.result.message).to.match(/success/i); 320 | }); 321 | }); 322 | 323 | 324 | lab.experiment('PUT /api/users/{id}/password', () => { 325 | 326 | let request; 327 | 328 | 329 | lab.beforeEach(() => { 330 | 331 | request = { 332 | method: 'PUT', 333 | url: '/api/users/{id}/password', 334 | auth: { 335 | strategy: 'basic', 336 | credentials: rootCredentials 337 | } 338 | }; 339 | }); 340 | 341 | 342 | lab.test('it returns HTTP 404 when `User.findByIdAndUpdate` misses', async () => { 343 | 344 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 345 | request.payload = { 346 | password: '53cur3p455' 347 | }; 348 | 349 | const response = await server.inject(request); 350 | 351 | Code.expect(response.statusCode).to.equal(404); 352 | Code.expect(response.result.message).to.match(/not found/i); 353 | }); 354 | 355 | 356 | lab.test('it returns HTTP 200 when all is well', async () => { 357 | 358 | const user = await User.create('finally', 'gue55', 'finally@made.it'); 359 | 360 | request.url = request.url.replace(/{id}/, user._id); 361 | request.payload = { 362 | password: '53cur3p455' 363 | }; 364 | 365 | const response = await server.inject(request); 366 | 367 | Code.expect(response.statusCode).to.equal(200); 368 | Code.expect(response.result).to.be.an.object(); 369 | Code.expect(response.result.username).to.equal('finally'); 370 | }); 371 | }); 372 | 373 | 374 | lab.experiment('GET /api/users/my', () => { 375 | 376 | let request; 377 | 378 | 379 | lab.beforeEach(() => { 380 | 381 | request = { 382 | method: 'GET', 383 | url: '/api/users/my', 384 | auth: { 385 | strategy: 'basic', 386 | credentials: accountCredentials 387 | } 388 | }; 389 | }); 390 | 391 | 392 | lab.test('it returns HTTP 200 when all is well', async () => { 393 | 394 | const response = await server.inject(request); 395 | 396 | Code.expect(response.statusCode).to.equal(200); 397 | Code.expect(response.result).to.be.an.object(); 398 | Code.expect(response.result.username).to.equal('stimpy'); 399 | }); 400 | }); 401 | 402 | 403 | lab.experiment('PUT /api/users/my', () => { 404 | 405 | let request; 406 | 407 | 408 | lab.beforeEach(() => { 409 | 410 | request = { 411 | method: 'PUT', 412 | url: '/api/users/my', 413 | auth: { 414 | strategy: 'basic', 415 | credentials: accountCredentials 416 | } 417 | }; 418 | }); 419 | 420 | 421 | lab.test('it returns HTTP 409 when the username is already in use', async () => { 422 | 423 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 424 | request.payload = { 425 | email: 'ren@stimpy.show', 426 | username: 'ren' 427 | }; 428 | 429 | const response = await server.inject(request); 430 | 431 | Code.expect(response.statusCode).to.equal(409); 432 | Code.expect(response.result.message).to.match(/username already in use/i); 433 | }); 434 | 435 | 436 | lab.test('it returns HTTP 409 when the email is already in use', async () => { 437 | 438 | request.url = request.url.replace(/{id}/, '555555555555555555555555'); 439 | request.payload = { 440 | email: 'ren@stimpy.show', 441 | username: 'pleasesteve' 442 | }; 443 | 444 | const response = await server.inject(request); 445 | 446 | Code.expect(response.statusCode).to.equal(409); 447 | Code.expect(response.result.message).to.match(/email already in use/i); 448 | }); 449 | 450 | 451 | lab.test('it returns HTTP 200 when all is well', async () => { 452 | 453 | request.payload = { 454 | email: 'stimpy@gmail.gov', 455 | username: 'stimpson' 456 | }; 457 | 458 | const response = await server.inject(request); 459 | 460 | Code.expect(response.statusCode).to.equal(200); 461 | Code.expect(response.result).to.be.an.object(); 462 | Code.expect(response.result.username).to.equal('stimpson'); 463 | Code.expect(response.result.email).to.equal('stimpy@gmail.gov'); 464 | }); 465 | }); 466 | 467 | 468 | lab.experiment('PUT /api/users/my/password', () => { 469 | 470 | let request; 471 | 472 | 473 | lab.beforeEach(() => { 474 | 475 | request = { 476 | method: 'PUT', 477 | url: '/api/users/my/password', 478 | auth: { 479 | strategy: 'basic', 480 | credentials: accountCredentials 481 | } 482 | }; 483 | }); 484 | 485 | 486 | lab.test('it returns HTTP 200 when all is well', async () => { 487 | 488 | request.payload = { 489 | password: '53cur3p455' 490 | }; 491 | 492 | const response = await server.inject(request); 493 | 494 | Code.expect(response.statusCode).to.equal(200); 495 | Code.expect(response.result).to.be.an.object(); 496 | Code.expect(response.result.username).to.equal('stimpson'); 497 | }); 498 | }); 499 | -------------------------------------------------------------------------------- /test/server/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Auth = require('../../server/auth'); 4 | const Code = require('@hapi/code'); 5 | const Fixtures = require('./fixtures'); 6 | const Hapi = require('@hapi/hapi'); 7 | const Lab = require('@hapi/lab'); 8 | const Manifest = require('../../manifest'); 9 | const Session = require('../../server/models/session'); 10 | const User = require('../../server/models/user'); 11 | 12 | 13 | const lab = exports.lab = Lab.script(); 14 | let server; 15 | 16 | 17 | lab.before(async () => { 18 | 19 | server = Hapi.Server(); 20 | 21 | const plugins = Manifest.get('/register/plugins') 22 | .filter((entry) => Auth.dependencies.includes(entry.plugin)) 23 | .map((entry) => { 24 | 25 | entry.plugin = require(entry.plugin); 26 | 27 | return entry; 28 | }); 29 | 30 | plugins.push(Auth); 31 | 32 | await server.register(plugins); 33 | await server.start(); 34 | await Fixtures.Db.removeAllData(); 35 | 36 | server.route({ 37 | method: 'GET', 38 | path: '/', 39 | options: { 40 | auth: false 41 | }, 42 | handler: async function (request, h) { 43 | 44 | try { 45 | await request.server.auth.test('simple', request); 46 | 47 | return { isValid: true }; 48 | } 49 | catch (err) { 50 | return { isValid: false }; 51 | } 52 | } 53 | }); 54 | }); 55 | 56 | 57 | lab.after(async () => { 58 | 59 | await Fixtures.Db.removeAllData(); 60 | await server.stop(); 61 | }); 62 | 63 | 64 | lab.experiment('Simple Auth Strategy', () => { 65 | 66 | lab.test('it returns as invalid without authentication provided', async () => { 67 | 68 | const request = { 69 | method: 'GET', 70 | url: '/' 71 | }; 72 | const response = await server.inject(request); 73 | 74 | Code.expect(response.statusCode).to.equal(200); 75 | Code.expect(response.result.isValid).to.equal(false); 76 | }); 77 | 78 | 79 | lab.test('it returns as invalid when the session query misses', async () => { 80 | 81 | const sessionId = '000000000000000000000001'; 82 | const sessionKey = '01010101-0101-0101-0101-010101010101'; 83 | const request = { 84 | method: 'GET', 85 | url: '/', 86 | headers: { 87 | authorization: Fixtures.Creds.authHeader(sessionId, sessionKey) 88 | } 89 | }; 90 | 91 | const response = await server.inject(request); 92 | 93 | Code.expect(response.statusCode).to.equal(200); 94 | Code.expect(response.result.isValid).to.equal(false); 95 | }); 96 | 97 | 98 | lab.test('it returns as invalid when the user query misses', async () => { 99 | 100 | const session = await Session.create('000000000000000000000000', '127.0.0.1', 'Lab'); 101 | const request = { 102 | method: 'GET', 103 | url: '/', 104 | headers: { 105 | authorization: Fixtures.Creds.authHeader(session._id, session.key) 106 | } 107 | }; 108 | const response = await server.inject(request); 109 | 110 | Code.expect(response.statusCode).to.equal(200); 111 | Code.expect(response.result.isValid).to.equal(false); 112 | }); 113 | 114 | 115 | lab.test('it returns as invalid when the user is not active', async () => { 116 | 117 | const { user } = await Fixtures.Creds.createAdminUser( 118 | 'Ben Hoek', 'ben', 'badben', 'ben@stimpy.show' 119 | ); 120 | const session = await Session.create(`${user._id}`, '127.0.0.1', 'Lab'); 121 | const update = { 122 | $set: { 123 | isActive: false 124 | } 125 | }; 126 | 127 | await User.findByIdAndUpdate(user._id, update); 128 | 129 | const request = { 130 | method: 'GET', 131 | url: '/', 132 | headers: { 133 | authorization: Fixtures.Creds.authHeader(session._id, session.key) 134 | } 135 | }; 136 | 137 | const response = await server.inject(request); 138 | 139 | Code.expect(response.statusCode).to.equal(200); 140 | Code.expect(response.result.isValid).to.equal(false); 141 | }); 142 | 143 | 144 | lab.test('it returns as valid when all is well', async () => { 145 | 146 | const { user } = await Fixtures.Creds.createAdminUser( 147 | 'Ren Hoek', 'ren', 'baddog', 'ren@stimpy.show' 148 | ); 149 | const session = await Session.create(`${user._id}`, '127.0.0.1', 'Lab'); 150 | 151 | const request = { 152 | method: 'GET', 153 | url: '/', 154 | headers: { 155 | authorization: Fixtures.Creds.authHeader(session._id, session.key) 156 | } 157 | }; 158 | 159 | const response = await server.inject(request); 160 | 161 | Code.expect(response.statusCode).to.equal(200); 162 | Code.expect(response.result.isValid).to.equal(true); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /test/server/fixtures/creds.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Account = require('../../../server/models/account'); 4 | const Admin = require('../../../server/models/admin'); 5 | const Session = require('../../../server/models/session'); 6 | const Slug = require('slug'); 7 | const User = require('../../../server/models/user'); 8 | 9 | 10 | class Credentials { 11 | static authHeader(username, password) { 12 | 13 | const combo = `${username}:${password}`; 14 | const combo64 = (Buffer.from(combo)).toString('base64'); 15 | 16 | return `Basic ${combo64}`; 17 | } 18 | 19 | static async createRootAdminUser() { 20 | 21 | let [admin, user, session] = await Promise.all([ 22 | Admin.create('Root Admin'), 23 | User.create('root', 'root', 'root@stimpy.show'), 24 | undefined 25 | ]); 26 | const adminUpdate = { 27 | $set: { 28 | groups: { 29 | root: 'Root' 30 | }, 31 | user: { 32 | id: `${user._id}`, 33 | name: 'root' 34 | } 35 | } 36 | }; 37 | const userUpdate = { 38 | $set: { 39 | 'roles.admin': { 40 | id: `${admin._id}`, 41 | name: 'Root Admin' 42 | } 43 | } 44 | }; 45 | 46 | session = await Session.create(`${user._id}`, '127.0.0.1', 'Lab'); 47 | 48 | [admin, user] = await Promise.all([ 49 | Admin.findByIdAndUpdate(admin._id, adminUpdate), 50 | User.findByIdAndUpdate(user._id, userUpdate) 51 | ]); 52 | 53 | return { 54 | scope: Object.keys(user.roles), 55 | roles: { admin }, 56 | user, 57 | session 58 | }; 59 | } 60 | 61 | static async createAdminUser(name, username, password, email, groups = []) { 62 | 63 | let [admin, user, session] = await Promise.all([ 64 | Admin.create(name), 65 | User.create(username, password, email), 66 | undefined 67 | ]); 68 | const adminUpdate = { 69 | $set: { 70 | groups: groups.reduce((accumulator, group) => { 71 | 72 | accumulator[Slug(group).toLowerCase()] = group; 73 | 74 | return accumulator; 75 | }, {}), 76 | user: { 77 | id: `${user._id}`, 78 | name: username 79 | } 80 | } 81 | }; 82 | const userUpdate = { 83 | $set: { 84 | 'roles.admin': { 85 | id: `${admin._id}`, 86 | name 87 | } 88 | } 89 | }; 90 | 91 | session = await Session.create(`${user._id}`, '127.0.0.1', 'Lab'); 92 | 93 | [admin, user] = await Promise.all([ 94 | Admin.findByIdAndUpdate(admin._id, adminUpdate), 95 | User.findByIdAndUpdate(user._id, userUpdate) 96 | ]); 97 | 98 | return { 99 | scope: Object.keys(user.roles), 100 | roles: { admin }, 101 | user, 102 | session 103 | }; 104 | } 105 | 106 | static async createAccountUser(name, username, password, email) { 107 | 108 | let [account, user, session] = await Promise.all([ 109 | Account.create(name), 110 | User.create(username, password, email), 111 | undefined 112 | ]); 113 | const adminUpdate = { 114 | $set: { 115 | user: { 116 | id: `${user._id}`, 117 | name: username 118 | } 119 | } 120 | }; 121 | const userUpdate = { 122 | $set: { 123 | 'roles.account': { 124 | id: `${account._id}`, 125 | name 126 | } 127 | } 128 | }; 129 | 130 | session = await Session.create(`${user._id}`, '127.0.0.1', 'Lab'); 131 | 132 | [account, user] = await Promise.all([ 133 | Account.findByIdAndUpdate(account._id, adminUpdate), 134 | User.findByIdAndUpdate(user._id, userUpdate) 135 | ]); 136 | 137 | return { 138 | scope: Object.keys(user.roles), 139 | roles: { account }, 140 | user, 141 | session 142 | }; 143 | } 144 | } 145 | 146 | 147 | module.exports = Credentials; 148 | -------------------------------------------------------------------------------- /test/server/fixtures/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Account = require('../../../server/models/account'); 4 | const Admin = require('../../../server/models/admin'); 5 | const AdminGroup = require('../../../server/models/admin-group'); 6 | const Session = require('../../../server/models/session'); 7 | const Status = require('../../../server/models/status'); 8 | const User = require('../../../server/models/user'); 9 | 10 | 11 | class Db { 12 | static async removeAllData() { 13 | 14 | return await Promise.all([ 15 | Account.deleteMany({}), 16 | Admin.deleteMany({}), 17 | AdminGroup.deleteMany({}), 18 | Session.deleteMany({}), 19 | Status.deleteMany({}), 20 | User.deleteMany({}) 21 | ]); 22 | } 23 | } 24 | 25 | 26 | module.exports = Db; 27 | -------------------------------------------------------------------------------- /test/server/fixtures/hapi.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | 4 | class Hapi {} 5 | 6 | 7 | Hapi.debugServerConfig = { 8 | debug: { 9 | request: ['error'] 10 | } 11 | }; 12 | 13 | 14 | module.exports = Hapi; 15 | -------------------------------------------------------------------------------- /test/server/fixtures/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Creds = require('./creds'); 4 | const Db = require('./db'); 5 | const Hapi = require('./hapi'); 6 | 7 | 8 | class Fixtures {} 9 | 10 | 11 | Fixtures.Creds = Creds; 12 | Fixtures.Db = Db; 13 | Fixtures.Hapi = Hapi; 14 | 15 | 16 | module.exports = Fixtures; 17 | -------------------------------------------------------------------------------- /test/server/mailer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Config = require('../../config'); 5 | const Lab = require('@hapi/lab'); 6 | const Mailer = require('../../server/mailer'); 7 | 8 | 9 | const lab = exports.lab = Lab.script(); 10 | 11 | 12 | lab.experiment('Mailer', () => { 13 | 14 | const Mailer_transport = Mailer.transport; 15 | 16 | 17 | lab.afterEach(() => { 18 | 19 | Mailer.transport = Mailer_transport; 20 | }); 21 | 22 | 23 | lab.test('it populates the template cache on first render', async () => { 24 | 25 | const context = { username: 'ren', email: 'ren@stimpy.show' }; 26 | const content = await Mailer.renderTemplate('welcome', context); 27 | 28 | Code.expect(content).to.match(/ren@stimpy.show/i); 29 | }); 30 | 31 | 32 | lab.test('it uses the template cache on subsequent renders', async () => { 33 | 34 | const context = { username: 'stimpy', email: 'stimpy@ren.show' }; 35 | const content = await Mailer.renderTemplate('welcome', context); 36 | 37 | Code.expect(content).to.match(/stimpy@ren.show/i); 38 | }); 39 | 40 | 41 | lab.test('it sends the email through the the transport', async () => { 42 | 43 | Mailer.transport = { 44 | sendMail: function (options) { 45 | 46 | Code.expect(options).to.be.an.object(); 47 | Code.expect(options.from).to.equal(Config.get('/system/fromAddress')); 48 | Code.expect(options.cc).to.be.an.object(); 49 | Code.expect(options.cc.email).to.equal('stimpy@ren.show'); 50 | 51 | return { wasSent: true }; 52 | } 53 | }; 54 | 55 | const context = { username: 'stimpy', email: 'stimpy@ren.show' }; 56 | const content = await Mailer.renderTemplate('welcome', context); 57 | const options = { 58 | cc: { 59 | name: 'Stimpson J Cat', 60 | email: 'stimpy@ren.show' 61 | } 62 | }; 63 | 64 | const info = await Mailer.sendEmail(options, 'welcome', context); 65 | 66 | Code.expect(info).to.be.an.object(); 67 | Code.expect(info.wasSent).to.equal(true); 68 | Code.expect(content).to.match(/stimpy@ren.show/i); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/server/models/account.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Account = require('../../../server/models/account'); 4 | const Code = require('@hapi/code'); 5 | const Config = require('../../../config'); 6 | const Fixtures = require('../fixtures'); 7 | const Lab = require('@hapi/lab'); 8 | const User = require('../../../server/models/user'); 9 | 10 | 11 | const lab = exports.lab = Lab.script(); 12 | const config = Config.get('/hapiMongoModels/mongodb'); 13 | 14 | 15 | lab.experiment('Account Model', () => { 16 | 17 | lab.before(async () => { 18 | 19 | await Account.connect(config.connection, config.options); 20 | await Fixtures.Db.removeAllData(); 21 | }); 22 | 23 | 24 | lab.after(async () => { 25 | 26 | await Fixtures.Db.removeAllData(); 27 | 28 | Account.disconnect(); 29 | }); 30 | 31 | 32 | lab.test('it parses names into name fields', () => { 33 | 34 | const justFirst = Account.nameAdapter('Steve'); 35 | 36 | Code.expect(justFirst).to.be.an.object(); 37 | Code.expect(justFirst.first).to.equal('Steve'); 38 | Code.expect(justFirst.middle).to.equal(''); 39 | Code.expect(justFirst.last).to.equal(''); 40 | 41 | const firstAndLast = Account.nameAdapter('Ren Höek'); 42 | 43 | Code.expect(firstAndLast).to.be.an.object(); 44 | Code.expect(firstAndLast.first).to.equal('Ren'); 45 | Code.expect(firstAndLast.middle).to.equal(''); 46 | Code.expect(firstAndLast.last).to.equal('Höek'); 47 | 48 | const withMiddle = Account.nameAdapter('Stimpson J Cat'); 49 | 50 | Code.expect(withMiddle).to.be.an.object(); 51 | Code.expect(withMiddle.first).to.equal('Stimpson'); 52 | Code.expect(withMiddle.middle).to.equal('J'); 53 | Code.expect(withMiddle.last).to.equal('Cat'); 54 | }); 55 | 56 | 57 | lab.test('it parses returns a full name', async () => { 58 | 59 | const account = await Account.create('Stan'); 60 | let name = account.fullName(); 61 | 62 | Code.expect(name).to.equal('Stan'); 63 | 64 | account.name = Account.nameAdapter('Ren Höek'); 65 | 66 | name = account.fullName(); 67 | 68 | Code.expect(name).to.equal('Ren Höek'); 69 | }); 70 | 71 | 72 | lab.test('it returns an instance when finding by username', async () => { 73 | 74 | const document = new Account({ 75 | name: Account.nameAdapter('Stimpson J Cat'), 76 | user: { 77 | id: '95EP150D35', 78 | name: 'stimpy' 79 | } 80 | }); 81 | 82 | await Account.insertOne(document); 83 | 84 | const account = await Account.findByUsername('stimpy'); 85 | 86 | Code.expect(account).to.be.an.instanceOf(Account); 87 | }); 88 | 89 | 90 | lab.test('it returns a new instance when create succeeds', async () => { 91 | 92 | const account = await Account.create('Ren Höek'); 93 | 94 | Code.expect(account).to.be.an.instanceOf(Account); 95 | }); 96 | 97 | 98 | lab.test('it links and unlinks users', async () => { 99 | 100 | let account = await Account.create('Guinea Pig'); 101 | const user = await User.create('guineapig', 'wheel', 'wood@chips.gov'); 102 | 103 | Code.expect(account.user).to.not.exist(); 104 | 105 | account = await account.linkUser(`${user._id}`, user.username); 106 | 107 | Code.expect(account.user).to.be.an.object(); 108 | 109 | account = await account.unlinkUser(); 110 | 111 | Code.expect(account.user).to.not.exist(); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/server/models/admin-group.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AdminGroup = require('../../../server/models/admin-group'); 4 | const Code = require('@hapi/code'); 5 | const Config = require('../../../config'); 6 | const Fixtures = require('../fixtures'); 7 | const Lab = require('@hapi/lab'); 8 | 9 | 10 | const lab = exports.lab = Lab.script(); 11 | const config = Config.get('/hapiMongoModels/mongodb'); 12 | 13 | 14 | lab.experiment('AdminGroup Model', () => { 15 | 16 | lab.before(async () => { 17 | 18 | await AdminGroup.connect(config.connection, config.options); 19 | await Fixtures.Db.removeAllData(); 20 | }); 21 | 22 | 23 | lab.after(async () => { 24 | 25 | await Fixtures.Db.removeAllData(); 26 | 27 | AdminGroup.disconnect(); 28 | }); 29 | 30 | 31 | lab.test('it returns a new instance when create succeeds', async () => { 32 | 33 | const adminGroup = await AdminGroup.create('Sales'); 34 | 35 | Code.expect(adminGroup).to.be.an.instanceOf(AdminGroup); 36 | }); 37 | 38 | 39 | lab.test('it returns false when permissions are missing', async () => { 40 | 41 | const adminGroup = await AdminGroup.create('Missing'); 42 | 43 | Code.expect(adminGroup.hasPermissionTo('SPACE_MADNESS')).to.equal(false); 44 | }); 45 | 46 | 47 | lab.test('it returns boolean values for set permissions', async () => { 48 | 49 | const adminGroup = await AdminGroup.create('Support'); 50 | 51 | adminGroup.permissions = { 52 | SPACE_MADNESS: true, 53 | UNTAMED_WORLD: false 54 | }; 55 | 56 | Code.expect(adminGroup.hasPermissionTo('SPACE_MADNESS')).to.equal(true); 57 | Code.expect(adminGroup.hasPermissionTo('UNTAMED_WORLD')).to.equal(false); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /test/server/models/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Admin = require('../../../server/models/admin'); 4 | const AdminGroup = require('../../../server/models/admin-group'); 5 | const Code = require('@hapi/code'); 6 | const Config = require('../../../config'); 7 | const Fixtures = require('../fixtures'); 8 | const Lab = require('@hapi/lab'); 9 | const User = require('../../../server/models/user'); 10 | 11 | 12 | const lab = exports.lab = Lab.script(); 13 | const config = Config.get('/hapiMongoModels/mongodb'); 14 | 15 | 16 | lab.experiment('Admin Model', () => { 17 | 18 | lab.before(async () => { 19 | 20 | await Admin.connect(config.connection, config.options); 21 | await Fixtures.Db.removeAllData(); 22 | }); 23 | 24 | 25 | lab.after(async () => { 26 | 27 | await Fixtures.Db.removeAllData(); 28 | 29 | Admin.disconnect(); 30 | }); 31 | 32 | 33 | lab.test('it parses names into name fields', () => { 34 | 35 | const justFirst = Admin.nameAdapter('Steve'); 36 | 37 | Code.expect(justFirst).to.be.an.object(); 38 | Code.expect(justFirst.first).to.equal('Steve'); 39 | Code.expect(justFirst.middle).to.equal(''); 40 | Code.expect(justFirst.last).to.equal(''); 41 | 42 | const firstAndLast = Admin.nameAdapter('Ren Höek'); 43 | 44 | Code.expect(firstAndLast).to.be.an.object(); 45 | Code.expect(firstAndLast.first).to.equal('Ren'); 46 | Code.expect(firstAndLast.middle).to.equal(''); 47 | Code.expect(firstAndLast.last).to.equal('Höek'); 48 | 49 | const withMiddle = Admin.nameAdapter('Stimpson J Cat'); 50 | 51 | Code.expect(withMiddle).to.be.an.object(); 52 | Code.expect(withMiddle.first).to.equal('Stimpson'); 53 | Code.expect(withMiddle.middle).to.equal('J'); 54 | Code.expect(withMiddle.last).to.equal('Cat'); 55 | }); 56 | 57 | 58 | lab.test('it parses returns a full name', async () => { 59 | 60 | const admin = await Admin.create('Stan'); 61 | let name = admin.fullName(); 62 | 63 | Code.expect(name).to.equal('Stan'); 64 | 65 | admin.name = Admin.nameAdapter('Ren Höek'); 66 | 67 | name = admin.fullName(); 68 | 69 | Code.expect(name).to.equal('Ren Höek'); 70 | }); 71 | 72 | 73 | lab.test('it returns a new instance when create succeeds', async () => { 74 | 75 | const admin = await Admin.create('Ren Höek'); 76 | 77 | Code.expect(admin).to.be.an.instanceOf(Admin); 78 | }); 79 | 80 | 81 | lab.test('it returns an instance when finding by username', async () => { 82 | 83 | const document = new Admin({ 84 | name: Admin.nameAdapter('Stimpson J Cat'), 85 | user: { 86 | id: '95EP150D35', 87 | name: 'stimpy' 88 | } 89 | }); 90 | 91 | await Admin.insertOne(document); 92 | 93 | const account = await Admin.findByUsername('stimpy'); 94 | 95 | Code.expect(account).to.be.an.instanceOf(Admin); 96 | }); 97 | 98 | 99 | lab.test('it returns false when checking for membership when groups are missing', async () => { 100 | 101 | const admin = await Admin.create('Ren Höek'); 102 | 103 | Code.expect(admin.isMemberOf('sales')).to.equal(false); 104 | }); 105 | 106 | 107 | lab.test('it returns false when permissions are missing', async () => { 108 | 109 | const admin = await Admin.create('Ren Höek'); 110 | const hasPermission = await admin.hasPermissionTo('SPACE_MADNESS'); 111 | 112 | Code.expect(hasPermission).to.equal(false); 113 | }); 114 | 115 | 116 | lab.test('it returns boolean values when the permission exists on the admin', async () => { 117 | 118 | const admin = new Admin({ 119 | name: Admin.nameAdapter('Ren Höek'), 120 | permissions: { 121 | SPACE_MADNESS: true, 122 | UNTAMED_WORLD: false 123 | } 124 | }); 125 | const hasPermission = await admin.hasPermissionTo('SPACE_MADNESS'); 126 | 127 | Code.expect(hasPermission).to.equal(true); 128 | }); 129 | 130 | 131 | lab.test('it returns boolean values when permission exits on the admin group', async () => { 132 | 133 | // create groups 134 | 135 | const salesGroup = new AdminGroup({ 136 | _id: 'sales', 137 | name: 'Sales', 138 | permissions: { 139 | UNTAMED_WORLD: false, 140 | WORLD_UNTAMED: true 141 | } 142 | }); 143 | const supportGroup = new AdminGroup({ 144 | _id: 'support', 145 | name: 'Support', 146 | permissions: { 147 | SPACE_MADNESS: true, 148 | MADNESS_SPACE: false 149 | } 150 | }); 151 | 152 | await AdminGroup.insertMany([salesGroup, supportGroup]); 153 | 154 | // admin without group membership 155 | 156 | const documentA = new Admin({ 157 | name: Admin.nameAdapter('Ren Höek') 158 | }); 159 | const testA1 = await documentA.hasPermissionTo('SPACE_MADNESS'); 160 | 161 | Code.expect(testA1).to.equal(false); 162 | 163 | const testA2 = await documentA.hasPermissionTo('UNTAMED_WORLD'); 164 | 165 | Code.expect(testA2).to.equal(false); 166 | 167 | // admin with group membership 168 | 169 | const documentB = new Admin({ 170 | name: Admin.nameAdapter('Ren B Höek'), 171 | groups: { 172 | sales: 'Sales', 173 | support: 'Support' 174 | } 175 | }); 176 | 177 | const testB1 = await documentB.hasPermissionTo('SPACE_MADNESS'); 178 | 179 | Code.expect(testB1).to.equal(true); 180 | 181 | const testB2 = await documentB.hasPermissionTo('UNTAMED_WORLD'); 182 | 183 | Code.expect(testB2).to.equal(false); 184 | }); 185 | 186 | 187 | lab.test('it links and unlinks users', async () => { 188 | 189 | let admin = await Admin.create('Guinea Pig'); 190 | const user = await User.create('guineapig', 'wheel', 'wood@chips.gov'); 191 | 192 | Code.expect(admin.user).to.not.exist(); 193 | 194 | admin = await admin.linkUser(`${user._id}`, user.username); 195 | 196 | Code.expect(admin.user).to.be.an.object(); 197 | 198 | admin = await admin.unlinkUser(); 199 | 200 | Code.expect(admin.user).to.not.exist(); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/server/models/auth-attempt.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const AuthAttempt = require('../../../server/models/auth-attempt'); 4 | const Code = require('@hapi/code'); 5 | const Config = require('../../../config'); 6 | const Fixtures = require('../fixtures'); 7 | const Lab = require('@hapi/lab'); 8 | 9 | 10 | const lab = exports.lab = Lab.script(); 11 | const config = Config.get('/hapiMongoModels/mongodb'); 12 | 13 | 14 | lab.experiment('AuthAttempt Model', () => { 15 | 16 | lab.before(async () => { 17 | 18 | await AuthAttempt.connect(config.connection, config.options); 19 | await Fixtures.Db.removeAllData(); 20 | }); 21 | 22 | 23 | lab.after(async () => { 24 | 25 | await Fixtures.Db.removeAllData(); 26 | 27 | AuthAttempt.disconnect(); 28 | }); 29 | 30 | 31 | lab.afterEach(async () => { 32 | 33 | await AuthAttempt.deleteMany({}); 34 | }); 35 | 36 | 37 | lab.test('it returns false when abuse is not detected', async () => { 38 | 39 | const result = await AuthAttempt.abuseDetected('127.0.0.1', 'ren'); 40 | 41 | Code.expect(result).to.equal(false); 42 | }); 43 | 44 | 45 | lab.test('it detects login abuse from an ip and many users', async () => { 46 | 47 | const attemptConfig = Config.get('/authAttempts'); 48 | const authRequest = (i) => AuthAttempt.create('127.0.0.2', `mudskipper${i}`); 49 | const authSpam = Array(attemptConfig.forIp).fill().map((_, i) => authRequest(i)); 50 | 51 | await Promise.all(authSpam); 52 | 53 | const result = await AuthAttempt.abuseDetected('127.0.0.2', 'yak'); 54 | 55 | Code.expect(result).to.equal(true); 56 | }); 57 | 58 | 59 | lab.test('it detects login abuse from an ip and one user', async () => { 60 | 61 | const attemptConfig = Config.get('/authAttempts'); 62 | const authRequest = () => AuthAttempt.create('127.0.0.3', 'steve'); 63 | const authSpam = Array(attemptConfig.forIpAndUser).fill().map((_) => authRequest()); 64 | 65 | await Promise.all(authSpam); 66 | 67 | const result = await AuthAttempt.abuseDetected('127.0.0.3', 'steve'); 68 | 69 | Code.expect(result).to.equal(true); 70 | }); 71 | 72 | 73 | lab.test('it returns a new instance when create succeeds', async () => { 74 | 75 | const authAttempt = await AuthAttempt.create('127.0.0.4', 'ren'); 76 | 77 | Code.expect(authAttempt).to.be.an.instanceOf(AuthAttempt); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/server/models/note-entry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Lab = require('@hapi/lab'); 5 | const NoteEntry = require('../../../server/models/note-entry'); 6 | 7 | 8 | const lab = exports.lab = Lab.script(); 9 | 10 | 11 | lab.experiment('NoteEntry Model', () => { 12 | 13 | lab.test('it instantiates an instance', () => { 14 | 15 | const noteEntry = new NoteEntry({ 16 | data: 'Important stuff.', 17 | adminCreated: { 18 | id: '111111111111111111111111', 19 | name: 'Root Admin' 20 | } 21 | }); 22 | 23 | Code.expect(noteEntry).to.be.an.instanceOf(NoteEntry); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /test/server/models/session.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Config = require('../../../config'); 5 | const Fixtures = require('../fixtures'); 6 | const Lab = require('@hapi/lab'); 7 | const Session = require('../../../server/models/session'); 8 | 9 | 10 | const lab = exports.lab = Lab.script(); 11 | const config = Config.get('/hapiMongoModels/mongodb'); 12 | 13 | 14 | lab.experiment('Session Model', () => { 15 | 16 | lab.before(async () => { 17 | 18 | await Session.connect(config.connection, config.options); 19 | await Fixtures.Db.removeAllData(); 20 | }); 21 | 22 | 23 | lab.after(async () => { 24 | 25 | await Fixtures.Db.removeAllData(); 26 | 27 | Session.disconnect(); 28 | }); 29 | 30 | 31 | lab.test('it returns a new instance when create succeeds', async () => { 32 | 33 | const session = await Session.create('ren', 'ip', 'userAgent'); 34 | 35 | Code.expect(session).to.be.an.instanceOf(Session); 36 | }); 37 | 38 | 39 | lab.test('it returns undefined when finding by credentials session misses', async () => { 40 | 41 | const id = '555555555555555555555555'; 42 | const keyHash = await Session.generateKeyHash(); 43 | const session = await Session.findByCredentials(id, keyHash.key); 44 | 45 | Code.expect(session).to.be.undefined(); 46 | }); 47 | 48 | 49 | lab.test('it returns undefined when finding by credentials session hits and key match misses', async () => { 50 | 51 | const userId = '000000000000000000000000'; 52 | const ip = '127.0.0.1'; 53 | const userAgent = [ 54 | 'Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us)', 55 | ' AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405' 56 | ].join(''); 57 | const session = await Session.create(userId, ip, userAgent); 58 | 59 | Code.expect(session).to.be.an.instanceOf(Session); 60 | 61 | const key = `${session.key}poison`; 62 | const result = await Session.findByCredentials(session._id, key); 63 | 64 | Code.expect(result).to.be.undefined(); 65 | }); 66 | 67 | 68 | lab.test('it returns a session instance when finding by credentials hits and key match hits', async () => { 69 | 70 | const userId = '000000000000000000000000'; 71 | const ip = '127.0.0.1'; 72 | const userAgent = [ 73 | 'Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us)', 74 | ' AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405' 75 | ].join(''); 76 | const session = await Session.create(userId, ip, userAgent); 77 | 78 | Code.expect(session).to.be.an.instanceOf(Session); 79 | 80 | const key = session.key; 81 | const result = await Session.findByCredentials(session._id, key); 82 | 83 | Code.expect(result).to.be.an.instanceOf(Session); 84 | Code.expect(session._id).to.equal(result._id); 85 | }); 86 | 87 | 88 | lab.test('it creates a key hash combination', async () => { 89 | 90 | const result = await Session.generateKeyHash(); 91 | 92 | Code.expect(result).to.be.an.object(); 93 | Code.expect(result.key).to.be.a.string(); 94 | Code.expect(result.hash).to.be.a.string(); 95 | }); 96 | 97 | 98 | lab.test('it updates the last active time of an instance', async () => { 99 | 100 | const userId = '000000000000000000000000'; 101 | const ip = '127.0.0.1'; 102 | const userAgent = [ 103 | 'Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us)', 104 | ' AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405' 105 | ].join(''); 106 | const session = await Session.create(userId, ip, userAgent); 107 | 108 | await session.updateLastActive(); 109 | 110 | Code.expect(session.lastActive).to.be.a.date(); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/server/models/status-entry.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Lab = require('@hapi/lab'); 5 | const StatusEntry = require('../../../server/models/status-entry'); 6 | 7 | 8 | const lab = exports.lab = Lab.script(); 9 | 10 | 11 | lab.experiment('Status Model', () => { 12 | 13 | lab.test('it instantiates an instance', () => { 14 | 15 | const statusEntry = new StatusEntry({ 16 | id: 'account-happy', 17 | name: 'Happy', 18 | adminCreated: { 19 | id: '111111111111111111111111', 20 | name: 'Root Admin' 21 | } 22 | }); 23 | 24 | Code.expect(statusEntry).to.be.an.instanceOf(StatusEntry); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/server/models/status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Config = require('../../../config'); 5 | const Fixtures = require('../fixtures'); 6 | const Lab = require('@hapi/lab'); 7 | const Status = require('../../../server/models/status'); 8 | 9 | 10 | const lab = exports.lab = Lab.script(); 11 | const config = Config.get('/hapiMongoModels/mongodb'); 12 | 13 | 14 | lab.experiment('Status Model', () => { 15 | 16 | lab.before(async () => { 17 | 18 | await Status.connect(config.connection, config.options); 19 | await Fixtures.Db.removeAllData(); 20 | }); 21 | 22 | 23 | lab.after(async () => { 24 | 25 | await Fixtures.Db.removeAllData(); 26 | 27 | Status.disconnect(); 28 | }); 29 | 30 | 31 | lab.test('it returns a new instance when create succeeds', async () => { 32 | 33 | const status = await Status.create('Order', 'Complete'); 34 | 35 | Code.expect(status).to.be.an.instanceOf(Status); 36 | Code.expect(status._id).to.equal('order-complete'); 37 | Code.expect(status.name).to.equal('Complete'); 38 | Code.expect(status.pivot).to.equal('Order'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/server/models/user.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Account = require('../../../server/models/account'); 4 | const Admin = require('../../../server/models/admin'); 5 | const Code = require('@hapi/code'); 6 | const Config = require('../../../config'); 7 | const Fixtures = require('../fixtures'); 8 | const Lab = require('@hapi/lab'); 9 | const User = require('../../../server/models/user'); 10 | 11 | 12 | const lab = exports.lab = Lab.script(); 13 | const config = Config.get('/hapiMongoModels/mongodb'); 14 | 15 | 16 | lab.experiment('User Model', () => { 17 | 18 | lab.before(async () => { 19 | 20 | await User.connect(config.connection, config.options); 21 | await Fixtures.Db.removeAllData(); 22 | }); 23 | 24 | 25 | lab.after(async () => { 26 | 27 | await Fixtures.Db.removeAllData(); 28 | 29 | User.disconnect(); 30 | }); 31 | 32 | 33 | lab.test('it returns a new instance when create succeeds', async () => { 34 | 35 | const user = await User.create('ren', 'bighouseblues', 'ren@stimpy.show'); 36 | 37 | Code.expect(user).to.be.an.instanceOf(User); 38 | }); 39 | 40 | 41 | lab.test('it returns undefined when finding by credentials user misses', async () => { 42 | 43 | const user = await User.findByCredentials('steve', '123456'); 44 | 45 | Code.expect(user).to.be.undefined(); 46 | }); 47 | 48 | 49 | lab.test('it returns undefined when finding by credentials user hits and password match misses', async () => { 50 | 51 | const user = await User.findByCredentials('ren', '123456'); 52 | 53 | Code.expect(user).to.be.undefined(); 54 | }); 55 | 56 | 57 | lab.test('it returns an instance when finding by credentials user hits and password match hits', async () => { 58 | 59 | const withUsername = await User.findByCredentials('ren', 'bighouseblues'); 60 | 61 | Code.expect(withUsername).to.be.an.instanceOf(User); 62 | 63 | const withEmail = await User.findByCredentials('ren@stimpy.show', 'bighouseblues'); 64 | 65 | Code.expect(withEmail).to.be.an.instanceOf(User); 66 | }); 67 | 68 | 69 | lab.test('it returns an instance when finding by email', async () => { 70 | 71 | const user = await User.findByEmail('ren@stimpy.show'); 72 | 73 | Code.expect(user).to.be.an.instanceOf(User); 74 | }); 75 | 76 | 77 | lab.test('it returns an instance when finding by username', async () => { 78 | 79 | const user = await User.findByUsername('ren'); 80 | 81 | Code.expect(user).to.be.an.instanceOf(User); 82 | }); 83 | 84 | 85 | lab.test('it creates a password hash combination', async () => { 86 | 87 | const password = '3l1t3f00&&b4r'; 88 | const result = await User.generatePasswordHash(password); 89 | 90 | Code.expect(result).to.be.an.object(); 91 | Code.expect(result.password).to.equal(password); 92 | Code.expect(result.hash).to.be.a.string(); 93 | }); 94 | 95 | 96 | lab.test('it returns boolean values when checking if a user can play roles', async () => { 97 | 98 | let user; 99 | 100 | user = await User.findByUsername('ren'); 101 | user = await User.findByIdAndUpdate(user._id, { 102 | $set: { 103 | roles: { 104 | account: { 105 | id: '555555555555555555555555', 106 | name: 'Ren Hoek' 107 | } 108 | } 109 | } 110 | }); 111 | 112 | Code.expect(user.canPlayRole('admin')).to.equal(false); 113 | Code.expect(user.canPlayRole('account')).to.equal(true); 114 | }); 115 | 116 | 117 | lab.test('it hydrates roles when both admin and account are missing', async () => { 118 | 119 | let user; 120 | 121 | user = await User.findByUsername('ren'); 122 | user = await User.findByIdAndUpdate(user._id, { 123 | $set: { 124 | roles: {} 125 | } 126 | }); 127 | 128 | await user.hydrateRoles(); 129 | 130 | Code.expect(user._roles).to.be.an.object(); 131 | Code.expect(Object.keys(user._roles)).to.have.length(0); 132 | }); 133 | 134 | 135 | lab.test('it hydrates roles when an account role is present', async () => { 136 | 137 | const account = await Account.create('Run Hoek'); 138 | 139 | let user; 140 | 141 | user = await User.findByUsername('ren'); 142 | user = await User.findByIdAndUpdate(user._id, { 143 | $set: { 144 | roles: { 145 | account: { 146 | id: `${account._id}`, 147 | name: account.fullName() 148 | } 149 | } 150 | } 151 | }); 152 | 153 | await user.hydrateRoles(); 154 | 155 | Code.expect(user._roles).to.be.an.object(); 156 | Code.expect(Object.keys(user._roles)).to.have.length(1); 157 | Code.expect(user._roles.account).to.be.an.instanceOf(Account); 158 | }); 159 | 160 | 161 | lab.test('it hydrates roles when an admin role is present', async () => { 162 | 163 | const admin = await Admin.create('Run Hoek'); 164 | 165 | let user; 166 | 167 | user = await User.findByUsername('ren'); 168 | user = await User.findByIdAndUpdate(user._id, { 169 | $set: { 170 | roles: { 171 | admin: { 172 | id: `${admin._id}`, 173 | name: admin.fullName() 174 | } 175 | } 176 | } 177 | }); 178 | 179 | await user.hydrateRoles(); 180 | 181 | Code.expect(user._roles).to.be.an.object(); 182 | Code.expect(Object.keys(user._roles)).to.have.length(1); 183 | Code.expect(user._roles.admin).to.be.an.instanceOf(Admin); 184 | }); 185 | 186 | 187 | lab.test('it links and unlinks roles', async () => { 188 | 189 | let user = await User.create('guineapig', 'wheel', 'wood@chips.gov'); 190 | const [admin, account] = await Promise.all([ 191 | Admin.create('Guinea Pig'), 192 | Account.create('Guinea Pig') 193 | ]); 194 | 195 | Code.expect(user.roles.admin).to.not.exist(); 196 | Code.expect(user.roles.account).to.not.exist(); 197 | 198 | user = await user.linkAdmin(`${admin._id}`, admin.fullName()); 199 | user = await user.linkAccount(`${account._id}`, account.fullName()); 200 | 201 | Code.expect(user.roles.admin).to.be.an.object(); 202 | Code.expect(user.roles.account).to.be.an.object(); 203 | 204 | user = await user.unlinkAdmin(); 205 | user = await user.unlinkAccount(); 206 | 207 | Code.expect(user.roles.admin).to.not.exist(); 208 | Code.expect(user.roles.account).to.not.exist(); 209 | }); 210 | 211 | 212 | lab.test('it hydrates roles and caches the results for subsequent access', async () => { 213 | 214 | const user = await User.findByUsername('ren'); 215 | 216 | await user.hydrateRoles(); 217 | 218 | Code.expect(user._roles).to.be.an.object(); 219 | Code.expect(Object.keys(user._roles)).to.have.length(1); 220 | Code.expect(user._roles.admin).to.be.an.instanceOf(Admin); 221 | 222 | const roles = await user.hydrateRoles(); 223 | 224 | Code.expect(user._roles).to.equal(roles); 225 | }); 226 | }); 227 | -------------------------------------------------------------------------------- /test/server/preware.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Auth = require('../../server/auth'); 4 | const Code = require('@hapi/code'); 5 | const Fixtures = require('./fixtures'); 6 | const Hapi = require('@hapi/hapi'); 7 | const Lab = require('@hapi/lab'); 8 | const Manifest = require('../../manifest'); 9 | const Preware = require('../../server/preware'); 10 | 11 | 12 | const lab = exports.lab = Lab.script(); 13 | let server; 14 | let adminCredentials; 15 | 16 | 17 | lab.before(async () => { 18 | 19 | server = Hapi.Server(); 20 | 21 | const plugins = Manifest.get('/register/plugins') 22 | .filter((entry) => Auth.dependencies.includes(entry.plugin)) 23 | .map((entry) => { 24 | 25 | entry.plugin = require(entry.plugin); 26 | 27 | return entry; 28 | }); 29 | 30 | plugins.push(Auth); 31 | 32 | await server.register(plugins); 33 | await server.start(); 34 | await Fixtures.Db.removeAllData(); 35 | 36 | const auth = { strategy: 'simple', scope: 'admin' }; 37 | const handler = (request, h) => ({ message: 'ok' }); 38 | 39 | server.route({ 40 | method: 'GET', 41 | path: '/limited/to/root/group', 42 | options: { 43 | auth, 44 | pre: [ 45 | Preware.requireAdminGroup('root') 46 | ] 47 | }, 48 | handler 49 | }); 50 | 51 | server.route({ 52 | method: 'GET', 53 | path: '/limited/to/multiple/groups', 54 | options: { 55 | auth, 56 | pre: [ 57 | Preware.requireAdminGroup(['sales', 'support']) 58 | ] 59 | }, 60 | handler 61 | }); 62 | 63 | server.route({ 64 | method: 'GET', 65 | path: '/just/not/the/root/user', 66 | options: { 67 | auth, 68 | pre: [ 69 | Preware.requireNotRootUser 70 | ] 71 | }, 72 | handler 73 | }); 74 | 75 | adminCredentials = await Fixtures.Creds.createAdminUser( 76 | 'Ren Hoek', 'ren', 'baddog', 'ren@stimpy.show', ['Sales'] 77 | ); 78 | }); 79 | 80 | 81 | lab.after(async () => { 82 | 83 | await Fixtures.Db.removeAllData(); 84 | await server.stop(); 85 | }); 86 | 87 | 88 | lab.experiment('Preware', () => { 89 | 90 | lab.test('it prevents access when group membership misses', async () => { 91 | 92 | const request = { 93 | method: 'GET', 94 | url: '/limited/to/root/group', 95 | auth: { 96 | strategy: 'basic', 97 | credentials: adminCredentials 98 | } 99 | }; 100 | const response = await server.inject(request); 101 | 102 | Code.expect(response.statusCode).to.equal(403); 103 | }); 104 | 105 | 106 | lab.test('it grants access when group membership hits', async () => { 107 | 108 | const request = { 109 | method: 'GET', 110 | url: '/limited/to/multiple/groups', 111 | auth: { 112 | strategy: 'basic', 113 | credentials: adminCredentials 114 | } 115 | }; 116 | const response = await server.inject(request); 117 | 118 | Code.expect(response.statusCode).to.equal(200); 119 | }); 120 | 121 | 122 | lab.test('it prevents access to the root user', async () => { 123 | 124 | const rootCredentails = await Fixtures.Creds.createRootAdminUser(); 125 | const request = { 126 | method: 'GET', 127 | url: '/just/not/the/root/user', 128 | auth: { 129 | strategy: 'basic', 130 | credentials: rootCredentails 131 | } 132 | }; 133 | const response = await server.inject(request); 134 | 135 | Code.expect(response.statusCode).to.equal(403); 136 | }); 137 | 138 | 139 | lab.test('it grants access to non-root users', async () => { 140 | 141 | const request = { 142 | method: 'GET', 143 | url: '/just/not/the/root/user', 144 | auth: { 145 | strategy: 'basic', 146 | credentials: adminCredentials 147 | } 148 | }; 149 | const response = await server.inject(request); 150 | 151 | Code.expect(response.statusCode).to.equal(200); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /test/server/web/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Code = require('@hapi/code'); 4 | const Hapi = require('@hapi/hapi'); 5 | const Lab = require('@hapi/lab'); 6 | const Main = require('../../../server/web/main'); 7 | const Manifest = require('../../../manifest'); 8 | 9 | 10 | const lab = exports.lab = Lab.script(); 11 | let server; 12 | 13 | 14 | lab.before(async () => { 15 | 16 | server = Hapi.Server(); 17 | 18 | const plugins = Manifest.get('/register/plugins') 19 | .filter((entry) => Main.dependencies.includes(entry.plugin)) 20 | .map((entry) => { 21 | 22 | entry.plugin = require(entry.plugin); 23 | 24 | return entry; 25 | }); 26 | 27 | plugins.push(Main); 28 | 29 | await server.register(plugins); 30 | await server.start(); 31 | }); 32 | 33 | 34 | lab.after(async () => { 35 | 36 | await server.stop(); 37 | }); 38 | 39 | 40 | lab.experiment('GET /', () => { 41 | 42 | let request; 43 | 44 | 45 | lab.beforeEach(() => { 46 | 47 | request = { 48 | method: 'GET', 49 | url: '/' 50 | }; 51 | }); 52 | 53 | 54 | lab.test('it returns HTTP 200 when all is good', async () => { 55 | 56 | const response = await server.inject(request); 57 | 58 | Code.expect(response.statusCode).to.equal(200); 59 | Code.expect(response.result).to.match(/welcome/i); 60 | }); 61 | }); 62 | --------------------------------------------------------------------------------