From 21c553259828eb62f851237051b98868ad9b39b8 Mon Sep 17 00:00:00 2001 From: James Chartrand Date: Sun, 26 May 2024 17:45:13 -0400 Subject: [PATCH] add coverage, and prettying and linting on precommit --- .husky/.gitignore | 1 + .husky/pre-commit | 5 ++ .lintstagedrc.json | 6 ++ .prettierignore | 3 + .prettierrc.js | 7 ++ eslint.config.js | 11 +++ package.json | 21 ++++- src/app.js | 183 ++++++++++++++++++++------------------ src/app.test.js | 96 +++++++++----------- src/transactionManager.js | 151 ++++++++++++++++++------------- 10 files changed, 280 insertions(+), 204 deletions(-) create mode 100644 .husky/.gitignore create mode 100755 .husky/pre-commit create mode 100644 .lintstagedrc.json create mode 100644 .prettierignore create mode 100644 .prettierrc.js create mode 100644 eslint.config.js diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..b56da6c --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged +npm test \ No newline at end of file diff --git a/.lintstagedrc.json b/.lintstagedrc.json new file mode 100644 index 0000000..891b2e0 --- /dev/null +++ b/.lintstagedrc.json @@ -0,0 +1,6 @@ +{ + "*.js": [ + "prettier --write", + "eslint" + ] +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..00543e5 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +build +coverage +node_modules \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..bf2cd83 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,7 @@ +export default { + trailingComma: 'none', + tabWidth: 2, + semi: false, + singleQuote: true, + bracketSpacing: true +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..96bac76 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,11 @@ +import globals from 'globals' +import pluginJs from '@eslint/js' +import eslintConfigPrettier from 'eslint-config-prettier' +import mochaPlugin from 'eslint-plugin-mocha' + +export default [ + { languageOptions: { globals: globals.node } }, + mochaPlugin.configs.flat.recommended, + pluginJs.configs.recommended, + eslintConfigPrettier +] diff --git a/package.json b/package.json index e2d752a..86b7420 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ "scripts": { "start": "node -r dotenv/config server.js", "dev": "nodemon -r dotenv/config server.js", - "test": "NODE_OPTIONS=--experimental-vm-modules npx mocha --timeout 10000 -r dotenv/config dotenv_config_path=src/test-fixtures/.env.testing src/app.test.js " + "dev-noenv": "nodemon server.js", + "test": "NODE_OPTIONS=--experimental-vm-modules npx c8 mocha --timeout 10000 -r dotenv/config dotenv_config_path=src/test-fixtures/.env.testing src/app.test.js ", + "coveralls": "npm run test; c8 report --reporter=text-lcov | coveralls", + "prepare": "test -d node_modules/husky && husky install || echo \"husky is not installed\"", + "lint": "eslint", + "lint-fix": "eslint --fix" }, "dependencies": { "@digitalbazaar/did-io": "^2.0.0", @@ -30,9 +35,17 @@ "morgan": "~1.9.1" }, "devDependencies": { + "@eslint/js": "^9.3.0", "chai": "^4.3.7", + "eslint": "^9.3.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-mocha": "^10.4.3", + "globals": "^15.3.0", + "husky": "^9.0.11", + "lint-staged": "^15.2.5", "mocha": "^10.2.0", "nodemon": "^2.0.21", + "prettier": "3.2.5", "supertest": "^6.3.3" }, "keywords": [ @@ -51,5 +64,9 @@ "url": "https://github.com/digitalcredentials/transaction-manager-service" }, "homepage": "https://github.com/digitalcredentials/transaction-manager-service", - "bugs": "https://github.com/digitalcredentials/transaction-manager-service/issues" + "bugs": "https://github.com/digitalcredentials/transaction-manager-service/issues", + "lint-staged": { + "*.js": "eslint --cache --fix", + "*.{js,css,md}": "prettier --write" + } } diff --git a/src/app.js b/src/app.js index 669a9e1..234efbc 100644 --- a/src/app.js +++ b/src/app.js @@ -1,59 +1,68 @@ -import express, { request } from 'express'; -import logger from 'morgan'; -import cors from 'cors'; +import express from 'express' +import logger from 'morgan' +import cors from 'cors' import axios from 'axios' -import { initializeTransactionManager, setupExchange, retrieveStoredData, getVPR } from './transactionManager.js'; -import { getDataForExchangeSetupPost } from './test-fixtures/testData.js'; -import { getSignedDIDAuth } from './didAuth.js'; -import TransactionException from './TransactionException.js'; +import { + initializeTransactionManager, + setupExchange, + retrieveStoredData, + getVPR +} from './transactionManager.js' +import { getDataForExchangeSetupPost } from './test-fixtures/testData.js' +import { getSignedDIDAuth } from './didAuth.js' +import TransactionException from './TransactionException.js' -export async function build(opts = {}) { +export async function build() { + await initializeTransactionManager() - await initializeTransactionManager() + var app = express() - var app = express(); + app.use(logger('dev')) + app.use(express.json()) + app.use(express.urlencoded({ extended: false })) + app.use(cors()) - app.use(logger('dev')); - app.use(express.json()); - app.use(express.urlencoded({ extended: false })); - app.use(cors()) + app.get('/', function (req, res) { + res.send({ message: 'transaction-service server status: ok.' }) + }) - app.get('/', function (req, res, next) { - res.send({ message: 'transaction-service server status: ok.' }) - }); - - app.get('/healthz', async function (req, res) { - - const baseURL = `${req.protocol}://${req.headers.host}` - const testData = getDataForExchangeSetupPost('test', baseURL) - const exchangeURL = `${baseURL}/exchange` - try { - const response = await axios.post( - exchangeURL, - testData - ) - const { data: walletQuerys } = response - const walletQuery = walletQuerys.find(q => q.retrievalId === 'someId') - const parsedDeepLink = new URL(walletQuery.directDeepLink) - const requestURI = parsedDeepLink.searchParams.get('vc_request_url'); - const challenge = parsedDeepLink.searchParams.get('challenge'); - const didAuth = await getSignedDIDAuth('did:ex:223234', challenge) - const { data } = await axios.post(requestURI, didAuth) - const { tenantName, vc: unSignedVC } = data - if (!tenantName === 'test' || ! unSignedVC.name === "A Simply Wonderful Course") { - throw new TransactionException(503, 'transaction-service healthz failed') - } - } catch (e) { - console.log(`exception in healthz: ${JSON.stringify(e)}`) - return res.status(503).json({ - error: `transaction-service healthz check failed with error: ${e}`, - healthy: false - }) - } - res.send({ message: 'transaction-service server status: ok.', healthy: true }) + app.get('/healthz', async function (req, res) { + const baseURL = `${req.protocol}://${req.headers.host}` + const testData = getDataForExchangeSetupPost('test', baseURL) + const exchangeURL = `${baseURL}/exchange` + try { + const response = await axios.post(exchangeURL, testData) + const { data: walletQuerys } = response + const walletQuery = walletQuerys.find((q) => q.retrievalId === 'someId') + const parsedDeepLink = new URL(walletQuery.directDeepLink) + const requestURI = parsedDeepLink.searchParams.get('vc_request_url') + const challenge = parsedDeepLink.searchParams.get('challenge') + const didAuth = await getSignedDIDAuth('did:ex:223234', challenge) + const { data } = await axios.post(requestURI, didAuth) + const { tenantName, vc: unSignedVC } = data + if ( + !tenantName === 'test' || + !unSignedVC.name === 'A Simply Wonderful Course' + ) { + throw new TransactionException( + 503, + 'transaction-service healthz failed' + ) + } + } catch (e) { + console.log(`exception in healthz: ${JSON.stringify(e)}`) + return res.status(503).json({ + error: `transaction-service healthz check failed with error: ${e}`, + healthy: false + }) + } + res.send({ + message: 'transaction-service server status: ok.', + healthy: true }) + }) - /* + /* This is step 1 in an exchange. Creates a new exchange and stores the provided data for later use in the exchange, in particular the subject data @@ -62,55 +71,57 @@ export async function build(opts = {}) { with which to trigger wallet selection that in turn will trigger the exchange when the wallet opens. */ - app.post("/exchange", - async (req, res) => { - try { - const data = req.body; - if (!data || !Object.keys(data).length) return res.status(400).send({ code: 400, message: 'No data was provided in the body.' }) - const walletQuerys = await setupExchange(data) - return res.json(walletQuerys) - } catch (error) { - console.log(error); - return res.status(error.code || 500).json(error); - } - }) + app.post('/exchange', async (req, res) => { + try { + const data = req.body + if (!data || !Object.keys(data).length) + return res + .status(400) + .send({ code: 400, message: 'No data was provided in the body.' }) + const walletQuerys = await setupExchange(data) + return res.json(walletQuerys) + } catch (error) { + console.log(error) + return res.status(error.code || 500).json(error) + } + }) - /* + /* This is step 2 in an exchange, where the wallet has asked to initiate the exchange, and we reply here with a Verifiable Presentation Request, asking for a DIDAuth. Note that in some scenarios the wallet may skip this step and directly present the DIDAuth. */ - app.post("/exchange/:exchangeId", - async (req, res) => { - try { - const vpr = await getVPR(req.params.exchangeId) - return res.json(vpr) - } catch (error) { - console.log(error); - return res.status(error.code || 500).json(error); - } - }) + app.post('/exchange/:exchangeId', async (req, res) => { + try { + const vpr = await getVPR(req.params.exchangeId) + return res.json(vpr) + } catch (error) { + console.log(error) + return res.status(error.code || 500).json(error) + } + }) - /* + /* This is step 3 in an exchange, where we verify the supplied DIDAuth, and if verified we return the previously stored data for the exchange. */ - app.post("/exchange/:exchangeId/:transactionId", - async (req, res) => { - try { - const didAuth = req.body - const data = await retrieveStoredData(req.params.exchangeId, req.params.transactionId, didAuth) - return res.json(data) - } catch (error) { - console.log(error); - return res.status(error.code || 500).json(error); - } - }) - - - return app; + app.post('/exchange/:exchangeId/:transactionId', async (req, res) => { + try { + const didAuth = req.body + const data = await retrieveStoredData( + req.params.exchangeId, + req.params.transactionId, + didAuth + ) + return res.json(data) + } catch (error) { + console.log(error) + return res.status(error.code || 500).json(error) + } + }) + return app } diff --git a/src/app.test.js b/src/app.test.js index 06a7dba..fa1ffc6 100644 --- a/src/app.test.js +++ b/src/app.test.js @@ -1,87 +1,75 @@ import { expect } from 'chai' -import request from 'supertest'; -import { build } from './app.js'; -import { getDataForExchangeSetupPost } from './test-fixtures/testData.js'; -import { clearKeyv, initializeTransactionManager } from './transactionManager.js'; +import request from 'supertest' +import { build } from './app.js' +import { getDataForExchangeSetupPost } from './test-fixtures/testData.js' +import { + clearKeyv, + initializeTransactionManager +} from './transactionManager.js' let app -describe('api', () => { - - beforeEach(async () => { - app = await build(); - }); +describe('api', function () { + beforeEach(async function () { + app = await build() + }) - describe('GET /', () => { - it('GET / => hello', done => { + describe('GET /', function () { + it('GET / => hello', function (done) { request(app) - .get("/") + .get('/') .expect(200) .expect('Content-Type', /json/) - .expect(/{"message":"transaction-service server status: ok."}/, done); - }); + .expect(/{"message":"transaction-service server status: ok."}/, done) + }) }) - describe('GET /unknown', () => { - it('unknown endpoint returns 404', done => { - request(app) - .get("/unknown") - .expect(404, done) - }, 10000); + describe('GET /unknown', function () { + it('unknown endpoint returns 404', function (done) { + request(app).get('/unknown').expect(404, done) + }, 10000) }) - describe('POST /exchange', () => { - - it('returns 400 if no body', done => { + describe('POST /exchange', function () { + it('returns 400 if no body', function (done) { request(app) - .post("/exchange") + .post('/exchange') .expect('Content-Type', /json/) .expect(400, done) }) - it('returns array of wallet queries', async () => { + it('returns array of wallet queries', async function () { const testData = getDataForExchangeSetupPost('test') - const response = await request(app) - .post("/exchange") - .send(testData) + const response = await request(app).post('/exchange').send(testData) - expect(response.header["content-type"]).to.have.string("json"); - expect(response.status).to.eql(200); + expect(response.header['content-type']).to.have.string('json') + expect(response.status).to.eql(200) expect(response.body) expect(response.body.length).to.eql(testData.data.length) }) - }) - describe('GET /healthz', () => { + describe('GET /healthz', function () { + it('returns 200 if healthy', async function () { + const response = await request(app).get('/healthz') - it('returns 200 if healthy', async () => { - - const response = await request(app) - .get("/healthz") - - expect(response.header["content-type"]).to.have.string("json"); - expect(response.status).to.eql(200); - expect(response.body).to.eql({ message: 'transaction-service server status: ok.', healthy: true }) - + expect(response.header['content-type']).to.have.string('json') + expect(response.status).to.eql(200) + expect(response.body).to.eql({ + message: 'transaction-service server status: ok.', + healthy: true + }) }) - it('returns 503 if not healthy', async () => { - // we delete the keyv store to force an error + it('returns 503 if not healthy', async function () { + // we delete the keyv store to force an error clearKeyv() - const response = await request(app) - .get("/healthz") + const response = await request(app).get('/healthz') - expect(response.header["content-type"]).to.have.string("json"); - expect(response.status).to.eql(503); - expect(response.body).to.have.property('healthy', false); + expect(response.header['content-type']).to.have.string('json') + expect(response.status).to.eql(503) + expect(response.body).to.have.property('healthy', false) initializeTransactionManager() - }) - - }) - }) - - diff --git a/src/transactionManager.js b/src/transactionManager.js index 77fd6ce..1633aec 100644 --- a/src/transactionManager.js +++ b/src/transactionManager.js @@ -1,15 +1,15 @@ /*! * Copyright (c) 2023 Digital Credentials Consortium. All rights reserved. */ -import crypto from 'crypto'; -import Keyv from 'keyv'; -import {KeyvFile} from 'keyv-file' -import { verifyDIDAuth } from './didAuth.js'; +import crypto from 'crypto' +import Keyv from 'keyv' +import { KeyvFile } from 'keyv-file' +import { verifyDIDAuth } from './didAuth.js' const persistToFile = process.env.PERSIST_TO_FILE -const defaultTimeToLive = process.env.DEFAULT_TTL = 1000 * 60 * 10; // keyv entry expires after ten minutes +const defaultTimeToLive = (process.env.DEFAULT_TTL = 1000 * 60 * 10) // keyv entry expires after ten minutes -let keyv; +let keyv /** * Intializes the keyv store either in-memory or in file system, according to env. @@ -27,10 +27,9 @@ export const initializeTransactionManager = () => { }) }) } else { - keyv = new Keyv(); + keyv = new Keyv() } } - } /** @@ -50,13 +49,15 @@ export const setupExchange = async (exchangeData) => { // where each object contains a choice of wallet queries for the exchange. // A wallet query is either a deeplink, or a VPR for use with CHAPI. // Each object also contains whatever 'metadata' had been supplied - const exchangeHost = exchangeData.exchangeHost; - const tenantName = exchangeData.tenantName; - const processRecord = bindProcessRecordFnToExchangeHostAndTenant(exchangeHost, tenantName) + const exchangeHost = exchangeData.exchangeHost + const tenantName = exchangeData.tenantName + const processRecord = bindProcessRecordFnToExchangeHostAndTenant( + exchangeHost, + tenantName + ) return await Promise.all(exchangeData.data.map(processRecord)) } - /** * This returns the vpr as described in the * "Integrating with the VC-API Exchanges workflow" section of: @@ -65,40 +66,43 @@ export const setupExchange = async (exchangeData) => { export const getDIDAuthVPR = async (exchangeId) => { const exchangeData = await getExchangeData(exchangeId) return { - "query": { - "type": "DIDAuthentication" + query: { + type: 'DIDAuthentication' }, - "interact": { - "service": [ + interact: { + service: [ { - "type": "VerifiableCredentialApiExchangeService", - "serviceEndpoint": `${exchangeData.exchangeHost}/exchange/${exchangeData.exchangeId}/${exchangeData.transactionId}` + type: 'VerifiableCredentialApiExchangeService', + serviceEndpoint: `${exchangeData.exchangeHost}/exchange/${exchangeData.exchangeId}/${exchangeData.transactionId}` // "serviceEndpoint": "https://playground.chapi.io/exchanges/eyJjcmVkZW50aWFsIjoiaHR0cHM6Ly9wbGF5Z3JvdW5kLmNoYXBpLmlvL2V4YW1wbGVzL2pmZjIvamZmMi5qc29uIiwiaXNzdWVyIjoiZGIvdmMifQ/esOGVHG8d44Q" }, { - "type": "CredentialHandlerService" + type: 'CredentialHandlerService' } ] }, - "challenge": exchangeData.transactionId, - "domain": exchangeData.exchangeHost, + challenge: exchangeData.transactionId, + domain: exchangeData.exchangeHost } } -/** - * @param {string} exchangeHost +/** + * @param {string} exchangeHost * @param {string} tenantName * @returns a function for processing incoming records, bound to the specific exchangeHost and tenant */ -const bindProcessRecordFnToExchangeHostAndTenant = (exchangeHost, tenantName) => { - return async (record) => { +const bindProcessRecordFnToExchangeHostAndTenant = ( + exchangeHost, + tenantName +) => { + return async (record) => { record.tenantName = tenantName record.exchangeHost = exchangeHost record.transactionId = crypto.randomUUID() record.exchangeId = crypto.randomUUID() - + const timeToLive = record.timeToLive || defaultTimeToLive - await keyv.set(record.exchangeId, record, timeToLive); + await keyv.set(record.exchangeId, record, timeToLive) // directDeepLink bypasses the VPR step and assumes the wallet knows to send a DIDAuth. const directDeepLink = `https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&challenge=${record.transactionId}&vc_request_url=${exchangeHost}/exchange/${record.exchangeId}/${record.transactionId}` @@ -106,34 +110,36 @@ const bindProcessRecordFnToExchangeHostAndTenant = (exchangeHost, tenantName) => //vprDeepLink = deeplink that calls /exchanges/${exchangeId} to initiate the exchange // and get back a VPR to which to then send the DIDAuth. const vprDeepLink = `https://lcw.app/request.html?issuer=issuer.example.com&auth_type=bearer&vc_request_url=${exchangeHost}/exchange/${record.exchangeId}` - // + // const chapiVPR = await getDIDAuthVPR(record.exchangeId) - const retrievalId = record.retrievalId; + const retrievalId = record.retrievalId const metadata = record.metadata return { retrievalId, directDeepLink, vprDeepLink, chapiVPR, metadata } } } /** - * + * * This is the "old" version of the vpr, which might have been superseded by the above vpr, * at least as described in the "Integrating with the VC-API Exchanges workflow" section of: * https://chapi.io/developers/playgroundfaq/ -* @param {string} exchangeId -* @returns returns a verifiable presentation request for DIDAuth -*/ + * @param {string} exchangeId + * @returns returns a verifiable presentation request for DIDAuth + */ export const getVPR = async (exchangeId) => { const exchangeData = await getExchangeData(exchangeId) return { - "verifiablePresentationRequest": { - "query": [{ "type": "DIDAuthentication" }], - "challenge": exchangeData.transactionId, - "domain": exchangeData.exchangeHost, - "interact": { - "service": [{ - "type": "UnmediatedPresentationService2021", - "serviceEndpoint": `${exchangeData.exchangeHost}/exchange/${exchangeData.exchangeId}/${exchangeData.transactionId}` - }] + verifiablePresentationRequest: { + query: [{ type: 'DIDAuthentication' }], + challenge: exchangeData.transactionId, + domain: exchangeData.exchangeHost, + interact: { + service: [ + { + type: 'UnmediatedPresentationService2021', + serviceEndpoint: `${exchangeData.exchangeHost}/exchange/${exchangeData.exchangeId}/${exchangeData.transactionId}` + } + ] } } } @@ -144,17 +150,24 @@ export const getVPR = async (exchangeId) => { * @param {string} transactionId * @param {Object} didAuthVP * @throws {ExchangeError} Invalid DIDAuth - * @returns returns stored data but only if didAuth verifies and transactionId + * @returns returns stored data but only if didAuth verifies and transactionId * from request param matches stored transactionId */ -export const retrieveStoredData = async (exchangeId, transactionId, didAuthVP) => { +export const retrieveStoredData = async ( + exchangeId, + transactionId, + didAuthVP +) => { const storedData = await getExchangeData(exchangeId) - const didAuthVerified = await verifyDIDAuth({ presentation: didAuthVP, challenge: storedData.transactionId }) + const didAuthVerified = await verifyDIDAuth({ + presentation: didAuthVP, + challenge: storedData.transactionId + }) const transactionIdMatches = transactionId === storedData.transactionId if (didAuthVerified && transactionIdMatches) { return storedData } else { - throw new ExchangeError("Invalid DIDAuth.", 401) + throw new ExchangeError('Invalid DIDAuth.', 401) } } @@ -163,9 +176,9 @@ export const retrieveStoredData = async (exchangeId, transactionId, didAuthVP) = * @throws {ExchangeError} Unknown exchangeID * @returns returns stored data if exchangeId exists */ -const getExchangeData = async exchangeId => { +const getExchangeData = async (exchangeId) => { const storedData = await keyv.get(exchangeId) - if (!storedData) throw new ExchangeError("Unknown exchangeId.", 404) + if (!storedData) throw new ExchangeError('Unknown exchangeId.', 404) return storedData } @@ -175,11 +188,11 @@ const getExchangeData = async exchangeId => { */ export const clearKeyv = () => { try { - keyv = null; + keyv = null } catch (e) { - throw new ExchangeError("Clear failed") - } - + console.log(e) + throw new ExchangeError('Clear failed') + } } /** @@ -193,31 +206,45 @@ export const clearKeyv = () => { * @param {Object} exchangeData.data[].metadata anything else we want to store in the record for later use * @throws {ExchangError} Unknown exchangeID */ -const verifyExchangeData = exchangeData => { +const verifyExchangeData = (exchangeData) => { const batchId = exchangeData.batchId if (!exchangeData.exchangeHost) { - throw new ExchangeError("Incomplete exchange data - you must provide an exchangeHost", 400) + throw new ExchangeError( + 'Incomplete exchange data - you must provide an exchangeHost', + 400 + ) } if (!exchangeData.tenantName) { - throw new ExchangeError("Incomplete exchange data - you must provide a tenant name", 400) + throw new ExchangeError( + 'Incomplete exchange data - you must provide a tenant name', + 400 + ) } - exchangeData.data.forEach(credData => { + exchangeData.data.forEach((credData) => { if (!credData.vc || credData.subjectData) { - throw new ExchangeError("Incomplete exchange data - you must provide either a vc or subjectData", 400) + throw new ExchangeError( + 'Incomplete exchange data - you must provide either a vc or subjectData', + 400 + ) } if (credData.subjectData && !batchId) { - throw new ExchangeError("Incomplete exchange data - if you provide subjectData, you must also provide a batchId", 400) + throw new ExchangeError( + 'Incomplete exchange data - if you provide subjectData, you must also provide a batchId', + 400 + ) } if (!credData.retrievalId) { - throw new ExchangeError("Incomplete exchange data - every submitted record must have it's own retrievalId.", 400) + throw new ExchangeError( + "Incomplete exchange data - every submitted record must have it's own retrievalId.", + 400 + ) } }) } - class ExchangeError extends Error { constructor(msg, code) { - super(msg); + super(msg) this.code = code } -} \ No newline at end of file +}