diff --git a/README.md b/README.md index f91a6efb26..4a188893df 100644 --- a/README.md +++ b/README.md @@ -39,19 +39,19 @@ In its most basic form, a Satellite-based microservice looks like this: const { Satellite, // the Satellite constructor logger, // pre-configured logger -} = require("@senecacdot/satellite"); +} = require('@senecacdot/satellite'); // Define your microservice const service = new Satellite(); // Add your routes to the service's router -service.router.get("/my-route", (req, res) => { - res.json({ message: "hello world" }); +service.router.get('/my-route', (req, res) => { + res.json({ message: 'hello world' }); }); // Start the service on the specified port service.start(8888, () => { - logger.info("Satellite Microservice running on port 8888"); + logger.info('Satellite Microservice running on port 8888'); }); ``` @@ -158,9 +158,9 @@ router.get('/admin', isAuthenticated(), isAuthorized({ roles: ["admin"] }), (req The `logger` object is a pre-configured logger based on [Pino](https://getpino.io/#/). ```js -const { logger } = require("@senecacdot/satellite"); +const { logger } = require('@senecacdot/satellite'); -logger.info("Hello World!"); +logger.info('Hello World!'); ``` ### Hash @@ -168,7 +168,21 @@ logger.info("Hello World!"); The `hash()` function is a convenience hashing function, which returns a 10 character hash: ```js -const { hash } = require("@senecacdot/satellite"); +const { hash } = require('@senecacdot/satellite'); -const id = hash("http://someurl.com"); +const id = hash('http://someurl.com'); +``` + +### Create Error + +The `createError()` function creates a unique HTTP Error Object which is based on [http-errors](https://www.npmjs.com/package/http-errors). + +```js +const { createError } = require('@senecacdot/satellite'); + +const e = createError(404, 'This is a message that describes your Error object'); + +console.log(e.status); // of type: Number + +console.log(e.message); // of type: String ``` diff --git a/package-lock.json b/package-lock.json index 5b31f2df59..fdd6f21445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1835,6 +1835,11 @@ "vary": "^1" } }, + "create-error": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/create-error/-/create-error-0.3.1.tgz", + "integrity": "sha1-aYECRaYp5lRDK/BDdzYAA6U1GiM=" + }, "cross-spawn": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", diff --git a/src/index.js b/src/index.js index 3977036817..3172d84c68 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ const { createRouter } = require("./app"); module.exports.Satellite = require("./satellite"); module.exports.logger = require("./logger"); module.exports.hash = require("./hash"); +module.exports.createError = require("http-errors"); module.exports.Router = (options) => createRouter(options); module.exports.isAuthenticated = isAuthenticated; module.exports.isAuthorized = isAuthorized; diff --git a/test.js b/test.js index e221b7561e..8f838ce889 100644 --- a/test.js +++ b/test.js @@ -1,10 +1,10 @@ /* global describe, test, beforeEach, afterEach, expect */ -const fetch = require("node-fetch"); -const getPort = require("get-port"); -const jwt = require("jsonwebtoken"); +const fetch = require('node-fetch'); +const getPort = require('get-port'); +const jwt = require('jsonwebtoken'); // Tests cause terminus to leak warning on too many listeners, increase a bit -require("events").EventEmitter.defaultMaxListeners = 32; +require('events').EventEmitter.defaultMaxListeners = 32; const { Satellite, @@ -13,14 +13,15 @@ const { isAuthorized, logger, hash, -} = require("./src"); + createError, +} = require('./src'); const { JWT_EXPIRES_IN, JWT_ISSUER, JWT_AUDIENCE, SECRET } = process.env; const createSatelliteInstance = (options) => { - const service = new Satellite(options || { name: "test" }); + const service = new Satellite(options || { name: 'test' }); // Default route - service.router.get("/always-200", (req, res) => { + service.router.get('/always-200', (req, res) => { res.status(200).end(); }); @@ -45,7 +46,7 @@ const createToken = ({ sub, secret, roles }) => { return jwt.sign(payload, secret || SECRET, { expiresIn: JWT_EXPIRES_IN }); }; -describe("Satellite()", () => { +describe('Satellite()', () => { let port; let port2; let url; @@ -59,10 +60,10 @@ describe("Satellite()", () => { service = createSatelliteInstance(); // Silence the logger. Override if you need it in a test - logger.level = "silent"; + logger.level = 'silent'; // Create a JWT bearer token we can use if necessary - token = createToken({ sub: "test-user@email.com" }); + token = createToken({ sub: 'test-user@email.com' }); }); afterEach((done) => { @@ -70,12 +71,12 @@ describe("Satellite()", () => { service = null; }); - test("start() should throw if port not defined", () => { + test('start() should throw if port not defined', () => { expect(() => service.start()).toThrow(); }); - describe("/healthcheck", () => { - test("Satellite() instance should have /healthcheck", (done) => { + describe('/healthcheck', () => { + test('Satellite() instance should have /healthcheck', (done) => { service.start(port, async () => { const res = await fetch(`${url}/healthcheck`); expect(res.ok).toBe(true); @@ -83,11 +84,11 @@ describe("Satellite()", () => { }); }); - test("Satellite() should use provided healthCheck function, and fail if rejected", (done) => { + test('Satellite() should use provided healthCheck function, and fail if rejected', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', healthCheck() { - return Promise.reject(new Error("sorry, service unavailable")); + return Promise.reject(new Error('sorry, service unavailable')); }, }); service.start(port, async () => { @@ -97,11 +98,11 @@ describe("Satellite()", () => { }); }); - test("Satellite() should use provided healthCheck function, and pass if resolved", (done) => { + test('Satellite() should use provided healthCheck function, and pass if resolved', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', healthCheck() { - return Promise.resolve("ok"); + return Promise.resolve('ok'); }, }); service.start(port, async () => { @@ -112,14 +113,14 @@ describe("Satellite()", () => { }); }); - test("start() should throw if called twice", (done) => { + test('start() should throw if called twice', (done) => { service.start(port, () => { expect(() => service.start(port2, async () => {})).toThrow(); done(); }); }); - test("Satellite() should allow adding routes to the default router", (done) => { + test('Satellite() should allow adding routes to the default router', (done) => { service.start(port, async () => { const res = await fetch(`${url}/always-200`); expect(res.status).toBe(200); @@ -127,7 +128,7 @@ describe("Satellite()", () => { }); }); - test("Satellite() should respond with 404 if route not found", (done) => { + test('Satellite() should respond with 404 if route not found', (done) => { service.start(port, async () => { const res = await fetch(`${url}/always-404`); expect(res.status).toBe(404); @@ -135,34 +136,32 @@ describe("Satellite()", () => { }); }); - test("Satellite() should allow adding middleware with beforeRouter", (done) => { + test('Satellite() should allow adding middleware with beforeRouter', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', beforeRouter(app) { app.use((req, res, next) => { - req.testValue = "test"; + req.testValue = 'test'; next(); }); }, }); - service.router.get("/test-value", (req, res) => - res.json({ testValue: req.testValue }) - ); + service.router.get('/test-value', (req, res) => res.json({ testValue: req.testValue })); service.start(port, async () => { const res = await fetch(`${url}/test-value`); expect(res.ok).toBe(true); const body = await res.json(); - expect(body).toEqual({ testValue: "test" }); + expect(body).toEqual({ testValue: 'test' }); service.stop(done); }); }); - test("Satellite() should allow passing a router to the constructor", (done) => { + test('Satellite() should allow passing a router to the constructor', (done) => { const router = Router(); - router.get("/test-value", (req, res) => res.json({ hello: "world" })); + router.get('/test-value', (req, res) => res.json({ hello: 'world' })); const service = createSatelliteInstance({ - name: "test", + name: 'test', router, }); @@ -172,69 +171,65 @@ describe("Satellite()", () => { const res = await fetch(`${url}/test-value`); expect(res.ok).toBe(true); const body = await res.json(); - expect(body).toEqual({ hello: "world" }); + expect(body).toEqual({ hello: 'world' }); service.stop(done); }); }); - test("Satellite() should allow adding middleware with beforeParsers", (done) => { + test('Satellite() should allow adding middleware with beforeParsers', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', beforeParsers(app) { app.use((req, res, next) => { - req.testValue = "test"; + req.testValue = 'test'; next(); }); }, }); - service.router.get("/test-value", (req, res) => - res.json({ testValue: req.testValue }) - ); + service.router.get('/test-value', (req, res) => res.json({ testValue: req.testValue })); service.start(port, async () => { const res = await fetch(`${url}/test-value`); expect(res.ok).toBe(true); const body = await res.json(); - expect(body).toEqual({ testValue: "test" }); + expect(body).toEqual({ testValue: 'test' }); service.stop(done); }); }); - test("Satellite() should allow adding middleware with beforeParsers and beforeRouter together", (done) => { + test('Satellite() should allow adding middleware with beforeParsers and beforeRouter together', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', beforeParsers(app) { app.use((req, res, next) => { - req.testValue = "parsers"; + req.testValue = 'parsers'; next(); }); }, beforeRouter(app) { app.use((req, res, next) => { - req.testValue += "-router"; + req.testValue += '-router'; next(); }); }, }); - service.router.get("/test-value", (req, res) => - res.json({ testValue: req.testValue }) - ); + service.router.get('/test-value', (req, res) => res.json({ testValue: req.testValue })); service.start(port, async () => { const res = await fetch(`${url}/test-value`); expect(res.ok).toBe(true); const body = await res.json(); - expect(body).toEqual({ testValue: "parsers-router" }); + expect(body).toEqual({ testValue: 'parsers-router' }); service.stop(done); }); }); - describe("Router()", () => { - test("should be able to create sub-routers using Router()", (done) => { + describe('Router()', () => { + test('should be able to create sub-routers using Router()', (done) => { const customRouter = Router(); - customRouter.get("/sub-router", (req, res) => { + customRouter.get('/sub-router', (req, res) => { res.status(200).end(); }); - service.router.use("/router", customRouter); + service.router.use('/router', customRouter); const testRoute = async () => { const res = await fetch(`${url}/router/sub-router`); @@ -243,23 +238,23 @@ describe("Satellite()", () => { }; service.start(port, () => { - logger.info("Here we go!"); + logger.info('Here we go!'); testRoute(); }); }); }); - test("isAuthenticated() should work on a specific route", (done) => { + test('isAuthenticated() should work on a specific route', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', }); const router = service.router; - router.get("/public", (req, res) => res.json({ hello: "public" })); - router.get("/protected", isAuthenticated(), (req, res) => { + router.get('/public', (req, res) => res.json({ hello: 'public' })); + router.get('/protected', isAuthenticated(), (req, res) => { // Make sure the user payload was added to req - expect(req.user.sub).toEqual("test-user@email.com"); - res.json({ hello: "protected" }); + expect(req.user.sub).toEqual('test-user@email.com'); + res.json({ hello: 'protected' }); }); service.start(port, async () => { @@ -267,7 +262,7 @@ describe("Satellite()", () => { let res = await fetch(`${url}/public`); expect(res.ok).toBe(true); let body = await res.json(); - expect(body).toEqual({ hello: "public" }); + expect(body).toEqual({ hello: 'public' }); // Protected should fail without authorization header res = await fetch(`${url}/protected`); @@ -282,42 +277,42 @@ describe("Satellite()", () => { }); expect(res.ok).toBe(true); body = await res.json(); - expect(body).toEqual({ hello: "protected" }); + expect(body).toEqual({ hello: 'protected' }); service.stop(done); }); }); - test("isAuthorized() without providing proper roles Array should throw", () => { + test('isAuthorized() without providing proper roles Array should throw', () => { expect(() => isAuthorized()).toThrow(); expect(() => isAuthorized({})).toThrow(); - expect(() => isAuthorized({ roles: "admin" })).toThrow(); + expect(() => isAuthorized({ roles: 'admin' })).toThrow(); expect(() => isAuthorized({ roles: [] })).toThrow(); expect(() => isAuthorized({ roles: [true] })).toThrow(); }); - test("isAuthorized() without isAuthenticated() fail with 401", (done) => { + test('isAuthorized() without isAuthenticated() fail with 401', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', }); - const token = createToken({ sub: "user@email.com", roles: ["user"] }); + const token = createToken({ sub: 'user@email.com', roles: ['user'] }); const adminToken = createToken({ - sub: "admin-user@email.com", - roles: ["user", "admin"], + sub: 'admin-user@email.com', + roles: ['user', 'admin'], }); const router = service.router; - router.get("/public", (req, res) => res.json({ hello: "public" })); + router.get('/public', (req, res) => res.json({ hello: 'public' })); router.get( - "/protected", + '/protected', /* isAuthenticated() is required here, but we aren't calling it */ - isAuthorized({ roles: ["admin"] }), + isAuthorized({ roles: ['admin'] }), (req, res) => { // Make sure an admin user payload was added to req - expect(req.user.sub).toEqual("admin-user@email.com"); + expect(req.user.sub).toEqual('admin-user@email.com'); expect(Array.isArray(req.user.roles)).toBe(true); - expect(req.user.roles).toContain("admin"); - res.json({ hello: "protected" }); + expect(req.user.roles).toContain('admin'); + res.json({ hello: 'protected' }); } ); @@ -326,7 +321,7 @@ describe("Satellite()", () => { let res = await fetch(`${url}/public`); expect(res.ok).toBe(true); let body = await res.json(); - expect(body).toEqual({ hello: "public" }); + expect(body).toEqual({ hello: 'public' }); // Protected should fail without authorization header res = await fetch(`${url}/protected`); @@ -355,37 +350,32 @@ describe("Satellite()", () => { }); }); - test("isAuthenticated() + isAuthorized() without required role should fail", (done) => { + test('isAuthenticated() + isAuthorized() without required role should fail', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', }); - const token = createToken({ sub: "user@email.com", roles: ["user"] }); + const token = createToken({ sub: 'user@email.com', roles: ['user'] }); const adminToken = createToken({ - sub: "admin-user@email.com", - roles: ["user", "admin"], + sub: 'admin-user@email.com', + roles: ['user', 'admin'], }); const router = service.router; - router.get("/public", (req, res) => res.json({ hello: "public" })); - router.get( - "/protected", - isAuthenticated(), - isAuthorized({ roles: ["admin"] }), - (req, res) => { - // Make sure an admin user payload was added to req - expect(req.user.sub).toEqual("admin-user@email.com"); - expect(Array.isArray(req.user.roles)).toBe(true); - expect(req.user.roles).toContain("admin"); - res.json({ hello: "protected" }); - } - ); + router.get('/public', (req, res) => res.json({ hello: 'public' })); + router.get('/protected', isAuthenticated(), isAuthorized({ roles: ['admin'] }), (req, res) => { + // Make sure an admin user payload was added to req + expect(req.user.sub).toEqual('admin-user@email.com'); + expect(Array.isArray(req.user.roles)).toBe(true); + expect(req.user.roles).toContain('admin'); + res.json({ hello: 'protected' }); + }); service.start(port, async () => { // Public should need no bearer token let res = await fetch(`${url}/public`); expect(res.ok).toBe(true); let body = await res.json(); - expect(body).toEqual({ hello: "public" }); + expect(body).toEqual({ hello: 'public' }); // Protected should fail without authorization header res = await fetch(`${url}/protected`); @@ -409,39 +399,34 @@ describe("Satellite()", () => { }); expect(res.ok).toBe(true); body = await res.json(); - expect(body).toEqual({ hello: "protected" }); + expect(body).toEqual({ hello: 'protected' }); service.stop(done); }); }); - test("isAuthenticated() + isAuthorized() for admin role should work on a specific route", (done) => { + test('isAuthenticated() + isAuthorized() for admin role should work on a specific route', (done) => { const service = createSatelliteInstance({ - name: "test", + name: 'test', }); - const token = createToken({ sub: "admin@email.com", roles: ["admin"] }); + const token = createToken({ sub: 'admin@email.com', roles: ['admin'] }); const router = service.router; - router.get("/public", (req, res) => res.json({ hello: "public" })); - router.get( - "/protected", - isAuthenticated(), - isAuthorized({ roles: ["admin"] }), - (req, res) => { - // Make sure an admin user payload was added to req - expect(req.user.sub).toEqual("admin@email.com"); - expect(Array.isArray(req.user.roles)).toBe(true); - expect(req.user.roles).toContain("admin"); - res.json({ hello: "protected" }); - } - ); + router.get('/public', (req, res) => res.json({ hello: 'public' })); + router.get('/protected', isAuthenticated(), isAuthorized({ roles: ['admin'] }), (req, res) => { + // Make sure an admin user payload was added to req + expect(req.user.sub).toEqual('admin@email.com'); + expect(Array.isArray(req.user.roles)).toBe(true); + expect(req.user.roles).toContain('admin'); + res.json({ hello: 'protected' }); + }); service.start(port, async () => { // Public should need no bearer token let res = await fetch(`${url}/public`); expect(res.ok).toBe(true); let body = await res.json(); - expect(body).toEqual({ hello: "public" }); + expect(body).toEqual({ hello: 'public' }); // Protected should fail without authorization header res = await fetch(`${url}/protected`); @@ -456,184 +441,194 @@ describe("Satellite()", () => { }); expect(res.ok).toBe(true); body = await res.json(); - expect(body).toEqual({ hello: "protected" }); + expect(body).toEqual({ hello: 'protected' }); service.stop(done); }); }); - describe("Default body parsers", () => { - test("should support JSON body", (done) => { - service.router.post("/json", (req, res) => { + describe('Default body parsers', () => { + test('should support JSON body', (done) => { + service.router.post('/json', (req, res) => { // echo back the json body res.json(req.body); }); service.start(port, async () => { const res = await fetch(`${url}/json`, { - method: "post", + method: 'post', headers: { - Accept: "application/json", - "Content-Type": "application/json", + Accept: 'application/json', + 'Content-Type': 'application/json', }, - body: JSON.stringify({ json: "rocks" }), + body: JSON.stringify({ json: 'rocks' }), }); expect(res.ok).toBe(true); const data = await res.json(); - expect(data).toEqual({ json: "rocks" }); + expect(data).toEqual({ json: 'rocks' }); done(); }); }); - test("should support x-www-form-urlencoded body", (done) => { - service.router.post("/form", (req, res) => { + test('should support x-www-form-urlencoded body', (done) => { + service.router.post('/form', (req, res) => { // echo back the body res.json(req.body); }); service.start(port, async () => { const res = await fetch(`${url}/form`, { - method: "post", + method: 'post', headers: { - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', }, - body: "json=rocks", + body: 'json=rocks', }); expect(res.ok).toBe(true); const data = await res.json(); - expect(data).toEqual({ json: "rocks" }); + expect(data).toEqual({ json: 'rocks' }); done(); }); }); }); - test("the default README example code should work", (done) => { + test('the default README example code should work', (done) => { // Add your routes to the service's router - service.router.get("/my-route", (req, res) => { - res.json({ message: "hello world" }); + service.router.get('/my-route', (req, res) => { + res.json({ message: 'hello world' }); }); const testRoute = async () => { const res = await fetch(`${url}/my-route`); expect(res.ok).toBe(true); const body = await res.json(); - expect(body).toEqual({ message: "hello world" }); + expect(body).toEqual({ message: 'hello world' }); service.stop(done); }; service.start(port, () => { - logger.info("Here we go!"); + logger.info('Here we go!'); testRoute(); }); }); - describe("cors", () => { - test("CORS set by default", (done) => { + describe('cors', () => { + test('CORS set by default', (done) => { service.start(port, async () => { const res = await fetch(`${url}/always-200`, { - credentials: "same-origin", + credentials: 'same-origin', }); expect(res.ok).toBe(true); - expect(res.headers.get("access-control-allow-origin")).toBe("*"); + expect(res.headers.get('access-control-allow-origin')).toBe('*'); done(); }); }); - test("Allow disabling CORS", (done) => { + test('Allow disabling CORS', (done) => { const corsService = createSatelliteInstance({ - name: "test", + name: 'test', port, cors: false, }); corsService.start(port, async () => { const res = await fetch(`${url}/always-200`, { - credentials: "same-origin", + credentials: 'same-origin', }); expect(res.ok).toBe(true); - expect(res.headers.get("access-control-allow-origin")).toBe(null); + expect(res.headers.get('access-control-allow-origin')).toBe(null); corsService.stop(done); }); }); - test("Allow passing options to CORS", (done) => { - const origin = "http://example.com"; + test('Allow passing options to CORS', (done) => { + const origin = 'http://example.com'; const corsService = createSatelliteInstance({ - name: "test", + name: 'test', port, cors: { origin }, }); corsService.start(port, async () => { const res = await fetch(`${url}/always-200`, { - credentials: "same-origin", + credentials: 'same-origin', }); expect(res.ok).toBe(true); - expect(res.headers.get("access-control-allow-origin")).toBe( - "http://example.com" - ); + expect(res.headers.get('access-control-allow-origin')).toBe('http://example.com'); corsService.stop(done); }); }); }); - describe("helmet", () => { - test("helmet on by default", (done) => { + describe('helmet', () => { + test('helmet on by default', (done) => { service.start(port, async () => { const res = await fetch(`${url}/always-200`); expect(res.ok).toBe(true); - expect(res.headers.get("x-xss-protection")).toBe("0"); + expect(res.headers.get('x-xss-protection')).toBe('0'); done(); }); }); - test("Allow disabling helmet", (done) => { + test('Allow disabling helmet', (done) => { const helmetService = createSatelliteInstance({ - name: "test", + name: 'test', port, helmet: false, }); helmetService.start(port, async () => { const res = await fetch(`${url}/always-200`); expect(res.ok).toBe(true); - expect(res.headers.get("x-xss-protection")).toBe(null); + expect(res.headers.get('x-xss-protection')).toBe(null); helmetService.stop(done); }); }); - test("Allow passing options to helmet", (done) => { + test('Allow passing options to helmet', (done) => { const helmetService = createSatelliteInstance({ - name: "test", + name: 'test', port, helmet: { xssFilter: false }, }); helmetService.start(port, async () => { const res = await fetch(`${url}/always-200`); expect(res.ok).toBe(true); - expect(res.headers.get("x-xss-protection")).toBe(null); + expect(res.headers.get('x-xss-protection')).toBe(null); helmetService.stop(done); }); }); }); }); -describe("logger", () => { - test("logger should have expected methods()", () => { - ["trace", "debug", "info", "warn", "error", "fatal"].forEach((level) => { - expect(typeof logger[level] === "function").toBe(true); - expect(() => logger[level]("test")).not.toThrow(); +describe('logger', () => { + test('logger should have expected methods()', () => { + ['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach((level) => { + expect(typeof logger[level] === 'function').toBe(true); + expect(() => logger[level]('test')).not.toThrow(); }); }); }); -describe("hash", () => { - it("should return a 10 character hash value", () => { - expect(hash("satellite").length).toBe(10); +describe('hash', () => { + it('should return a 10 character hash value', () => { + expect(hash('satellite').length).toBe(10); }); - it("should hash a string correctly", () => { - expect(hash("satellite")).toBe("dc4b4e203f"); + it('should hash a string correctly', () => { + expect(hash('satellite')).toBe('dc4b4e203f'); + }); + + it('should return a different hash if anything changes', () => { + expect(hash('satellite2')).toBe('6288d4ca65'); + }); +}); + +describe('Create Error tests for Satellite', () => { + test('should be an instance of type Error', () => { + expect(createError(404, 'testing') instanceof Error).toBe(true); }); - it("should return a different hash if anything changes", () => { - expect(hash("satellite2")).toBe("6288d4ca65"); + test("should have it's value, and message accessible through it's members", () => { + const testError = createError(404, 'Satellite Test for Errors'); + expect(testError.status).toBe(404); + expect(testError.message).toBe('Satellite Test for Errors'); }); });