~akiva/user-profiles-api-demo

3dc2151ebce4bd0a592bf7d0d2e5c026c8ca949b — Akiva Levy 2 years ago
Initial commit
A  => .gitignore +3 -0
@@ 1,3 @@
node_modules/*
**/node_modules/*
lib/**/db

A  => .npmrc +1 -0
@@ 1,1 @@
package-lock=false

A  => README.md +96 -0
@@ 1,96 @@
# user-profiles-crud-api

A minimal demonstration of a CRUD API for user profiles

For the sake of minimising the technical scope and time spent on this
MVP, various featurs are not implemented, preventing this from being
considered remotely close to production-ready. These are listed below,
under the _TODO_ heading.

## Install

After cloning the repository locally, simply install the sparse number 
of dependencies for the project via _npm_, `npm install`.

## Usage

In your local directory containing the cloned project, you may run 
`npm start` as you usually would to run the project using _npm_ 
directly. However, do note that the application currently uses an
environment variable, `JWT_SECRET`, to assign an HMAC secret for the
purpose of the JSON Web Token functionality. You can simply set this at
run time via `JWT_SECRET=somesecret npm start`.

Once running, you may connect to the service in whichever method you
prefer. As I prefer using `curl`, let's use that for the example:

```bash
curl -i -X POST \
  -d '{"username":"foo","name":"Foo McBar","email":"foo@bar.com","password":"Password123!"}' \
  -H "Content-Type: application/json" \
  http://localhost:8000/auth/register
```

Which returns a corresponding JSON response, such as:

```
{"id":"fZcp8B4l6xYjiJnwfbQNi","username":"foo","email":"foo@bar.com","name":"Foo McBar"}
```

You may now log into the auth controller to retrieve your JWT token:

```bash
curl -i -X POST \
  -d '{"username":"foo","password":"Pasword123!"}' \
  -H "Content-Type: application/json" \
  http://localhost:8000/auth/login
```

Which returns a corresponding JSON response, such as:

```
{"username":"foo","email":"foo@bar.com","name":"Foo McBar","accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Im4zeW9XU3o2VUNEdlpUN21USFNFayIsImlhdCI6MTY0MDMzNTk5NSwiZXhwIjoxNjQwNDIyMzk1fQ.N6Yks1ZvJxy7qIV_W1xAwCKLDcPrahW7aYeLwA3lJ2o"}
```

This token is set to expire 24 hours after time it was issued. Using
this token, you may now pass the authentication necessary to view user
profiles, as well as updating or deleting your own profile.

```bash
curl -i \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJLbk94NHd6bnFZbzFzLW5PSEp5cyIsImlhdCI6MTY0MDMzNjc0MSwiZXhwIjoxNjQwNDIzMTQxfQ.Fj-5n5ABSYFfRxwc_ti7TkvHgmSIO0_PaSrs38e_vlQ" \
  http://localhost:8000/users
```

Returning:

```
[{"key":"2KnOx4wznqYo1s-nOHJys","value":"{\"username\":\"foo\",\"hashedPassword\":\"$2b$10$QCE2DWZfwiYhzmvdX7BR.OQpfJUhuSGTjzMJV9kt148z/mnzrIcd6\",\"email\":\"foo@bar.com\",\"name\":\"Foo McBar\"}"}]
```

## Endpoints

## Tests

To run the test suite, run via _npm_, `npm t`.

## TODO and other missing features

- implement unique constraints to user profile data (ie. username,
  email)
- hardening of any sort, such as CSRF, CORS, DDoS prevention, etc.
- performance enhancement of any sort, such as caching, code
  minimisation, etc.
- robust error-handling
- special header tags
- a `dockerfile` for easy containerisation
- thorough tests including:
  - controller responses, incoming request properties and data types,
    duplicate records, etc.
  - status codes
  - validate request and response streams and properties in routes files
- better developer documentation
- robust jwt implementation
- configuration of process environment variables
- code coverage (`nyc`, for example)
- jwt token invalidation

A  => bin/postinstall +12 -0
@@ 1,12 @@
#!/usr/bin/env node

const { F_OK } = require('fs').constants
const { access, symlink } = require('fs').promises
const { resolve } = require('path')
const lib = resolve(__dirname, '../lib/')
const symlinkPath = resolve(__dirname, '../node_modules/_')

access(symlinkPath, F_OK)
  .catch(err => symlink(lib, symlinkPath))
  .then(() => process.stdout.write('> ./lib/ successfully symlinked as ./node_modules/_\n'))


A  => bin/preinstall +20 -0
@@ 1,20 @@
#!/usr/bin/env node

const { F_OK, R_OK } = require('fs').constants
const { access, readdir } = require('fs').promises
const { resolve, join } = require('path')
const cp = require('child_process')

function installDependency (cwd) {
  return Promise.resolve()
    .then(() => access(join(cwd, 'package.json'), F_OK | R_OK))
    .then(() =>
      cp.spawn('npm', ['i'], { env: process.env, cwd, stdio: 'inherit' })
    )
    .catch(() => {})
}

const lib = resolve(__dirname, '../lib/')

readdir(lib)
  .then(mods => Promise.all(mods.map(m => installDependency(join(lib, m)))))

A  => bin/seed.js +6 -0
@@ 1,6 @@
const userRepository = require('../lib/users/repository')

// userRepository.batch()
//   // .put('2021-12-18', { remaining: 350 })
//   // .put('2021-12-19', { remaining: 350 })
//   .write(() => console.log('Seeding users complete!'))

A  => index.js +17 -0
@@ 1,17 @@
process.title = require('./package.json').name

const argv = require('minimist')(process.argv.slice(2), {
  alias: { p: 'port' },
  default: { p: 8000 },
})

const routes = [
  require('_/auth/routes').bind(null, require('_/auth/controller')),
  require('_/users/routes').bind(null, require('_/users/controller')),
]

const server = require('_/server')(routes)

server.listen(process.env.PORT || argv.port, () =>
  console.log(`Server running at http://localhost:${server.address().port}`)
)

A  => lib/auth/controller.js +110 -0
@@ 1,110 @@
const SECRET = process.env.JWT_SECRET
const jwt = require('jsonwebtoken')
const User = require('_/users/model')
const service = require('./service')
const UserRepository = require('_/users/repository')
const { JWT_SECRET } = process.env

