From ffc50c62483fd0c6d0f53655f711e67182d40576 Mon Sep 17 00:00:00 2001 From: "Daniel da Silva Dias (dasilv32)" Date: Wed, 22 Apr 2020 00:23:35 +0000 Subject: [PATCH] Initial commit --- .eslintignore | 4 + .eslintrc.json | 77 +++++++++++ .githooks/post-checkout | 28 ++++ .githooks/post-commit | 7 + .githooks/pre-commit | 76 +++++++++++ .githooks/pre-merge-commit | 4 + .githooks/pre-push | 18 +++ .githooks/prepare-commit-msg | 37 ++++++ .gitignore | 31 +++++ .gitlab-ci.yml | 42 ++++++ .markdownlint.json | 33 +++++ .vscode/launch.json | 27 ++++ README.md | 54 ++++++++ __mocks__/nodemailer-promise.js | 9 ++ config.sample.json | 9 ++ cucumber/features/login.feature | 36 +++++ cucumber/steps/page.js | 24 ++++ cucumber/steps/todo.steps.js | 117 ++++++++++++++++ index.js | 31 +++++ jest-test.config.js | 20 +++ jest.puppeteer.config.js | 12 ++ jsdoc.conf | 22 +++ modules/user.js | 148 +++++++++++++++++++++ package.json | 68 ++++++++++ public/avatars/avatar.png | Bin 0 -> 56968 bytes public/favicon.ico | Bin 0 -> 5430 bytes public/style.css | 229 ++++++++++++++++++++++++++++++++ routes/public.js | 108 +++++++++++++++ routes/routes.js | 12 ++ routes/secure.js | 17 +++ temp.js | 20 +++ unitTests/user.spec.js | 202 ++++++++++++++++++++++++++++ views/error.handlebars | 20 +++ views/index.handlebars | 31 +++++ views/login.handlebars | 36 +++++ views/register.handlebars | 38 ++++++ views/secure.handlebars | 25 ++++ views/validate.handlebars | 21 +++ 38 files changed, 1693 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .githooks/post-checkout create mode 100644 .githooks/post-commit create mode 100644 .githooks/pre-commit create mode 100644 .githooks/pre-merge-commit create mode 100644 .githooks/pre-push create mode 100644 .githooks/prepare-commit-msg create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .markdownlint.json create mode 100644 .vscode/launch.json create mode 100644 README.md create mode 100644 __mocks__/nodemailer-promise.js create mode 100644 config.sample.json create mode 100644 cucumber/features/login.feature create mode 100644 cucumber/steps/page.js create mode 100644 cucumber/steps/todo.steps.js create mode 100644 index.js create mode 100644 jest-test.config.js create mode 100644 jest.puppeteer.config.js create mode 100644 jsdoc.conf create mode 100644 modules/user.js create mode 100644 package.json create mode 100644 public/avatars/avatar.png create mode 100644 public/favicon.ico create mode 100644 public/style.css create mode 100644 routes/public.js create mode 100644 routes/routes.js create mode 100644 routes/secure.js create mode 100644 temp.js create mode 100644 unitTests/user.spec.js create mode 100644 views/error.handlebars create mode 100644 views/index.handlebars create mode 100644 views/login.handlebars create mode 100644 views/register.handlebars create mode 100644 views/secure.handlebars create mode 100644 views/validate.handlebars diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..3854769 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ + +docs/** +node_modules/** +coverage/** diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..35347aa --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,77 @@ + +{ + "env": { + "es6": true, + "jasmine": true, + "node": true, + "browser": true, + "jest": true + }, + "parserOptions": { + "ecmaVersion": 2018, + "noInlineConfig": true, + "reportUnusedDisableDirectives": true, + "sourceType": "module" + }, + "rules": { + "arrow-body-style": "error", + "arrow-spacing": ["warn", {"before": true, "after": true}], + "brace-style": "error", + "camelcase": ["error", {"properties": "never"}], + "complexity": ["error", 5], + "eol-last": "warn", + "eqeqeq": "error", + "func-call-spacing": ["error", "never"], + "global-require": "error", + "handle-callback-err": "warn", + "indent": ["warn", "tab", {"SwitchCase": 1}], + "key-spacing": ["error", {"beforeColon": false, "afterColon": true}], + "linebreak-style": ["warn", "unix"], + "max-depth": ["error", 3], + "max-len": ["warn", { "code": 120, "tabWidth": 4 }], + "max-lines": ["warn", {"max": 150, "skipBlankLines": true, "skipComments": true}], + "max-lines-per-function": ["warn", {"max": 20, "skipBlankLines": true, "skipComments": true}], + "max-nested-callbacks": ["error", 4], + "max-params": ["error", 5], + "max-statements": ["error", 20], + "no-cond-assign": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty": "warn", + "no-empty-function": "error", + "no-multiple-empty-lines": "warn", + "no-extra-parens": "error", + "no-func-assign": "error", + "no-irregular-whitespace": "error", + "no-magic-numbers": ["warn", {"ignore": [-1, 0, 1]}], + "no-multi-spaces": "warn", + "no-multi-str": "off", + "no-unexpected-multiline": "error", + "no-unreachable": "error", + "no-self-assign": "error", + "no-trailing-spaces": "warn", + "no-undef": "error", + "no-unused-vars": "warn", + "no-var": 2, + "prefer-arrow-callback": "warn", + "prefer-const": "error", + "prefer-template": "error", + "quotes": ["warn", "single"], + "semi": ["warn", "never"], + "space-before-blocks": ["error", { "functions": "always", "keywords": "always", "classes": "always" }], + "space-before-function-paren": ["error", "never"], + "strict": ["error", "global"], + "yoda": "error" + }, + "overrides": [{ + "files": [ "*.test.js", "*.spec.js", "*.steps.js" ], + "rules": { + "global-require": "off", + "max-lines-per-function": "off", + "max-lines": "off", + "max-statements": "off", + "no-magic-numbers": "off" + } + }] +} \ No newline at end of file diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100644 index 0000000..7a89e93 --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,28 @@ +#!/bin/bash + +set -e + +echo "POST-CHECKOUT" + +prevHEAD=$1 +newHEAD=$2 +checkoutType=$3 + +[[ $checkoutType == 1 ]] && checkoutType='branch' || checkoutType='file' ; + +echo " Checkout type: $checkoutType" +echo " prev HEAD: "`git name-rev --name-only $prevHEAD` +echo " new HEAD: "`git name-rev --name-only $newHEAD` + +# this is a file checkout – do nothing +if [ "$3" == "0" ]; then exit; fi + +BRANCH_NAME=$(git symbolic-ref --short -q HEAD) +NUM_CHECKOUTS=`git reflog --date=local | grep -o ${BRANCH_NAME} | wc -l` + +#if the refs of the previous and new heads are the same +#AND the number of checkouts equals one, a new branch has been created +if [ "$1" == "$2" ] && [ ${NUM_CHECKOUTS} -eq 1 ]; then + echo " new branch '$BRANCH_NAME' created" +fi +echo diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100644 index 0000000..a71bdcb --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,7 @@ +#!/bin/sh + +set -e # using the options command to abort script at first error +echo +echo "POST-COMMIT" +# ./node_modules/.bin/markdownlint --ignore node_modules . +echo diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..b23a03d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,76 @@ +#!/bin/sh + +set -e # using the options command to abort script at first error +echo +echo "PRE-COMMIT" + +EMAIL=$(git config user.email) + +# make sure the user has registered a valid university email address +if [[ $EMAIL != *"@coventry.ac.uk" ]]; then + echo " invalid config settings" + echo " Your registered email is currently '$EMAIL'" + echo " please run the following git commands:" + echo " $ git config user.email xxx@coventry.ac.uk" + echo " $ git config user.name 'zzz'" + echo " where 'xxx' is your university username" + echo " and 'zzz' is your name as it appears on your university ID badge" + echo + exit 1 +fi + +# see if the user is trying to merge a branch into master +branch="$(git rev-parse --abbrev-ref HEAD)" +if [[ $2 == 'merge' ]]; then + echo "merging branch" + if [[ "$branch" == "master" ]]; then + echo " trying to merge into the 'master' branch" + echo " you should push the local branch to GitHub" + echo " and merge to master using a pull request" + echo + exit 1 + fi +fi + +# see if the user is trying to commit to the master branch +if [ "$branch" = "master" ]; then + read -p " You are about to commit to the master branch, are you sure? [y|n] " -n 1 -r < /dev/tty + echo + if echo $REPLY | grep -E '^[Yy]$' > /dev/null + then + exit 0 # commit will execute + fi + exit 1 # commit will not execute +fi + +# is the current branch a direct child of the master branch? +# echo "checking parent branch" +# PARENT=$(git show-branch -a | grep -v `git rev-parse --abbrev-ref HEAD` | grep -v origin | sed 's/.*\[\(.*\)\].*/\1/' | grep -v -e '^$' | grep -v "^----$" +# echo "parent branch is $PARENT") + +# see if the user is trying to commit to the master branch +# echo " you are trying to commit to the '$branch' branch" +# if [ "$branch" = "master" ]; then +# echo " you can't commit directly to the master branch" +# echo " create a local feature branch first" +# echo +# exit 1 +# fi + +# check for valid branch name: + +# valid_branch_regex="^iss\d{3}\/[a-z\-]+$" + +# if [[ ! $local_branch =~ $valid_branch_regex ]] +# then +# echo "invalid branch name" +# echo " format is: 'iss000/issue-name'" +# echo " replacing '000' with the issue number and 'issue-name' with the issue name" +# echo " only lower-case letters and replace spaces in the issue name with dashes" +# echo " rename your branch and try again" +# exit 1 +# fi + +./node_modules/.bin/eslint . + +echo " commit successful..." diff --git a/.githooks/pre-merge-commit b/.githooks/pre-merge-commit new file mode 100644 index 0000000..128c360 --- /dev/null +++ b/.githooks/pre-merge-commit @@ -0,0 +1,4 @@ +#!/bin/sh + +set -e # using the options command to abort script at first error +echo "running the 'pre-merge-commit' script" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 0000000..b99229a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,18 @@ +#!/bin/sh + +echo +echo "PRE-PUSH" + +protected_branch='master' +current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,') + +if [ $protected_branch = $current_branch ] +then + read -p " You're about to push master, is that what you intended? [y|n] " -n 1 -r < /dev/tty + echo + if echo $REPLY | grep -E '^[Yy]$' > /dev/null + then + exit 0 # push will execute + fi + exit 1 # push will not execute +fi diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg new file mode 100644 index 0000000..79c8815 --- /dev/null +++ b/.githooks/prepare-commit-msg @@ -0,0 +1,37 @@ +#!/bin/sh + +# With thanks to Sergio Vaccaro + +set -e # using the options command to abort script at first error +echo +echo "PREPARE-COMMIT-MSG" + +# Branch to protect +PROTECTED_BRANCH="master" + +# Remote +REMOTE="" + +# Check for merges +if [[ $2 != 'merge' ]]; then + # Not a merge + ECHO " not a merge" + exit 0 +fi + +# Current branch +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Check if in PROTECTED_BRANCH +#if [[ "$CURRENT_BRANCH" != "$PROTECTED_BRANCH" ]]; then +# # Not in PROTECTED_BRANCH: can proceed +# ECHO " not in the ${PROTECTED_BRANCH} branch" +# exit 0 +#fi + +echo " you are trying to merge into the ${PROTECTED_BRANCH} branch" +echo " merging branches to master must be done by creating a pull request" +echo " this merge has been cancelled however you will need to" +echo " reset the operation before continuing by running git reset --merge" +echo +exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cde5261 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ + +.DS_Store +node_modules/ +coverage/ +screenshots/* +docs/ + +data/ +coverage/ +sessions/ +screenshots/ +__image_snapshots_/ +__diff_output__/ +trace/ +.node-persist/ + +*.db +*snap.png +*diff.png +*trace.json +*.0x + +config.json +#Should probably be re-added +#Or at least provide documentation on how to populate + +*.pptx +*.mp4 + +*.codio +*.c9 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..3f4455b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,42 @@ +image: node:latest + +stages: + - code-testing + - staging-server + - acceptance-testing + +linting: + stage: code-testing + script: + - npm install + - npm run linter + +dependency-checks: + stage: code-testing + script: + - npm install + - npm run dependency + +unit-testing: + stage: code-testing + script: + - npm install + - npm test + +code-coverage: + stage: code-testing + script: + - npm install + - npm run coverage + +coverage-report: + stage: staging-server + script: + - npm install + - npm run coverage + artifacts: + paths: + - docs + expire_in: 30 days + only: + - master diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..3dc3115 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,33 @@ + +{ + "line-length": false, + "no-inline-html": { + "allowed_elements": [ + "a", "br" + ] + }, + "header-increment": true, + "ul-style": { + "style": "dash" + }, + "ul-start-left": true, + "no-trailing-spaces": { + "br_spaces": 0 + }, + "no-hard-tabs": true, + "no-reversed-links": true, + "no-multiple-blanks": true, + "no-missing-space-atx": true, + "no-multiple-space-atx": true, + "blanks-around-headers": true, + "header-start-left": true, + "no-duplicate-header": true, + "single-h1": true, + "blanks-around-fences": true, + "blanks-around-lists": true, + "no-bare-urls": true, + "no-space-in-emphasis": true, + "no-space-in-code": true, + "no-space-in-links": true, + "fenced-code-language": true +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ff946a2 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ + +{ + "version": "0.2.0", + "configurations": [ + + { + "type": "node", + "request": "attach", + "name": "Attach by Process ID", + "processId": "${command:PickProcess}" + }, + { + "type": "node", + "request": "launch", + "name": "Jest", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["--runInBand"], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "disableOptimisticBPs": true, + "protocol": "inspector", + "windows": { + "program": "${workspaceFolder}/node_modules/jest/bin/jest", + } + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..77726b8 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ + +# Assignment Template + +This repository contains the base files for the assignment. You will need to create a _private duplicate_ in your module organisation. Carry out the following steps: + +1. Click on the **Use this template** button at the top of the screen. +2. Change the owner to your module using the dropdown list. +3. Change the repository name: + 1. If this is your first attempt use your University Username (the one you use to log into the university systems). + 2. If you are doing the module resit append `-resit` to the end. +4. In the **Description** field enter the name of your project topic (eg. `Auction`). +5. Clone your private repository (refer to the **setup** lab if you have forgotten how to do this). + +## Local Config Settings + +Before you make any commits you need to update the [local config settings](https://git-scm.com/book/en/v2/Getting-Started-First-Time-Git-Setup). Start by using the Terminal (or Git Bash on Windows) navigate inside the project. Once you are in this directory run the following commands, substituting you name as it appears on your ID badge and your university email address (without the `uni.` domain prefix). + +```bash +git config user.name 'John Doe' +git config user.email 'doej@coventry.ac.uk' +git config core.hooksPath .githooks +git config --add merge.ff false +``` + +## Configuring the Email + +The assignment template includes code to send an email validation link to the users when they register. This link needs to be clicked to enable the account and allow them to log in. For this to work you will need to configure the code to connect to an IMAP-enabled email account which it will use to send the emails. + +The simplest solution is to use a Gmail account and to do this you will need to carry out three steps: + +1. Go to the gmail settings screen and, in the "Forwarding and POP/IMAP" tab enable IMAP support. +2. Use the following link to enable less secure apps https://www.google.com/settings/security/lesssecureapps +3. Rename the `config.sample.json` file to `config.json` and update this with your account details. + +Now you can start working on the assignment. Remember to install all the dependencies listed in the `package.json` file. + +## Feature Branching + +You should not be committing directly to the **master** branch, instead each task or activity you complete should be in its own _feature branch_. **Don't attempt this until after the lecture that covers version control (git)**. You should following the following steps: + +1. Log onto GitHub and add an issue to the _issue tracker_, this is your _todo_ list. +2. Create a local feature branch making sure that the name of the branch includes both the issue _number_ and _title_ (in lower case). + 1. For example: `git checkout -b iss023/fix-login-bug`. + 2. You can see a list of all the local branches using `git branch`. +3. As you work on the issue make your local commits by: + 1. staging the files with `git add --all`. + 2. committing with the `no-ff` flag, eg. `git commit --no-ff -m 'detailed commit message here'`. +4. When the task is complete and all the tests pass, push the feature branch to GitHub. + 1. For example `git push origin iss023/fix-login-bug` would push the branch named above. + 2. Switch back to the _master_ branch with `git checkout master`. +5. Back on GitHub raise a **Pull Request** that merges this feature branch to the _master_ branch. +5. If there are no issues you can then merge the branch using the button in the _Pull Request_ interface. +6. Pull the latest version of the master branch code using `git pull origin master`. + diff --git a/__mocks__/nodemailer-promise.js b/__mocks__/nodemailer-promise.js new file mode 100644 index 0000000..a98cdee --- /dev/null +++ b/__mocks__/nodemailer-promise.js @@ -0,0 +1,9 @@ + +const config = function() { + return mailOptions => new Promise( (resolve, reject) => { + if(!mailOptions.to.includes('@')) return reject('MOCK ERROR: INVALID EMAIL') + resolve() + }) +} + +module.exports.config = config diff --git a/config.sample.json b/config.sample.json new file mode 100644 index 0000000..f2e41d8 --- /dev/null +++ b/config.sample.json @@ -0,0 +1,9 @@ + +{ + "host": "smtp.gmail.com", + "port": 465, + "secure": true, + "auth": { + "user": "jdoe@gmail.com", "pass": "password" + } +} diff --git a/cucumber/features/login.feature b/cucumber/features/login.feature new file mode 100644 index 0000000..f66c7e7 --- /dev/null +++ b/cucumber/features/login.feature @@ -0,0 +1,36 @@ + +Feature: Logging In + The user should be able to register a new account and log in to + access the secure page. + + Scenario: registering, validating and logging in + Given The browser is open on the home page + When I click on the "register" link + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I enter "jdoe@coventry.ac.uk" in the "email" field + When I click on the submit button + Then the heading should be "Validate Your Account" + When I enter the email link in the browser + Then the heading should be "Account Validated" + When I click on the "log in" link + Then the heading should be "Log In" + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I click on the submit button + Then the heading should be "Secure Page" + + Scenario: registering and logging in without validating + Given The browser is open on the home page + When I click on the "register" link + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I enter "jdoe@coventry.ac.uk" in the "email" field + When I click on the submit button + Then the heading should be "Validate Your Account" + When I access the homepage + Then the heading should be "Log In" + When I enter "jdoe" in the "username" field + When I enter "p455w0rd" in the "password" field + When I click on the submit button + Then the heading should be "Validate Your Account" diff --git a/cucumber/steps/page.js b/cucumber/steps/page.js new file mode 100644 index 0000000..75e0133 --- /dev/null +++ b/cucumber/steps/page.js @@ -0,0 +1,24 @@ + +const puppeteer = require('puppeteer') + +module.exports = class User { + + constructor(width, height) { + return (async() => { + this.delayMS = 5 + this.browser = await puppeteer.launch( + { + headless: true, + slowMo: this.delayMS, + args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage'] + }) + this.page = await this.browser.newPage() + await this.page.setViewport({ width, height }) + await this.page.goto('http://localhost:8080') + await this.page.evaluate(() => localStorage.clear()) + await this.page.reload() + console.log('constructor completed') + return this.page + })() + } +} diff --git a/cucumber/steps/todo.steps.js b/cucumber/steps/todo.steps.js new file mode 100644 index 0000000..934b750 --- /dev/null +++ b/cucumber/steps/todo.steps.js @@ -0,0 +1,117 @@ + +// https://github.com/cucumber/cucumber-js/blob/master/docs/support_files/hooks.md + +function sleep(ms) { //Helper function to slightly delay shell command + return new Promise(resolve => { + setTimeout(resolve,ms) + }) +} + +const { Before, BeforeAll, After, AfterAll, Given, When, Then } = require('cucumber') +const shell = require('child_process') +// const assert = require('assert') + +const Page = require('./page.js') + +let page // this is the page object we use to reference a web page + +BeforeAll( async() => { + console.log('BEFORE ALL') + shell.exec('node index.js') + await sleep(100) +}) + +AfterAll( async() => { + console.log('AFTER ALL') + page.close() + await shell.exec('pkill node') + return Promise.resolve() +}) + +Before( async() => { + console.log('BEFORE') + await shell.exec('rm -rf *.db') +}) + +After( async() => { + console.log('AFTER') +}) + +Given('The browser is open on the home page', async() => { + console.log('The browser is open on the home page') + page = await new Page(800, 600) +}) + +When('I click on the {string} link', async link => { + console.log(`I click on the "${link}" link`) +}) + +When('I enter {string} in the {string} field', async(value, field) => { + console.log(`I enter "${value}" in the "${field}" field`) + // await page.click(`#${field}`) //field represents the id attribute in html + // await page.keyboard.type(value) +}) + +When('I click on the submit button', async() => { + console.log('I click on the submit button') + // await page.click('#submit') +}) + +Then('the heading should be {string}', async heading => { + console.log(`the heading should be "${heading}"`) + // const text = await page.evaluate( () => { + // const dom = document.querySelector('h1') + // return dom.innerText + // }) + // assert.equal(heading, text) +}) + +When('I enter the email link in the browser', async() => { + console.log('I enter the email link in the browser') +}) + +When('I access the homepage', async() => { + console.log('I access the homepage') +}) + +Then('take a screenshot called {string}', async filename => { + await page.screenshot({ path: `screenshots/${filename}.png` }) +}) + +// Then('the list should contain {string} rows', async rowCount => { +// rowCount = Number(rowCount) +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr td:first-child') +// const arr = Array.from(dom) +// return arr.map(td => td.innerText) +// }) +// assert.equal(items.length, rowCount) +// }) + +// Then('the item should be {string}', async item => { +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr td:first-child') +// const arr = Array.from(dom) +// return arr.map(td => td.innerText) +// }) +// assert.equal(item, items[0]) +// }) + +// Then('the list should contain a single entry for {string}', async item => { +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr td:first-child') +// const arr = Array.from(dom).map(td => td.innerText) +// return arr +// }) +// const count = items.reduce( (acc, val) => (val === item ? acc += 1 : acc), 0) +// assert.equal(count, 1) +// }) + +// Then('the {string} quantity should be {string}', async(item, qty) => { +// const items = await page.evaluate( () => { +// const dom = document.querySelectorAll('table tr') +// // const arr = Array.from(dom) +// return dom +// }) +// assert.equal(2, 2) +// }) diff --git a/index.js b/index.js new file mode 100644 index 0000000..e1e996c --- /dev/null +++ b/index.js @@ -0,0 +1,31 @@ + +const Koa = require('koa') +const session = require('koa-session') +const staticDir = require('koa-static') +const views = require('koa-views') + +const apiRouter = require('./routes/routes') + +const app = new Koa() +app.keys = ['darkSecret'] + +const defaultPort = 8080 +const port = process.env.PORT || defaultPort + +app.use(staticDir('public')) +app.use(session(app)) +app.use(views(`${__dirname}/views`, { extension: 'handlebars' }, {map: { handlebars: 'handlebars' }})) + +app.use( async(ctx, next) => { + console.log(`${ctx.method} ${ctx.path}`) + ctx.hbs = { + authorised: ctx.session.authorised, + host: `${ctx.protocol}://${ctx.host}` + } + for(const key in ctx.query) ctx.hbs[key] = ctx.query[key] + await next() +}) + +app.use(apiRouter.routes(), apiRouter.allowedMethods()) + +module.exports = app.listen(port, async() => console.log(`listening on port ${port}`)) diff --git a/jest-test.config.js b/jest-test.config.js new file mode 100644 index 0000000..f1f7a55 --- /dev/null +++ b/jest-test.config.js @@ -0,0 +1,20 @@ + +module.exports = { + displayName: 'test', + verbose: true, + collectCoverage: true, + 'preset': 'jest-puppeteer', + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0 + } + }, + testPathIgnorePatterns: [ + '/node_modules/', + '/__tests__/fixtures/', + ], + roots: ['/unitTests'] +} diff --git a/jest.puppeteer.config.js b/jest.puppeteer.config.js new file mode 100644 index 0000000..8efcd34 --- /dev/null +++ b/jest.puppeteer.config.js @@ -0,0 +1,12 @@ + +module.exports = { + server: { + command: 'node index.js', + port: 8080, + }, + launch: { + headless: false, + devtools: true, + timeout: 30000 + } +} diff --git a/jsdoc.conf b/jsdoc.conf new file mode 100644 index 0000000..9fa536f --- /dev/null +++ b/jsdoc.conf @@ -0,0 +1,22 @@ + +{ + "tags": { + "allowUnknownTags": true, + "dictionaries": ["jsdoc","closure"] + }, + "source": { + "include": [ "." ], + "exclude": [ "node_modules" ], + "includePattern": ".+\\.js(doc|x)?$", + "excludePattern": "(^|\\/|\\\\)_" + }, + "plugins": ["jsdoc-route-plugin"], + "templates": { + "cleverLinks": false, + "monospaceLinks": false + }, + "opts": { + "destination": "docs/jsdoc", + "recurse": true + } +} diff --git a/modules/user.js b/modules/user.js new file mode 100644 index 0000000..8010be6 --- /dev/null +++ b/modules/user.js @@ -0,0 +1,148 @@ + +const crypto = require('crypto') +const bcrypt = require('bcrypt-promise') +const sqlite = require('sqlite-async') +const mailer = require('nodemailer-promise') + +// you will need to create this file with your email details +const config = require('../config.json') +const saltRounds = 10 + +module.exports = class User { + + constructor(dbName = ':memory:') { + return (async() => { + this.db = await sqlite.open(dbName) + // we need this table to store the user accounts + const sql = 'CREATE TABLE IF NOT EXISTS users\ + (id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT, pass TEXT, email TEXT,\ + token TEXT, timestamp INTEGER, validated INTEGER DEFAULT 0);' + await this.db.run(sql) + return this + })() + } + + /** + * registers a new user + * @param {String} user the chosen username + * @param {String} pass the chosen password + * @returns {Boolean} returns true if the new user has been added + */ + async register(user, pass, email) { + Array.from(arguments).forEach( val => { + if(val.length === 0) throw new Error('missing field') + }) + let sql = `SELECT COUNT(id) as records FROM users WHERE user="${user}";` + const data = await this.db.get(sql) + if(data.records !== 0) throw new Error(`username "${user}" already in use`) + sql = `SELECT COUNT(id) as records FROM users WHERE email="${email}";` + const emails = await this.db.get(sql) + if(emails.records !== 0) throw new Error(`email address "${email}" is already in use`) + pass = await bcrypt.hash(pass, saltRounds) + sql = `INSERT INTO users(user, pass, email) VALUES("${user}", "${pass}", "${email}")` + await this.db.run(sql) + return true + } + + /** + * send an email message + * @param {String} to the email address to send the email to + * @param {String} subject the subject line of the email + * @param {String} html the html-formatted body of the email + * @returns {Boolean} returns true if the email was sent + */ + async sendEmail(to, subject, html) { + try { + const sendEmail = mailer.config(config) + const from = 'noreply@email.com' + const message = { from, to, subject, html } + await sendEmail(message) + return true + } catch (err) { + throw new Error('not able to send email') + } + } + + /** + * checks to see if a set of login credentials are valid + * @param {String} username the username to check + * @param {String} password the password to check + * @returns {Boolean} returns true if credentials are valid + */ + async login(username, password) { + let sql = `SELECT count(id) AS count FROM users WHERE user="${username}";` + const records = await this.db.get(sql) + if(!records.count) throw new Error(`username "${username}" not found`) + sql = `SELECT pass, validated FROM users WHERE user = "${username}";` + const record = await this.db.get(sql) + if(!record.validated) throw new Error('this account has not been validated') + const valid = await bcrypt.compare(password, record.pass) + if(valid === false) throw new Error(`invalid password for account "${username}"`) + return true + } + + /** + * generates and stores a token for the specified user + * the token will only be valid up to the timestamp specified in expiry + * @param {String} username generates a valid token for the user + * @param {Number} expiry a timestamp indicating when the token expires + * @returns {String} the token + */ + async generateToken(username, expiry) { + const len = 16 + const token = crypto.randomBytes(len).toString('hex') + const sql = `SELECT count(id) AS count FROM users WHERE user="${username}";` + const records = await this.db.get(sql) + if(!records.count) throw new Error(`username "${username}" not found`) + const encToken = await bcrypt.hash(token, saltRounds) + const updateSQL = `UPDATE users SET token = "${encToken}",\ + timestamp = ${expiry} WHERE user="${username}"` + await this.db.run(updateSQL) + return token + } + + /** + * checks the supplied token is valid and deletes it from the database + * @param {String} username the username of the specified user + * @param {String} token the token to check against the database + * @param {Number} now the current UNIX timestamp + * @returns {Boolean} returns true if the token was valid + */ + async checkToken(username, token, now) { + const sql = `SELECT id, token, timestamp, validated FROM users WHERE user="${username}"` + const user = await this.db.get(sql) + if(user.validated) return true // the user has already validated their account + if(user.token === null) throw new Error(`no token found for user "${username}"`) + if(user.timestamp < now) throw new Error('token has expired') + const updateSQL = `UPDATE users SET token = null, timestamp = null, validated = 1 WHERE user="${username}"` + await this.db.run(updateSQL) + return true + } + + /** + * checks the status of an account (exists/validated) + * @param {String} username the username to check + * @returns {Boolean} returns true if the account exists and is validated + */ + async checkStatus(username) { + const user = await this.db.get(`SELECT validated FROM users WHERE user="${username}"`) + if(user === undefined) throw new Error(`the "${username}" account does not exist`) + if(user.validated === 0) throw new Error(`the "${username}" account has not been validated`) + return true + } + + /** + * sets the status of the account to 'validated' + * @param {String} username the username of the specified user + * @returns {Boolean} returns true if account has been validated + */ + async validateAccount(username) { + const sql = `UPDATE users SET validated = 1 WHERE user = "${username}"` + await this.db.run(sql) + return true + } + + async tearDown() { + await this.db.close() + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c1f2e4 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "10_auth", + "version": "1.1.3", + "description": "A simple dynamic website template to be used as the base for various projects", + "main": "index.js", + "engines": { + "node": "12.x" + }, + "scripts": { + "coverage": "./node_modules/.bin/jest --coverage 'unitTests/' && ./node_modules/.bin/istanbul check-coverage --statement 100 --branch 100 --function 100 --line 100", + "cucumber": "node_modules/.bin/cucumber-js ./cucumber/features -r ./cucumber/steps", + "packagedeps": "./node_modules/.bin/depcheck .", + "dependency": "./node_modules/.bin/dependency-check -i bcrypt --unused --no-dev . && node_modules/.bin/dependency-check --missing .", + "start": "node index.js", + "jsdoc": "node_modules/.bin/jsdoc -c jsdoc.conf", + "linter": "node_modules/.bin/eslint .", + "test": "./node_modules/.bin/jest --coverage unitTests/" + }, + "author": "", + "license": "ISC", + "dependencies": { + "bcrypt": "^3.0.8", + "bcrypt-promise": "^2.0.0", + "handlebars": "^4.7.3", + "koa": "^2.6.2", + "koa-body": "^4.0.8", + "koa-bodyparser": "^4.2.1", + "koa-router": "^7.4.0", + "koa-send": "^5.0.0", + "koa-session": "^5.10.1", + "koa-static": "^5.0.0", + "koa-views": "^6.1.5", + "mime-types": "^2.1.26", + "nodemailer-promise": "^2.0.0", + "sharp": "^0.25.2", + "sqlite-async": "^1.0.12" + }, + "devDependencies": { + "coverage": "^0.4.1", + "cucumber": "^6.0.5", + "depcheck": "^0.9.2", + "dependency": "0.0.1", + "dependency-check": "^4.1.0", + "eslint": "^5.15.2", + "handlebars-validate": "^0.1.2", + "http-status-codes": "^1.3.2", + "istanbul": "^0.4.5", + "jest": "^24.9.0", + "jest-cucumber": "^2.0.11", + "jest-image-snapshot": "^2.11.0", + "jest-puppeteer": "^4.3.0", + "jscpd": "^2.0.16", + "jsdoc": "^3.6.3", + "jsdoc-route-plugin": "^0.1.0", + "markdownlint": "^0.17.0", + "puppeteer": "^1.20.0", + "puppeteer-har": "^1.1.1", + "shelljs": "^0.8.3", + "start-server-and-test": "^1.10.6", + "supertest": "^4.0.2" + }, + "jest": { + "projects": [ + "/jest-test.config.js" + ], + "preset": "jest-puppeteer" + } +} diff --git a/public/avatars/avatar.png b/public/avatars/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..125a38930a39455cbb6b1873b696050a6ca232c9 GIT binary patch literal 56968 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 T#XL}^TZE;ds;g3^WE=560vv@b literal 0 HcmV?d00001 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..82339b3b1dbbcf4550b737faf99c7774196fb8cb GIT binary patch literal 5430 zcmcgwX>3$g6n=;X+3|<}Oe_lM4}(N0C5Rv{W&J^*wo?RyAcBe}5&;u}0*OVy7L=Ay zXf2DCb|BCKWlMolQL5~P(pm(H?9=IVT4p+(_4u9l<_-5P?F_|ulQZ|;bI*6abC-9Q zk)%%2V^W_!67o1{&f}6aK$4`mIHg_yeFk(dLWd$O6g@IYf<9UzqCvr6a2=!u;tfkR z^2|^uG_Wl^+PcCf8~An;Y_eeRr06G%J;p#^&`W#&+C~cwO(V;@`5-Yh&vvE5}7=D}8hx zzSiZsXwv%)bp1v^?_?0K0r<(~hP$>PS!Oz9AM8gja~C)xcwp8umJ^iSP?sl_;1=C2bSk~ zIqV~QVl4FGK3B%d6U`1WoP*7Cv2eqV&l#JUPn+vD?W*2P%gW}`tm3iqo|;^kKr@RH zY4PD%lwX-eR~DX!e8cgN$vb75Ey(67=P7uRfkXkImj>G|>W zXT}Kf9?R3aA>af^1Lr9{g9Fh;$_Uj zO$l^k#i+>nrk<;6AmH+WGxBYsuGwFCoxNu==CgNCpi{TXRX2vbuCbO1if2-j?re3h zNurQfd;WoQ1CIgX=!LSTaoLI0hQ7}~IF*{c56&3_xvB{G$g^!>r0?n(@2eryDTfPDUI#V#9aW zg7STZ?<{>w_Rz$F;Z%C1NHmG5>^o7Q;pZ4a&N~_`xcMwODJn;w2}JlghJocb$*)|6 zM6;;npzW%G-(M|XWg}Q{^O>a?*k{U>`xbMvSSk&7lL(#*;uuFO`zov&EV%jH)Lg2% zgzt+g|Kh(*E?9z>Xq;lZelGwwpWpu*zwftvA#(Y?#rzGTQa}Ew7yi5P_g5Visdyjc z@z<=s#M9dK*LSCv{F%%UeQ!jK5^1Z6D)T6KJ>Z&m?k z4s&xt950}*S?DE)JO+L>j?`HWTD#FjJNJ5MSBZx*QRcUd+Rk}eMzi`HHqe9156*rH zyYdU2@^}@jH*Irke2V^KDKA~wEO>iR1lLNDo6Cr&JM>i#tdtUu!-(<5xroL;azZ^F zI@+pt$MaqZF3lTHjRpDvXs_3UZruscS4*2{$lJ!Zr#=q0S@2C0F-D)@+om6Pm0Qix z&hJD+4D5^7IInszx) a { + margin-top: 3vw; + margin-right: 3vw; + float: right; + font-size: 1.3vw; +} + +header > img { + width: 7vw; + border-radius: 50%; + position: absolute; + left: 0.5vw; + top: 0.5vw; +} + +p, legend { + font-size: 1.5vw; +} + +main { + width: 100%; + margin-left: 3vw; + /* background-color: pink; */ +} + +article { + width: 50vw; + /* background-color: grey; + float: left; */ +} + +aside { + /* background-color: yellow; + float: right; */ + width: 50vw; +} + +main a { + color: black; + text-decoration: underline; +} + +main a:hover { + color: red; + text-decoration: none; +} + +section { + background-color: #DDD; + width: 24vw; + margin-top: 1vw; + padding: 1vw; + border-radius: 1vw; +} + +section > p { + margin: 0; + padding: 0; + margin-bottom: 0.5vw; +} + +section > h3 { + margin: 0; + padding: 0; + font-size: 1.8vw; +} + +small { + display: block; + text-align: right; +} + +/* ===== FORMS ===== */ + +fieldset { + position: relative; + width: 25vw; + border: 1px solid #CCC; + padding-top: 4vw; +} + +legend { + position:absolute; + top:0; + left:0; + width: 25.5vw; + background-color: #ccc; + border-bottom: 5px solid grey; + padding: 0.6vw; +} + +input[type="file"] { + display: none; +} + +input[type="submit"], header > a, .custom-file-upload { + background-color: grey; + border: 2px solid grey; + color: white; + padding: 0.4vw 0.8vw; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 1.5vw; + /* border-radius: 0.6vw; */ +} + +input[type="submit"]:hover, header > a:hover, .custom-file-upload:hover { + color: grey; + background-color: white; +} + +input[type="text"], input[type="email"], input[type="password"], textarea, select { + font-size: 1.5vw; + width: 25vw; + border: 1px solid #CCC; + padding: 0.5vw; + /* margin-bottom: 1vw; */ + /* border-radius: 0.6vw; */ +} + +textarea { + height: 10vw; + resize: none; +} + +select { + width: 26.3vw; + -webkit-appearance: none; + -webkit-border-radius: 0px; +} + +form > br { + line-height: 3vw; +} + +dl { + width: 35vw; + overflow: hidden; + /* background: #ff0; */ + padding: 0; + margin: 0; + font-size: 1.5vw; +} +dt { + float: left; + width: 10.5vw; + /* adjust the width; make sure the total of both is 100% */ + /* background: #cc0; */ + padding: 0.5vw; + margin: 0 +} +dd { + float: left; + width: 21vw; + /* adjust the width; make sure the total of both is 100% */ + /* background: #dd0; */ + padding: 0.5vw; + margin: 0 +} + +/* ===== TABLES ===== */ + +table { + /* border: 1px solid grey; */ + border-collapse: collapse; +} + +tr:nth-child(odd) { + background: #DDD; +} + +td { + /* border: 1px solid grey; */ + padding: 0.5vw; + font-size: 1.5vw; + width: 25vw; +} + +td:last-child { + width: 10vw; + text-align: right; +} diff --git a/routes/public.js b/routes/public.js new file mode 100644 index 0000000..cf176dd --- /dev/null +++ b/routes/public.js @@ -0,0 +1,108 @@ + +const Router = require('koa-router') +const koaBody = require('koa-body')({multipart: true, uploadDir: '.'}) + +const router = new Router() + +const User = require('../modules/user') +const dbName = 'website.db' + +/** + * The secure home page. + * + * @name Home Page + * @route {GET} / + */ +router.get('/', async ctx => { + try { + await ctx.render('index', ctx.hbs) + } catch(err) { + await ctx.render('error', ctx.hbs) + } +}) + + +/** + * The user registration page. + * + * @name Register Page + * @route {GET} /register + */ +router.get('/register', async ctx => await ctx.render('register')) + +/** + * The script to process new user registrations. + * + * @name Register Script + * @route {POST} /register + */ +router.post('/register', koaBody, async ctx => { + const user = await new User(dbName) + try { + // call the functions in the module + await user.register(ctx.request.body.user, ctx.request.body.pass, ctx.request.body.email) + const day = 86400, milliseconds = 1000 + const expiry = Math.floor(Date.now() / milliseconds) + day + const token = await user.generateToken(ctx.request.body.user, expiry) + const html = `

Paste this url into your browser

\ +

${ctx.hbs.host}/validate/${ctx.request.body.user}/${token}

` + await user.sendEmail(ctx.request.body.email, 'Validate Your Account', html) + ctx.redirect(`/postregister?msg=new user "${ctx.request.body.user}" added`) + } catch(err) { + ctx.hbs.msg = err.message + ctx.hbs.body = ctx.request.body + console.log(ctx.hbs) + await ctx.render('register', ctx.hbs) + } finally { + user.tearDown() + } +}) + +router.get('/postregister', async ctx => await ctx.render('validate')) + +router.get('/validate/:user/:token', async ctx => { + try { + console.log('VALIDATE') + console.log(`URL --> ${ctx.request.url}`) + if(!ctx.request.url.includes('.css')) { + console.log(ctx.params) + const milliseconds = 1000 + const now = Math.floor(Date.now() / milliseconds) + const user = await new User(dbName) + await user.checkToken(ctx.params.user, ctx.params.token, now) + ctx.hbs.msg = `account "${ctx.params.user}" has been validated` + await ctx.render('login', ctx.hbs) + } + } catch(err) { + await ctx.render('login', ctx.hbs) + } +}) + +router.get('/login', async ctx => { + console.log(ctx.hbs) + await ctx.render('login', ctx.hbs) +}) + +router.post('/login', koaBody, async ctx => { + const user = await new User(dbName) + ctx.hbs.body = ctx.request.body + try { + const body = ctx.request.body + await user.login(body.user, body.pass) + ctx.session.authorised = true + const referrer = body.referrer || '/secure' + return ctx.redirect(`${referrer}?msg=you are now logged in...`) + } catch(err) { + ctx.hbs.msg = err.message + await ctx.render('login', ctx.hbs) + } finally { + user.tearDown() + } +}) + +router.get('/logout', async ctx => { + ctx.session.authorised = null + ctx.redirect('/?msg=you are now logged out') +}) + +module.exports = router diff --git a/routes/routes.js b/routes/routes.js new file mode 100644 index 0000000..c5c86a8 --- /dev/null +++ b/routes/routes.js @@ -0,0 +1,12 @@ + +const Router = require('koa-router') + +const publicRouter = require('./public') +const secureRouter = require('./secure') + +const apiRouter = new Router() + +const nestedRoutes = [publicRouter, secureRouter] +for (const router of nestedRoutes) apiRouter.use(router.routes(), router.allowedMethods()) + +module.exports = apiRouter diff --git a/routes/secure.js b/routes/secure.js new file mode 100644 index 0000000..1fc95ce --- /dev/null +++ b/routes/secure.js @@ -0,0 +1,17 @@ + +const Router = require('koa-router') + +const router = new Router({ prefix: '/secure' }) + +router.get('/', async ctx => { + try { + console.log(ctx.hbs) + if(ctx.hbs.authorised !== true) return ctx.redirect('/login?msg=you need to log in&referrer=/secure') + await ctx.render('secure', ctx.hbs) + } catch(err) { + ctx.hbs.error = err.message + await ctx.render('error', ctx.hbs) + } +}) + +module.exports = router diff --git a/temp.js b/temp.js new file mode 100644 index 0000000..f44f1b8 --- /dev/null +++ b/temp.js @@ -0,0 +1,20 @@ + +const mailer = require('nodemailer-promise') +const config = require('./config.json') + +async function main() { + try { + const sendEmail = mailer.config(config) + const message = { + from: 'sender@email.com', // sender address + to: 'marktyers@gmail.com', // list of receivers + subject: 'Subject of your email', // Subject line + html: '

Your html here

'// plain text body + } + await sendEmail(message) + } catch (err) { + console.log(`error: ${err.message}`) + } +} + +main() diff --git a/unitTests/user.spec.js b/unitTests/user.spec.js new file mode 100644 index 0000000..6d6a462 --- /dev/null +++ b/unitTests/user.spec.js @@ -0,0 +1,202 @@ + +const Accounts = require('../modules/user.js') + +describe('register()', () => { + + test('register a valid account', async done => { + expect.assertions(1) + const account = await new Accounts() + const register = await account.register('doej', 'password', 'doej@gmail.com') + expect(register).toBe(true) + account.tearDown() + done() + }) + + test('register a duplicate username', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password', 'doej@gmail.com') + await expect( account.register('doej', 'password', 'doej@gmail.com') ) + .rejects.toEqual( Error('username "doej" already in use') ) + account.tearDown() + done() + }) + + test('error if blank username', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect( account.register('', 'password', 'doej@gmail.com') ) + .rejects.toEqual( Error('missing field') ) + done() + }) + + test('error if blank password', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect( account.register('doej', '', 'doej@gmail.com') ) + .rejects.toEqual( Error('missing field') ) + done() + }) + test('error if blank email', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect( account.register('doej', 'password', '') ) + .rejects.toEqual( Error('missing field') ) + done() + }) + test('error if duplicate email', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('bondj', 'p455w0rd', 'doej@gmail.com') + await expect( account.register('doej', 'password', 'doej@gmail.com') ) + .rejects.toEqual( Error('email address "doej@gmail.com" is already in use') ) + done() + }) +}) + +describe('sendEmail()', () => { + test('send email to valid user', async done => { + expect.assertions(1) + const account = await new Accounts() + const status = await account.sendEmail('marktyers@gmail.com', 'test', '

Test

') + expect(status).toBe(true) + done() + }) + test('throws error if invalid email', async done => { + expect.assertions(1) + const account = await new Accounts() + await expect(account.sendEmail('marktyers', 'test', '

Test

')) + .rejects.toEqual( Error('not able to send email') ) + done() + }) +}) + +describe('generateToken()', () => { + test('should generate a valid token', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000)) + expect(typeof token).toEqual('string') + done() + }) + test('should throw an error if invalid username', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.generateToken('baduser', Math.floor(Date.now() / 1000)) ) + .rejects.toEqual( Error('username "baduser" not found') ) + done() + }) +}) + +describe('checkToken()', () => { + test('check for a valid token', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000) + 100) + const valid = await account.checkToken('doej', token, Math.floor(Date.now() / 1000)) + expect(valid).toBe(true) + done() + }) + test('should return true if account already validated', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000) + 100) + await account.checkToken('doej', token, Math.floor(Date.now() / 1000)) // this will delete the token + const valid = await account.checkToken('doej', token, Math.floor(Date.now() / 1000)) + expect(valid).toBe(true) + done() + }) + test('throw an error if there is no token', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.checkToken('doej', 'xxxxxx', Math.floor(Date.now() / 1000)) ) + .rejects.toEqual(Error('no token found for user "doej"') ) + done() + }) + test('throw an error if the token has expired', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + const token = await account.generateToken('doej', Math.floor(Date.now() / 1000) - 100) + await expect( account.checkToken('doej', token, Math.floor(Date.now() / 1000)) ) + .rejects.toEqual(Error('token has expired') ) + done() + }) +}) + +describe('checkStatus()', () => { + test('the account not validated when created', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.checkStatus('doej') ) + .rejects.toEqual( Error('the "doej" account has not been validated') ) + done() + }) + test('an invalid username throws an error', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.checkStatus('baduser') ) + .rejects.toEqual( Error('the "baduser" account does not exist') ) + done() + }) +}) + +describe('validateAccount()', () => { + test('validating a real account', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await account.validateAccount('doej') + const status = await account.checkStatus('doej') + expect(status).toBe(true) + done() + }) +}) + +describe('login()', () => { + test('log in with valid credentials', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await account.validateAccount('doej') + const valid = await account.login('doej', 'password') + expect(valid).toBe(true) + done() + }) + + test('invalid username', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.login('roej', 'password') ) + .rejects.toEqual( Error('username "roej" not found') ) + done() + }) + + test('account not validated', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await expect( account.login('doej', 'bad') ) + .rejects.toEqual( Error('this account has not been validated') ) + done() + }) + + test('invalid password', async done => { + expect.assertions(1) + const account = await new Accounts() + await account.register('doej', 'password') + await account.validateAccount('doej') + await expect( account.login('doej', 'bad') ) + .rejects.toEqual( Error('invalid password for account "doej"') ) + done() + }) + +}) diff --git a/views/error.handlebars b/views/error.handlebars new file mode 100644 index 0000000..63db6df --- /dev/null +++ b/views/error.handlebars @@ -0,0 +1,20 @@ + + + + + + + ERROR + + + + + +
+

An Error Has Occurred

+
+
+

{{message}}

+
+ + diff --git a/views/index.handlebars b/views/index.handlebars new file mode 100644 index 0000000..0b5a128 --- /dev/null +++ b/views/index.handlebars @@ -0,0 +1,31 @@ + + + + + + + Home Page + + + + + +
+

Home

+ {{#if authorised}} + Log out + {{else}} + Log in + {{/if}} +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+

This is the home page. Users can see it without logging in.

+ {{#if authorised}} +

secure page

+ {{/if}} +
+ + diff --git a/views/login.handlebars b/views/login.handlebars new file mode 100644 index 0000000..acc65c4 --- /dev/null +++ b/views/login.handlebars @@ -0,0 +1,36 @@ + + + + + + + Log In + + + + + +
+

Log In

+ Register +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+
+

+
+ +

+

+
+ +

+ +

+
+

Home page

+
+ + diff --git a/views/register.handlebars b/views/register.handlebars new file mode 100644 index 0000000..69e90ea --- /dev/null +++ b/views/register.handlebars @@ -0,0 +1,38 @@ + + + + + + + Create an Account + + + + + +
+

Create an Account

+ Log in +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+
+

+
+ +

+

+
+ +

+

+
+ +

+

+
+
+ + diff --git a/views/secure.handlebars b/views/secure.handlebars new file mode 100644 index 0000000..3a4e2a7 --- /dev/null +++ b/views/secure.handlebars @@ -0,0 +1,25 @@ + + + + + + + Home Page + + + + + +
+

Secure Page

+ Log out +
+ {{#if msg}} +

{{msg}}

+ {{/if}} +
+

This is a secure page. Users need to have a valid account and be logged in to see it.

+

Home page

+
+ + diff --git a/views/validate.handlebars b/views/validate.handlebars new file mode 100644 index 0000000..ac6ddd5 --- /dev/null +++ b/views/validate.handlebars @@ -0,0 +1,21 @@ + + + + + + + Account Validation + + + + + +
+

Account Created

+
+
+

Your account has been successfully created and will need to be validated before use

+

Please check the email you registered and click on the link it contains.

+
+ +