module.exports = (req, res) => {
  if (req === undefined || !(req instanceof Object))
    throw new Error('request object is required')
  if (res === undefined || !(res instanceof Object))
    throw new Error('response object is required')

  function register() {
    return new Promise((resolve, reject) => {
      let user = req?.body
      return User(user)
        .then(res => user = res && res)
        .then(user => UserRepository.create(user))
        .then(id => user = { id, ...User.serialise(user) })
        .then(() => handleResponse(201, user))
        .then(() => resolve(user))
        .catch(e => reject(e))
    })
  }

  function login() {
    return new Promise((resolve, reject) => {
      const { username, password } = req.body
      let loggedInUser
      return service.login({ username, password })
        .then(user => {
          loggedInUser = user
          return jwt.sign({ id: user.id }, JWT_SECRET, {
            expiresIn: 86400 // 24 hours
          })
        })
        .then(accessToken => {
          req.userId = loggedInUser.id
          return accessToken
        })
        .then(accessToken => handleResponse(200, {
          ...User.serialise(loggedInUser),
          accessToken })
        )
        .then(resolve)
        .catch(reject)
    })
  }

  function verifyToken (delegate) {
    return new Promise((resolve, reject) => {
      if (delegate === undefined || typeof delegate !== 'function')
        return reject({
          status: 500,
          message: new Error('delegate function is required')
        })
      const [type, token] = req?.headers['authorization']?.split(' ') || []
      if (type !== 'Bearer' || !token)
        return reject({
          status: 403,
          message: new Error('authorization token is required')
        })
      jwt.verify(token, JWT_SECRET, (err, decoded) => {
        if (err)
          return reject({ status: 401, message: new Error('unauthorized') })
        req.userId = decoded.id
        return delegate()
          .then(resolve)
          .catch(reject)
      })
    })
  }

  function handleResponse(status, message) {
    if (status !== parseInt(status, 10))
      throw new Error(status + ' handleResponse status must be a valid number')
    if (message instanceof Error)
      message = message.message
    message = typeof message === 'string'
      ? message
      : JSON.stringify(message)
    return res.writeHead(status, {
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(Buffer.from(message)),
    })
      .end(message)
  }

  function asyncErrorBoundary(delegate, defaultStatus = 500) {
    if (delegate === undefined || typeof delegate !== 'function')
      return Promise.reject({
        status: 500,
        message: new Error('delegate function is required')
      })
    return new Promise((resolve, reject) => delegate()
      .then(resolve)
      .catch((error = {}) => {
        const { status = defaultStatus, message = error } = error
        return reject(handleResponse(status, message))
      })
    )
  }

  return {
    register: () => asyncErrorBoundary(register),
    login: () => asyncErrorBoundary(login),
    verifyToken,
  }
}

A  => lib/auth/controller.test.js +187 -0
@@ 1,187 @@
process.env.NODE_ENV = 'test'
process.env.JWT_SECRET = 'secret'
const test = require('tape')
const { controller } = require('.')

const req = (props = {}) => ({ url: '', headers: {}, ...props })

const res = () => ({
  writeHead(status, opts) {
    this.statusCode = status
    return this
  },
  statusCode: 200,
  end: msg => msg,
})

test('Auth controller', function (t) {
  t.test('es a factory function', function (st) {
    st.equal(typeof controller, 'function')
    st.end()
  })

  t.test('requires a request and response argument', function (st) {
    st.throws(() => controller(), /required/)
    st.end()
  })

  t.test('returns an object', function (st) {
    st.doesNotThrow(() => controller({}, {}), /required/)
    st.end()
  })

  t.test('has static methods', function (st) {
    const ctrlr = controller(req(), res())
    st.equal(Object.keys(ctrlr).length, 3)
    st.ok(ctrlr.hasOwnProperty('register'))
    st.ok(ctrlr.hasOwnProperty('login'))
    st.ok(ctrlr.hasOwnProperty('verifyToken'))
    st.end()
  })

  t.test('#register()', function (st) {
    st.test('requires a valid username', st => {
      st.plan(1)
      const ctrlr = controller(req({
        body: {},
      }), res())
      ctrlr.register()
        .catch(e => st.ok(JSON.parse(e).username.some(e => /required/.test(e))))
    })

    st.test('requires a valid password', st => {
      st.plan(1)
      const ctrlr = controller(req({
        body: {},
      }), res())
      ctrlr.register()
        .catch(e => st.ok(JSON.parse(e).password.some(e => /required/.test(e))))
    })

    st.test('returns a user object', st => {
      st.plan(3)
      const ctrlr = controller(
        req({
          body: {
            username: 'foo',
            name: 'Foo Bar',
            password: 'Password123!',
            email: 'foo@bar.com',
          }
        }),
        res()
      )
      ctrlr.register()
        .then(user => {
          st.equal(typeof user, 'object')
          st.ok(user.hasOwnProperty('id'))
          st.ok(!user.hasOwnProperty('password'))
        })
    })
  })

  t.test('#login()', function (st) {
    st.test('requires a valid username', st => {
      st.plan(1)
      const ctrlr = controller(req({ body: {} }), res())
      ctrlr.login()
        .catch(e => st.ok(e => /required/.test(e)))
    })

    st.test('requires a valid password', st => {
      st.plan(1)
      const ctrlr = controller(req({ body: { username: 'foo' } }), res())
      ctrlr.login()
        .catch(e => console.log('SHIT HERE', e) || st.ok(e => /required/.test(e)))
   })

    st.test('returns a serialised user object with token as JSON', st => {
      st.plan(5)
      const newUser = {
        username: 'foo',
        name: 'Foo Bar',
        password: 'Password123!',
        email: 'foo@bar.com',
      }
      const ctrlr = controller(req({ body: newUser }), res())
      ctrlr.register()
        .then(user => {
          const { username, password } = newUser
          return controller(req({ body: { username, password }}), res())
            .login()
        })
        .then(user => {
          st.ok(typeof user === 'string')
          st.doesNotThrow(() => JSON.parse(user))
          st.ok(typeof JSON.parse(user) === 'object')
          st.ok(JSON.parse(user).hasOwnProperty('accessToken'))
          st.ok(!JSON.parse(user).hasOwnProperty('hashedPassword'))
        })
    })
  })

  t.test('#verifyToken()', function (st) {
    st.test('requires a delegate function', st => {
      st.plan(2)
      const response = res()
      controller(req(), response).verifyToken()
        .catch(e => {
          st.ok(/delegate.+function/i.test(e.message))
          st.equal(e.status, 500)
        })
    })

    st.test('requires an "Authorization" header', st => {
      st.plan(2)
      const response = res()
      const ctrlr = controller(req(), response)
      ctrlr.verifyToken(() => {})
        .catch(e => {
          st.ok(/authorization token/i.test(e.message))
          st.equal(e.status, 403)
        })
    })

    st.test('requires a valid access token', st => {
      st.plan(2)
      const response = res()
      const ctrlr = controller(req({
        headers: { 'Authorization': 'Bearer 123' },
      }), response)
      ctrlr.verifyToken(() => {})
        .catch(e => {
          st.ok(/unauthorized/i.test(e.message))
          st.equal(e.status, 401)
        })
    })

    st.test('adds authorized user ID to the request and calls the delegate', st => {
      st.plan(2)
      const newUser = {
        username: 'foo',
        name: 'Foo Bar',
        password: 'Password123!',
        email: 'foo@bar.com',
      }
      controller(req({ body: newUser }), res()).register()
        .then(user => {
          const { username, password } = newUser
          return controller(req({ body: { username, password }}), res()).login()
        })
        .then(user => controller(req({ body: newUser }), res()).login())
        .then(user => {
          const request = req({
            headers: {
              'Authorization': 'Bearer ' + JSON.parse(user).accessToken
            },
          })
          return controller(request, res())
            .verifyToken(() => Promise.resolve([true, request]))
        })
        .then(([response, request]) => {
          st.ok(response)
          st.ok(request.hasOwnProperty('userId'))
        })
     })
  })
})

A  => lib/auth/index.js +9 -0
@@ 1,9 @@
const controller = require('./controller')
const service = require('./service')
const routes = require('./routes')

module.exports = {
  controller,
  service,
  routes,
}

A  => lib/auth/index.test.js +17 -0
@@ 1,17 @@
const test = require('tape')
const feature = require('.')

test('Auth feature', function (t) {
  t.test('returns an object', function (st) {
    st.equal(typeof feature, 'object')
    st.end()
  })

  t.test('has static methods', function (st) {
    st.equal(Object.keys(feature).length, 3)
    st.ok(feature.hasOwnProperty('controller'))
    st.ok(feature.hasOwnProperty('service'))
    st.ok(feature.hasOwnProperty('routes'))
    st.end()
  })
})

A  => lib/auth/package.json +14 -0
@@ 1,14 @@
{
  "name": "auth",
  "version": "1.0.0",
  "description": "Authentication feature domain",
  "main": "index.js",
  "private": true,
  "dependencies": {
    "jsonwebtoken": "^8.5.1"
  },
  "devDependencies": {
    "level-mem": "^6.0.1",
    "tape": "^5.3.2"
  }
}

A  => lib/auth/routes.js +12 -0
@@ 1,12 @@
module.exports = (controller, req, res) => {
  if (controller === undefined || !(controller instanceof Object))
    throw new Error('controller is required')

  controller = controller(req, res)

  if (/^\/auth\/login\/?$/.test(req.url) && req.method === 'POST')
    return controller.login()

  if (/^\/auth\/register\/?$/.test(req.url) && req.method === 'POST')
    return controller.register()
}

A  => lib/auth/routes.test.js +31 -0
@@ 1,31 @@
const test = require('tape')
const { routes } = require('.')

const controller = (req, res) => ({
  register: () => true,
  login: () => true,
})

test('Auth routes', function (t) {
  t.test('is a function', function (st) {
    st.equal(typeof routes, 'function')
    st.end()
  })

  t.test('requires a controller', function (st) {
    st.throws(() => routes(), /controller.+required/i)
    st.end()
  })

  t.test('matches POST /auth/login(/)', function (st) {
    st.ok(routes(controller, { method: 'POST', url: '/auth/login' }, {}))
    st.ok(routes(controller, { method: 'POST', url: '/auth/login/' }, {}))
    st.end()
  })

  t.test('matches POST /auth/register(/)', function (st) {
    st.ok(routes(controller, { method: 'POST', url: '/auth/register' }, {}))
    st.ok(routes(controller, { method: 'POST', url: '/auth/register/' }, {}))
    st.end()
  })
})

A  => lib/auth/service.js +24 -0
@@ 1,24 @@
const repository = require('_/users/repository')
const User = require('_/users/model')

module.exports = {
  login: ({ username, password } = {}) =>
    new Promise((resolve, reject) => {
      if (username === undefined || typeof username !== 'string')
        return reject('username is required')
      if (password === undefined || typeof password !== 'string')
        return reject('password is required')
      let loggedInUser
      return repository.findBy('username', username)
        .then(user => loggedInUser = user && user ? user : reject('username not found'))
        .then(user => User.comparePassword(
          password,
          user.hashedPassword || ''
        ))
        .then(match => match
          ? resolve(loggedInUser)
          : reject('username and password do not match')
        )
        .catch(reject)
    }),
}

A  => lib/auth/service.test.js +57 -0
@@ 1,57 @@
process.env.NODE_ENV = 'test'
const test = require('tape')
const { service } = require('.')
const { repository } = require('_/users')

test('Auth service', function (t) {
  t.test('returns an object', function (st) {
    st.equal(typeof service, 'object')
    st.end()
  })

  t.test('has static methods', function (st) {
    st.equal(Object.keys(service).length, 1)
    st.ok(service.hasOwnProperty('login'))
    st.end()
  })

  t.test('#login()', function (st) {
    st.test('requires a username', st => {
      st.plan(2)
      service.login()
        .catch(e => st.ok(/username is required/.test(e)))
        .then(() => service.login({ username: 'foo' }))
        .catch(e => st.notOk(/username is required/.test(e)))
    })

    st.test('requires a password', st => {
      st.plan(2)
      service.login({ username: 'foo' })
        .catch(e => st.ok(/password is required/.test(e)))
        .then(() => service.login({ username: 'foo', password: 'foo' }))
        .catch(e => st.notOk(/password is required/.test(e)))
    })

    st.test('throws an error if the username was not found', st => {
      st.plan(1)
      service.login({ username: 'jim-bob', password: 'secretsauce' })
        .catch(e => st.ok(/username not found/.test(e)))
    })

    st.test('throws an error if the username and password are invalid', st => {
      st.plan(1)
      const user = {
        username: 'foo',
        name: 'Foo Bar',
        password: 'Password123!',
        email: 'foo@bar.com',
      }
      repository.create(user)
        .then(() => service.login({
          username: user.username,
          password: 'fishyfishfish'
        }))
        .catch(e => st.ok(/username and password do not match/.test(e)))
    })
  })
})

A  => lib/server/index.js +36 -0
@@ 1,36 @@
const http = require('http')
const url = require('url')
const { createReadStream } = require('fs')

module.exports = (routes = []) => http.createServer((req, res) => {
  req.body = req.body || {}
  req.queryParams = new URLSearchParams(req.url.split('?')[1])
  req.url = url.parse(req.url).pathname

  req.on('data', chunk => {
    try {
      const { 'content-type': contentType } = req.headers
      if (/application\/json/.test(contentType)) {
        body = JSON.parse(chunk.toString())
        req.body = Object.assign({}, req.body, body)
      }
    }
    catch (e) {
      const { message } = e
      return res.writeHead(500, {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(Buffer.from(message)),
      }).end(JSON.stringify(message))
    }
  })

  req.on('end', async () => {
    ;[...routes, function (req, res) {
      const message = 'not found'
      return res.writeHead(404, {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(Buffer.from(message)),
      }).end(message)
    }].some(route => res.writableEnded || route(req, res))
  })
})

A  => lib/server/index.test.js +22 -0
@@ 1,22 @@
const test = require('tape')
const server = require('.')
const http = require('http')

test('server', function (t) {
  t.test('returns a function', function (st) {
    st.equal(typeof server, 'function')
    st.end()
  })

  t.test('accepts an array of routes', function (st) {
    st.equal(typeof server([]), 'object')
    st.equal(typeof server(), 'object')
    st.equal(server().__proto__, http.createServer().__proto__)
    st.end()
  })

  t.test('exposes a .listen method', function (st) {
    st.equal(typeof server().listen, 'function')
    st.end()
  })
})

A  => lib/users/controller.js +111 -0
@@ 1,111 @@
const model = require('./model')
const repository = require('./repository')
const authController = require('_/auth/controller')

module.exports = (req, res) => {
  if (req === undefined || !(req instanceof Object))
    throw new Error('request object is required')
  if (res === undefined || !(res instanceof Object))
    throw new Error('response object is required')

  const verifyToken = authController(req, res).verifyToken

  function validId (id) {
    return /^([0-9a-z_-]){21}\/?$/i.test(id)
  }

  function getIdUrlParameter(url) {
    return req.url.replace(/^\/.+\//, '')
  }

  function getUser() {
    return new Promise((resolve, reject) => {
      const id = getIdUrlParameter(req.url)
      if (!validId(id))
        return reject(new Error('id parameter is required'))
      return repository.get(id)
        .then(user => handleResponse(user) && resolve(user))
        .catch(reject)
    })
  }

  function list() {
    return repository.list().then(handleResponse)
  }

  function register() {
    return new Promise((resolve, reject) => {
      let user = req?.body
      if (!user) return reject(new Error('user payload is required'))
      return model(user)
        .then(res => user = res && res)
        .then(user => repository.create(user))
        .then(id => user = { id, ...model.serialise(user) })
        .then(() => res.statusCode = 201 && handleResponse(user))
        .then(() => resolve(user))
        .catch(e => reject(e))
    })
  }

  function remove() {
    return new Promise((resolve, reject) => {
      const id = getIdUrlParameter(req.url)
      if (!validId(id))
        return reject(new Error('id parameter is required'))
      if (id !== req.userId) {
        req.statusCode = 403
        return reject(new Error('forbidden'))
      }
      return repository.remove(id)
        .then(resolve)
        .catch(reject)
    })
  }

  function put() {
    return new Promise((resolve, reject) => {
      const id = getIdUrlParameter(req.url)
      if (!validId(id))
        return reject(new Error('id parameter is required'))
      let user = req?.body
      if (!user) return reject(new Error('user payload is required'))
      if (id !== req.userId) {
        req.statusCode = 403
        return reject(new Error('forbidden'))
      }
      return repository.put(id, user)
        .then(resolve)
        .catch(reject)
    })
  }

  function handleResponse(message) {
    if (message instanceof Error)
      message = message.message
    message = typeof message === 'string'
      ? message
      : JSON.stringify(message)
    return res.writeHead(res.statusCode || 200, {
      'Content-Type': 'application/json',
      'Content-Length': Buffer.byteLength(Buffer.from(message)),
    }).end(message)
  }

  function asyncErrorBoundary(delegate, defaultStatus = 500) {
    return new Promise((resolve, reject) => delegate()
      .then(resolve)
      .catch((error = {}) => {
        const { status = defaultStatus, message = error } = error
        return reject(handleResponse(message))
      })
    )
  }

  return {
    register: () => asyncErrorBoundary(register),
    list: () => asyncErrorBoundary(verifyToken.bind(null, list)),
    getUser: () => asyncErrorBoundary(verifyToken.bind(null, getUser)),
    remove: () => asyncErrorBoundary(verifyToken.bind(null, remove)),
    put: () => asyncErrorBoundary(verifyToken.bind(null, put)),
  }
}

A  => lib/users/controller.test.js +322 -0
@@ 1,322 @@
process.env.NODE_ENV = 'test'
const test = require('tape')
const { controller } = require('.')
const { controller: authController } = require('_/auth')

const request = (props = {}) => ({
  url: '',
  headers: {},
  ...props
})

const res = {
  writeHead(status, opts) {
    return this
  },
  end: msg => msg,
}

const testUser = {
  username: 'foo',
  name: 'Foo Bar',
  password: 'Password123!',
  email: 'foo@bar.com',
}

function authenticateUser(user, req, res) {
  req.body = user
  return authController(req, res).register()
    .then(data => {
      user.id = data.id
      req.body = {
        username: user.username,
        password: user.password,
      }
      return authController(req, res).login()
    })
    .then(user => req.headers['Authorization'] =
      'Bearer ' + JSON.parse(user).accessToken
    )
}

test('User controller', function (t) {
  t.test('is a factory function', function (st) {
    st.equal(typeof controller, 'function')
    st.end()
  })

  t.test('requires an request and response argument', function (st) {
    st.throws(() => controller(), /required/)
    st.end()
  })

  t.test('returns an object', function (st) {
    st.doesNotThrow(() => controller({}, {}), /required/)
    st.end()
  })

  t.test('has static methods', function (st) {
    const ctrlr = controller(request(), res)
    st.equal(Object.keys(ctrlr).length, 5)
    st.ok(ctrlr.hasOwnProperty('list'))
    st.ok(ctrlr.hasOwnProperty('getUser'))
    st.ok(ctrlr.hasOwnProperty('register'))
    st.ok(ctrlr.hasOwnProperty('remove'))
    st.ok(ctrlr.hasOwnProperty('put'))
    st.end()
  })

  t.test('#getUser()', function (st) {
    st.test('requires an authorization token', st => {
      st.plan(1)
      controller(request(), res).getUser()
        .catch(e => st.ok(/authorization token/.test(e)))
    })

    st.test('requires an ID in the requested URL', st => {
      st.plan(2)
      const req = request()
      authenticateUser(testUser, req, res)
        .then(() => controller(req, res).getUser())
        .catch(e => st.ok(/id parameter is required/.test(e)))
        .then(() => controller({ ...req, url: '/users/' }, res).getUser())
        .catch(e => st.ok(/id parameter is required/.test(e)))
    })

    st.test('requires a valid ID', st => {
      st.plan(1)
      const req = request()
      authenticateUser(testUser, req, res)
        .then(() => controller({ ...req, url: '/users/1'}, res).getUser())
        .catch(e => st.ok(/id parameter is required/.test(e)))
    })

    st.test('returns a user object', st => {
      st.plan(2)
      const req = request()
      authenticateUser(testUser, req, res)
        .then(_ => {
          req.url = '/users/' + req.userId
          return controller(req, res).getUser()
        })
        .then(row => {
          st.equal(typeof row, 'object')
          st.equal(row.name, 'Foo Bar')
        })
    })
  })

  t.test('#register()', function (st) {
    st.test('requires a user payload in the request', st => {
      st.plan(1)
      const ctrlr = controller(request(), res)
      ctrlr.register()
        .catch(e => st.ok(/user payload is required/.test(e)))
    })

    st.test('requires a valid username', st => {
      st.plan(1)
      const ctrlr = controller(request({
        url: '/users',
        body: {},
      }), res)
      ctrlr.register()
        .catch(e => st.ok(JSON.parse(e).username.some(e => /required/.test(e))))
    })

    st.test('requires a valid password', st => {
      st.plan(1)
      const ctrlr = controller(request({
        body: {},
      }), res)
      ctrlr.register()
        .catch(e => st.ok(JSON.parse(e).password.some(e => /required/.test(e))))
    })

    st.test('requires a valid email', st => {
      st.plan(1)
      const ctrlr = controller(request({
        url: '/users',
        body: {},
      }), res)
      ctrlr.register()
        .catch(e => st.ok(JSON.parse(e).email.some(e => /required/.test(e))))
    })

    st.test('requires a valid name', st => {
      st.plan(1)
      const ctrlr = controller(request({
        url: '/users',
        body: {},
      }), res)
      ctrlr.register()
        .catch(e => st.ok(JSON.parse(e).name.some(e => /required/.test(e))))
    })

    st.test('returns a user object', st => {
      st.plan(3)
      const ctrlr = controller(
        request({
          body: {
            username: 'foo',
            name: 'Foo Bar',
            password: 'Password123!',
            email: 'foo@bar.com',
          }
        }),
        res
      )
      ctrlr.register()
        .then(user => {
          st.equal(typeof user, 'object')
          st.ok(user.hasOwnProperty('id'))
          st.ok(!user.hasOwnProperty('password'))
        })
    })
  })

  t.test('#list()', function (st) {
    st.test('returns an array of users', st => {
      st.plan(2)
      const req = request()
      authenticateUser(testUser, req, res)
        .then(() => controller(req, res).list())
        .then(users => {
          users = JSON.parse(users)
          st.ok(Array.isArray(users))
          st.equal(typeof users[0], 'object')
        })
    })
  })

  t.test('#remove()', function (st) {
    st.test('requires an ID in request.url', st => {
      st.plan(1)
      const req = request({ url: '/users/' })
      authenticateUser(testUser, req, res)
        .then(() => controller(req, res).remove())
        .catch(e => st.ok(/id parameter is required/.test(e)))
    })

    st.test('requires a valid ID', st => {
      st.plan(1)
      const req = request({ url: '/users/1' })
      authenticateUser(testUser, req, res)
        .then(() => controller(req, res).remove())
        .catch(e => st.ok(/id parameter is required/.test(e)))
    })

    st.test('allows a user to delete their own profile', st => {
      st.plan(1)
      const req = request()
      authenticateUser(testUser, req, res)
        .then(() => {
          req.url = '/users/' + req.userId
          return controller(req, res).remove()
        })
        .then(_ => controller(req, res).getUser())
        .catch(e => st.ok(/key not found/i.test(e)))
    })

    st.test('does not allow for deleting another person\'s profile', st => {
      st.plan(1)
      const req = request()
      const req2 = request()
      const testUser2 = {
        username: 'bar',
        name: 'Bar Baz',
        password: 'Password123!',
        email: 'bar@baz.com',
      }
      Promise.all([
        authenticateUser(testUser, req, res),
        authenticateUser(testUser2, req2, res),
      ])
        .then(() => {
          req.url = '/users/' + req2.userId
          return controller(req, res).remove()
        })
        .catch(e => {
          st.ok(/forbidden/i.test(e))
        })
    })
  })

  t.test('#put()', function (st) {
    st.test('requires a user payload in the request', st => {
      st.plan(1)
      const req = request()
      authenticateUser(testUser, req, res)
        .then(token => controller({
          ...req,
          body: undefined,
          url: '/users/' + req.userId
        }, res).put())
        .catch(e => st.ok(/user payload is required/.test(e)))
    })

    st.test('requires a valid ID', st => {
      st.plan(1)
      const req = request({ url: '/users/1' })
      authenticateUser(testUser, req, res)
        .then(() => controller(req, res).put())
        .catch(e => st.ok(/id parameter is required/.test(e)))
    })

    st.test('allows a user to update their own profile', st => {
      st.plan(1)
      const req = request()
      authenticateUser(testUser, req, res)
        .then(() => {
          req.url = '/users/' + req.userId
          req.body = { ...testUser, username: 'bar' }
          return controller(req, res).put()
        })
        .then(_ => controller(req, res).getUser())
        .then(user => st.notEqual(user.username, testUser.username))
    })

    st.test('does not allow for updating another person\'s profile', st => {
      st.plan(1)
      const req = request()
      const req2 = request()
      const testUser2 = {
        username: 'bar',
        name: 'Bar Baz',
        password: 'Password123!',
        email: 'bar@baz.com',
      }
      Promise.all([
        authenticateUser(testUser, req, res),
        authenticateUser(testUser2, req2, res),
      ])
        .then(() => {
          req.url = '/users/' + req2.userId
          req.body = { ...testUser, username: 'foobarbaz' }
          return controller(req, res).put()
        })
        .catch(st.ok)
    })

    st.test('returns the updated user object', st => {
      st.plan(3)
      const ctrlr = controller(
        request({
          body: {
            username: 'foo',
            name: 'Foo Bar',
            password: 'Password123!',
            email: 'foo@bar.com',
          }
        }),
        res
      )
      ctrlr.register()
        .then(user => {
          st.equal(typeof user, 'object')
          st.ok(user.hasOwnProperty('id'))
          st.ok(!user.hasOwnProperty('password'))
        })
    })
  })
})

A  => lib/users/index.js +11 -0
@@ 1,11 @@
const controller = require('./controller')
const model = require('./model')
const repository = require('./repository')
const routes = require('./routes')

module.exports = {
  controller,
  model,
  repository,
  routes,
}

A  => lib/users/index.test.js +18 -0
@@ 1,18 @@
const test = require('tape')
const feature = require('.')

test('User feature', function (t) {
  t.test('returns an object', function (st) {
    st.equal(typeof feature, 'object')
    st.end()
  })

  t.test('has static methods', function (st) {
    st.equal(Object.keys(feature).length, 4)
    st.ok(feature.hasOwnProperty('controller'))
    st.ok(feature.hasOwnProperty('model'))
    st.ok(feature.hasOwnProperty('repository'))
    st.ok(feature.hasOwnProperty('routes'))
    st.end()
  })
})

A  => lib/users/model.js +71 -0
@@ 1,71 @@
const assert = require('assert')
const bcrypt = require('bcrypt')

module.exports = User

async function User(props = {}) {
  return Promise.resolve()
    .then(() => {
      const errors = {}
      const fields = ['name', 'username', 'email', 'password']

      fields.forEach(field => {
        const value = props[field]
        errors[field] = errors[field] || []
        if (!props.hasOwnProperty(field))
          errors[field].push('is required')
        if (typeof props[field] !== 'string')
          errors[field].push('must be a string')
        switch (field) {
          case 'name':
            if (!/^([A-Z][a-z]+([ ]?[a-z]?['-]?[A-Z][a-z]+)*)$/.test(value))
              errors[field].push('must be a valid name')
            break;
          case 'username':
            if (!/^[a-zA-Z][a-zA-Z0-9-_]{2,32}$/.test(value))
              errors[field].push('must be a valid username (3-32 '
                + 'characters long, including A-Z, a-z, 0-9, and _')
            break;
          case 'email':
            if (!/^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/.test(value))
              errors[field].push('must be a valid e-mail address')
            break;
          case 'password':
            if (!/((?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\W]).{8,32})/gm.test(value))
              errors[field].push('must be 8-32 characters long, including '
                + 'at least one of each of the following: an uppercase '
                + 'letter, a lowercase letter, a number, and a special '
                + 'character')
            break;
        }
        if (!errors[field].length) delete errors[field]
      })

      if (Object.keys(errors).length) return Promise.reject(errors)

      const {
        username,
        password,
        email,
        name,
      } = props

      return bcrypt.hash(password, 10)
        .then(hashedPassword => ({
          username,
          hashedPassword,
          email,
          name,
        }))
    })
}

User.serialise = function serialise (user) {
  return ({
    username: user.username,
    email: user.email,
    name: user.name,
  })
}

User.comparePassword = bcrypt.compare

A  => lib/users/model.test.js +151 -0
@@ 1,151 @@
const test = require('tape')
const model = require('./model')

test('User model', function (t) {
  t.test('has class methods', function (st) {
    st.equal(Object.keys(model).length, 2)
    st.ok(model.hasOwnProperty('comparePassword'))
    st.ok(model.hasOwnProperty('serialise'))
    st.end()
  })

  t.test('returns a errors object', function (st) {
    st.plan(2)
    model({}).catch(errors => {
      st.equal(typeof errors, 'object')
      st.equal(Object.keys(errors).length, 4)
    })
  })

  t.test('requires a valid username', function (st) {
    st.plan(6)
    model().catch(errors => st.ok(errors.username.some(e => /required/i.test(e))))
    model({ username: 1 })
      .catch(errors => st.ok(errors.username.some(e => /string/i.test(e))))
    ;[
      'f',
      '   ',
      'abcdefghijklmnopqrstuvwxyz1234567890',
    ].forEach(username => model({ username })
      .catch(errors => st.ok(errors.username.some(e => /valid/i.test(e))))
    )
    model({ username: 'foobar' }).catch(errors => st.notOk(errors.username))
  })

  t.test('requires a valid name', function (st) {
    st.plan(8)
    model().catch(errors => st.ok(errors.name.some(e => /required/i.test(e))))
    model({ name: 1 })
      .catch(errors => st.ok(errors.name.some(e => /string/i.test(e))))
    ;[
      'f',
      '   ',
      'foo bar',
      'abcdefghijklmnopqrstuvwxyz1234567890',
    ].forEach(name => model({ name })
      .catch(errors => st.ok(errors.name.some(e => /valid/i.test(e))))
    )
    model({ name: 'Foo Bar' }).catch(errors => st.notOk(errors.name))
    model({ name: 'Foo Bar-Baz' }).catch(errors => st.notOk(errors.name))
  })

  t.test('requires a valid email address', function (st) {
    st.plan(4)
    model().catch(errors => st.ok(errors.email.some(e => /required/i.test(e))))
    model({ email: 1 })
      .catch(errors => st.ok(errors.email.some(e => /string/i.test(e))))
    model({ email: 'fishyfishfish'})
      .catch(errors => st.ok(errors.email.some(e => /valid/i.test(e))))
    model({ email: 'valid.password@domain.com!' })
      .catch(errors => st.notOk(errors.email))
  })

  t.test('requires a valid password', function (st) {
    st.plan(7)
    model().catch(errors => st.ok(errors.password.some(e => /required/i.test(e))))
    model({ password: 1 })
      .catch(errors => st.ok(errors.password.some(e => /string/i.test(e))))
    ;[
      '1',
      'password',
      'thisisfartoolongforourneeds.....wwwweeeeeeeeeeeeeeeeeeeee',
      '61532765127365^%$!@',
    ].forEach(password => model({ password })
      .catch(errors => st.ok(errors.password))
    )
    model({ password: 'GoodPassword123!' })
      .catch(errors => st.notOk(errors.password))
  })

  t.test('returned object', function (st) {
    const user = {
      username: 'foo',
      name: 'Foo Bar',
      email: 'foo@bar.com',
      password: 'Secret5auce!',
    }

    st.test('contains a username property', function (st) {
      st.plan(1)
      model(user).then(u => st.equal(u.username, user.username))
    })

    st.test('contains a name property', function (st) {
      st.plan(1)
      model(user).then(u => st.equal(u.name, user.name))
    })

    st.test('contains an email property', function (st) {
      st.plan(1)
      model(user).then(u => st.equal(u.email, user.email))
    })

    st.test('does not contain a password property', function (st) {
      st.plan(1)
      model(user).then(u => st.notOk(u.hasOwnProperty('password')))
    })

    st.test('contains a hashedPassword property', function (st) {
      st.plan(1)
      model(user).then(u => st.ok(u.hashedPassword))
    })
  })

  t.test('#serialise()', function (st) {
    const user = {
      username: 'foo',
      name: 'Foo Bar',
      email: 'foo@bar.com',
      password: 'Secret5auce!',
    }

    st.test('removes the password properties', function (st) {
      st.notOk(model.serialise(user).hasOwnProperty('password'))
      st.notOk(model.serialise(user).hasOwnProperty('hashedPassword'))
      st.end()
    })
  })

  t.test('#comparePassword()', function (st) {
    const user = {
      username: 'foo',
      name: 'Foo Bar',
      email: 'foo@bar.com',
      password: 'Secret5auce!',
    }

    st.test('returns true if the password is correct', function (st) {
      st.plan(1)
      model(user)
        .then(u => model.comparePassword(user.password, u.hashedPassword))
        .then(st.ok)
    })

    st.test('returns false if the password is incorrect', function (st) {
      st.plan(1)
      model(user)
        .then(u => model.comparePassword('donkeyrhubarb', u.hashedPassword))
        .then(st.notOk)
    })
  })
})

A  => lib/users/package.json +16 -0
@@ 1,16 @@
{
  "name": "users",
  "version": "1.0.0",
  "description": "Users feature domain",
  "main": "index.js",
  "private": true,
  "dependencies": {
    "bcrypt": "^5.0.1",
    "level": "^7.0.1",
    "nanoid": "^3.1.30"
  },
  "devDependencies": {
    "level-mem": "^6.0.1",
    "tape": "^5.3.2"
  }
}

A  => lib/users/repository.js +49 -0
@@ 1,49 @@
const { resolve } = require('path')
const db = process.env.NODE_ENV === 'test'
  ? require('level-mem')()
  : require('level')(resolve(__dirname, 'db'), { valueEncoding: 'json' })
const { nanoid } = require('nanoid')

module.exports = {
  get: id => db.get(id).then(user => ({
    ...JSON.parse(user)
  })),
  findBy: (key, value) => new Promise((resolve, reject) => {
    if (typeof key !== 'string' || !key)
      return reject(new Error('key is required'))
    if (key && value === undefined)
      return reject(new Error('key value is required'))
    return db.createReadStream()
      .on('data', user => JSON.parse(user.value)[key] === value
        ? resolve({ id: user.key, ...JSON.parse(user.value) })
        : null
      )
      .on('error', err => reject(err))
      .on('close', () => {})
      .on('end', () => resolve())
  }),
  create: user => {
    if (typeof user !== 'object')
      return Promise.reject(new Error('user is required'))
    const id = nanoid()
    return db.put(id, JSON.stringify(user))
      .then(() => id)
  },
  put: function (id, user) {
    if (typeof id !== 'string')
      return Promise.reject(new Error('id is required'))
    if (typeof user !== 'object')
      return Promise.reject(new Error('user is required'))
    return db.put(id, JSON.stringify(user))
      .then(() => this.get(id))
  },
  list: () => new Promise((resolve, reject) => {
    const rows = []
    return db.createReadStream()
      .on('data', data => rows.push(data))
      .on('error', err => reject(err))
      .on('close', () => {})
      .on('end', () => resolve(rows))
  }),
  remove: db.del.bind(db),
}

A  => lib/users/repository.test.js +112 -0
@@ 1,112 @@
process.env.NODE_ENV = 'test'
const test = require('tape')
const repository = require('./repository')

test('UserRepository', function (t) {
  t.test('returns static methods', function (st) {
    st.equal(Object.keys(repository).length, 6)
    st.equal(typeof repository.get, 'function')
    st.equal(typeof repository.create, 'function')
    st.equal(typeof repository.put, 'function')
    st.equal(typeof repository.list, 'function')
    st.equal(typeof repository.remove, 'function')
    st.equal(typeof repository.findBy, 'function')
    st.end()
  })

  t.test('#get()', function (st) {
    st.test('requires an ID', function (st) {
      st.plan(1)
      repository.get()
        .catch(e => st.ok(e.message === 'key cannot be `null` or `undefined`'))
    })
  })

  t.test('#create()', function (st) {
    st.test('requires a user', function (st) {
      st.plan(1)
      repository.create()
        .catch(e => st.ok(/user.+required/.test(e.message)))
    })

    st.test('returns the ID for the saved user', function (st) {
      st.plan(1)
      repository.create({ username: 'foo' })
        .then(id => st.ok(typeof id === 'string'))
    })

    st.test('saves the new user', function (st) {
      st.plan(1)
      repository.create({ username: 'foo' })
        .then(repository.get)
        .then(st.ok)
    })
  })

  t.test('#list()', function (st) {
    st.test('returns an of all users', function (st) {
      st.plan(1)
      repository.list().then(d => st.ok(Array.isArray(d)))
    })
  })

  t.test('#remove()', function (st) {
    st.test('requires an ID', function (st) {
      st.plan(1)
      repository.remove()
        .catch(e => st.ok(e.message === 'key cannot be `null` or `undefined`'))
    })

    st.test('removed the entry from the db', function (st) {
      st.plan(1)
      let userId
      repository.create({ username: 'foo' })
        .then(id => userId = id && id)
        .then(repository.remove)
        .then(() => repository.get(userId))
        .catch(m => st.ok(/key not found in database/i.test(m.message)))
    })
  })

  t.test('#findBy()', function (st) {
    st.test('requires a query-by key', function (st) {
      st.plan(1)
      repository.findBy()
        .catch(e => st.ok(e.message === 'key is required'))
    })

    st.test('requires a query-by value', function (st) {
      st.plan(1)
      repository.findBy('blah')
        .catch(e => st.ok(/key value is required/i.test(e.message)))
    })

    st.test('returns a user object', function (st) {
      st.plan(1)
      repository.findBy('username', 'foo')
        .then(st.ok)
    })
  })

  t.test('#put()', function (st) {
    st.test('requires a user id', function (st) {
      st.plan(1)
      repository.put()
        .catch(e => st.ok(/id.+required/.test(e.message)))
    })

    st.test('requires a user object', function (st) {
      st.plan(1)
      repository.put('d34db33f')
        .catch(e => st.ok(/user.+required/.test(e.message)))
    })

    st.test('returns the updated user record', function (st) {
      st.plan(1)
      const user = { username: 'foo' }
      repository.create(user)
        .then(id => repository.put(id, { username: 'foobar' }))
        .then(updated => st.notEqual(updated.username, user.username))
    })
  })
})

A  => lib/users/routes.js +21 -0
@@ 1,21 @@
module.exports = (controller, req, res) => {
  if (controller === undefined || !(controller instanceof Object))
    throw new Error('controller is required')

  controller = controller(req, res)

  if (/^\/users\/?$/.test(req.url) && req.method === 'GET')
    return controller.list()

  if (/^\/users\/([0-9a-z_-]){21}\/?$/i.test(req.url) && req.method === 'GET')
    return controller.getUser()

  if (/^\/users\/?$/.test(req.url) && req.method === 'POST')
    return controller.register()

  if (/^\/users\/([0-9a-z_-]){21}\/?$/i.test(req.url) && req.method === 'DELETE')
    return controller.register()

  if (/^\/users\/([0-9a-z_-]){21}\/?$/i.test(req.url) && req.method === 'PUT')
    return controller.put()
}

A  => lib/users/routes.test.js +61 -0
@@ 1,61 @@
process.env.NODE_ENV = 'test'
const test = require('tape')
const { routes } = require('.')

const controller = (req, res) => ({
  list: () => true,
  getUser: () => true,
  register: () => true,
  put: () => true,
})

test('User routes', function (t) {
  t.test('is a function', function (st) {
    st.equal(typeof routes, 'function')
    st.end()
  })

  t.test('requires a controller', function (st) {
    st.throws(() => routes(), /controller.+required/i)
    st.end()
  })

  t.test('matches GET /users(/)', function (st) {
    st.ok(routes(controller, { method: 'GET', url: '/users' }, {}))
    st.ok(routes(controller, { method: 'GET', url: '/users/' }, {}))
    st.end()
  })

  t.test('matches GET /users/<nanoid>(/)', function (st) {
    st.notOk(routes(controller, { method: 'GET', url: '/users/123' }, {}))
    st.ok(routes(
      controller, { method: 'GET', url: '/users/V1StGXR8_Z5jdHi6B-myT' }, {}
    ))
    st.ok(routes(
      controller, { method: 'GET', url: '/users/V1StGXR8_Z5jdHi6B-myT/' }, {}
    ))
    st.end()
  })

  t.test('matches POST /users(/)', function (st) {
    st.ok(typeof routes(controller, { method: 'POST', url: '/users' }, {}))
    st.ok(typeof routes(controller, { method: 'POST', url: '/users/' }, {}))
    st.end()
  })

  t.test('matches DELETE /users/<nanoid>(/)', function (st) {
    st.notOk(routes(controller, { method: 'DELETE', url: '/users/123' }, {}))
    st.ok(routes(
      controller, { method: 'DELETE', url: '/users/V1StGXR8_Z5jdHi6B-myT' }, {}
    ))
    st.end()
  })

  t.test('matches PUT /users/<nanoid>(/)', function (st) {
    st.notOk(routes(controller, { method: 'PUT', url: '/users/123' }, {}))
    st.ok(routes(
      controller, { method: 'PUT', url: '/users/V1StGXR8_Z5jdHi6B-myT' }, {}
    ))
    st.end()
  })
})

A  => package.json +24 -0
@@ 1,24 @@
{
  "name": "user-profiles-crud-api",
  "version": "1.0.0",
  "description": "Simple demonstration of a CRUD API for users and their profiles",
  "main": "index.js",
  "private": true,
  "directories": {
    "lib": "lib"
  },
  "scripts": {
    "preinstall": "./bin/preinstall",
    "postinstall": "./bin/postinstall",
    "start": "node .",
    "test": "tape lib/**/*test.js"
  },
  "author": "Akiva Levy <akiva@sixthirteen.co> (http://sixthirteen.co/)",
  "license": "ISC",
  "dependencies": {
    "minimist": "^1.2.5"
  },
  "devDependencies": {
    "tape": "^5.3.2"
  }
}