diff --git a/package-lock.json b/package-lock.json index 68bc1cd28f..b813d4197f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "dotenv": "~16.4.5", "escape-html": "~1.0.3", "express": "~4.19.2", + "express-async-errors": "~3.1.1", "express-rate-limit": "~7.2.0", "history": "~5.3.0", "ini": "~4.1.3", @@ -4768,6 +4769,14 @@ "node": ">= 0.10.0" } }, + "node_modules/express-async-errors": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz", + "integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==", + "peerDependencies": { + "express": "^4.16.2" + } + }, "node_modules/express-rate-limit": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.2.0.tgz", diff --git a/package.json b/package.json index 1de1846736..4727698fa5 100755 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "dotenv": "~16.4.5", "escape-html": "~1.0.3", "express": "~4.19.2", + "express-async-errors": "~3.1.1", "express-rate-limit": "~7.2.0", "history": "~5.3.0", "ini": "~4.1.3", diff --git a/src/client/app/components/admin/users/CreateUserModalComponent.tsx b/src/client/app/components/admin/users/CreateUserModalComponent.tsx index 263eead62e..087263bb93 100644 --- a/src/client/app/components/admin/users/CreateUserModalComponent.tsx +++ b/src/client/app/components/admin/users/CreateUserModalComponent.tsx @@ -130,7 +130,7 @@ export default function CreateUserModal() { showErrorNotification( translate('users.failed.to.create.user') + translate('users.successfully.edit.user.username') + userDetails.username + ') ' + - error.data.message); + error.data); resetPasswordFields(); }); }; diff --git a/src/client/app/components/admin/users/EditUserModalComponent.tsx b/src/client/app/components/admin/users/EditUserModalComponent.tsx index 3816a174cc..0abf64729b 100644 --- a/src/client/app/components/admin/users/EditUserModalComponent.tsx +++ b/src/client/app/components/admin/users/EditUserModalComponent.tsx @@ -209,7 +209,7 @@ export default function EditUserModalComponent(props: EditUserModalComponentProp showErrorNotification( translate('users.failed.to.edit.user') + translate('users.successfully.edit.user.username') + userDetails.username + ') ' + - error.data.message + error.data ); }); resetPasswordFields(); @@ -228,7 +228,7 @@ export default function EditUserModalComponent(props: EditUserModalComponentProp showErrorNotification( translate('users.failed.to.delete.user') + translate('users.successfully.edit.user.username') + props.user.username + ') ' + - error.data.message + error.data ); }); }; diff --git a/src/client/app/components/conversion/EditConversionModalComponent.tsx b/src/client/app/components/conversion/EditConversionModalComponent.tsx index f677e204f5..fe42d5f4c5 100644 --- a/src/client/app/components/conversion/EditConversionModalComponent.tsx +++ b/src/client/app/components/conversion/EditConversionModalComponent.tsx @@ -420,7 +420,7 @@ export default function EditConversionModalComponent(props: EditConversionModalC translate('conversion.delete.failure') + ' (' + translate('conversion.source') + ' "' + unitDataById[payload.sourceId]?.identifier + '"' + ', ' + translate('conversion.destination') + ' "' + unitDataById[payload.destinationId]?.identifier + '") ' + - error.data.message + error.data ); }); }; diff --git a/src/client/app/components/groups/EditGroupModalComponent.tsx b/src/client/app/components/groups/EditGroupModalComponent.tsx index f1f364e032..7c640bb57d 100644 --- a/src/client/app/components/groups/EditGroupModalComponent.tsx +++ b/src/client/app/components/groups/EditGroupModalComponent.tsx @@ -205,9 +205,11 @@ export default function EditGroupModalComponent(props: EditGroupModalComponentPr deleteGroup(groupState.id) .unwrap() .then(() => { - showSuccessNotification(translate('group.delete.success') + ' ' + groupState.name); + showSuccessNotification( + translate('group.delete.success') + ' (' + translate('name') + ' "' + groupState.name + '")' + ); }).catch(error => { - showErrorNotification(translate('group.delete.failure') + error.data.message); + showErrorNotification(translate('group.delete.failure') + ' ' + error.data); }); }; /* End Confirm Delete Modal */ diff --git a/src/client/app/components/meters/EditMeterModalComponent.tsx b/src/client/app/components/meters/EditMeterModalComponent.tsx index ca7b569e67..70a934f630 100644 --- a/src/client/app/components/meters/EditMeterModalComponent.tsx +++ b/src/client/app/components/meters/EditMeterModalComponent.tsx @@ -239,6 +239,7 @@ export default function EditMeterModalComponent(props: EditMeterModalComponentPr (unitDataById[props.meter.unitId].unitRepresent != UnitRepresentType.quantity && unitDataById[localMeterEdits.unitId].unitRepresent == UnitRepresentType.quantity)); + // Submit new meter if checks where ok. editMeter({ meterData: submitState, shouldRefreshViews: shouldRefreshReadingViews }) .unwrap() diff --git a/src/server/app.js b/src/server/app.js index 816af2ef19..03a26d1abc 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -4,6 +4,11 @@ const fs = require('fs'); const express = require('express'); +// TODO Temporary patch for Express 4, which does not forward rejected async route handler +// promises to the global error handler on its own (Express 5 does this natively). Remove this +// import (and the express-async-errors dependency in package.json) once Express 5 is adopted β€” +// see issue #1676 for that migration. +require('express-async-errors'); const rateLimit = require('express-rate-limit'); const path = require('path'); const favicon = require('serve-favicon'); @@ -182,7 +187,9 @@ app.use((err, req, res, next) => { return res.status(HTTP_CODES.BAD_REQUEST).send('Bad Request'); } - log.error('Unhandled request error caught by global error handler; logging forwarded err object.', err); + // Include the method/URL so the admin-visible log entry can be traced back to a specific + // request instead of just showing a generic message with no route context. + log.error(`Unhandled request error caught by global error handler for ${req.method} ${req.originalUrl}; logging forwarded err object.`, err); // If response headers are already sent, Express cannot safely change the response // Forward to the default Express handler to finish error if (res.headersSent) { diff --git a/src/server/log.js b/src/server/log.js index 627c168d86..375039a73a 100644 --- a/src/server/log.js +++ b/src/server/log.js @@ -60,6 +60,13 @@ class Logger { if (error !== null) { if (error.stack) { messageToLog += `Stacktrace: \n${error.stack}\n`; + // If this error wraps another one (e.g. new Error(msg, { cause: originalErr })), + // also print the original's stack so console/file output isn't limited to just + // where the wrapper was created β€” this only affects console/file output, not the + // message stored in the DB, which is unaffected either way. + if (error.cause && error.cause.stack) { + messageToLog += `Caused by: \n${error.cause.stack}\n`; + } } else { // It's possible someone passed in an error that isn't actually an Error object // because javascript lets you throw anything. In that case, the error won't have diff --git a/src/server/routes/baseline.js b/src/server/routes/baseline.js index 68c4444b9c..8d7a4a79ed 100644 --- a/src/server/routes/baseline.js +++ b/src/server/routes/baseline.js @@ -6,20 +6,20 @@ const { getConnection } = require('../db'); const express = require('express'); const Baseline = require('../models/Baseline'); -const log = require('../log'); const validate = require('jsonschema').validate; const { adminAuthMiddleware } = require('./authenticator'); const { STRING_GENERAL_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); const { isValidIsoDateTime } = require('../util/timeValidation'); +const { success, failure } = require('./response'); const router = express.Router(); router.get('/', async (req, res) => { const conn = getConnection(); try { const rawBaselines = await Baseline.getAllBaselines(conn); - res.json(rawBaselines); + success(res, rawBaselines); } catch (err) { - log(`Error while getting all baselines: ${err}`, 'error'); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while getting all baselines: ${err.message}`, { cause: err })); } }); router.post('/new', adminAuthMiddleware('create baselines'), async (req, res) => { @@ -59,14 +59,14 @@ router.post('/new', adminAuthMiddleware('create baselines'), async (req, res) => }; if (!validate(req.body, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } // baseline.js does not use moment; validate date strings directly // TODO This might not stay and is not used in OED now but need to see if it has a timezone for the check. if (!isValidIsoDateTime(req.body.applyStart) || !isValidIsoDateTime(req.body.applyEnd) || !isValidIsoDateTime(req.body.calcStart) || !isValidIsoDateTime(req.body.calcEnd)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } @@ -80,10 +80,9 @@ router.post('/new', adminAuthMiddleware('create baselines'), async (req, res) => req.body.calcEnd, req.body.note); await baseline.insert(conn); - res.sendStatus(HTTP_CODES.OK); + success(res); } catch (err) { - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); - log(`Error while adding baseline: ${err}`, 'error'); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while adding baseline: ${err.message}`, { cause: err })); } }); module.exports = router; diff --git a/src/server/routes/ciks.js b/src/server/routes/ciks.js index 5eabee2f83..861d0fba4d 100644 --- a/src/server/routes/ciks.js +++ b/src/server/routes/ciks.js @@ -3,9 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const express = require('express'); -const { log } = require('../log'); const { getConnection } = require('../db'); const Cik = require('../models/Cik'); +const { success, failure } = require('./response'); +const { HTTP_CODES } = require('../util/httpCodes'); const router = express.Router(); @@ -27,8 +28,8 @@ router.get('/', async (req, res) => { const conn = getConnection(); try { const rows = await Cik.getAll(conn); - res.json(rows.map(formatCikForResponse)); + success(res, rows.map(formatCikForResponse)); } catch (err) { - log.error(`Error while performing GET ciks details query: ${err}`); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET ciks details query: ${err.message}`, { cause: err })); } }); \ No newline at end of file diff --git a/src/server/routes/compareReadings.js b/src/server/routes/compareReadings.js index e59a6c58c1..a472d6f431 100644 --- a/src/server/routes/compareReadings.js +++ b/src/server/routes/compareReadings.js @@ -12,6 +12,7 @@ const Reading = require('../models/Reading'); const { STRING_GENERAL_MAX_LENGTH, NUMERIC_ID_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); const { isValidIsoDuration } = require('../util/timeValidation'); +const { success, failure } = require('./response'); const DATE_TIME_WITH_TIME_REGEX = /^\d{4}-\d{2}-\d{2}(?:T| )\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/; @@ -118,7 +119,7 @@ function createRouter() { router.get('/meters/:meter_ids', async (req, res) => { if (!(validateMeterCompareReadingsParams(req.params) && validateQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } const meterIDs = req.params.meter_ids.split(',').map(id => parseInt(id)); @@ -128,20 +129,24 @@ function createRouter() { const shiftRaw = req.query.shift; if (!isValidCompareDateTime(currStartRaw) || !isValidCompareDateTime(currEndRaw) || !isValidIsoDuration(shiftRaw)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } - // The string sent should set the timezone to UTC so honor that as OED uses UTC. - const currStart = moment.parseZone(currStartRaw, moment.ISO_8601, true); - const currEnd = moment.parseZone(currEndRaw, moment.ISO_8601, true); - const shift = moment.duration(shiftRaw); - res.json(await meterCompareReadings(meterIDs, graphicUnitID, currStart, currEnd, shift)); + try { + // The string sent should set the timezone to UTC so honor that as OED uses UTC. + const currStart = moment.parseZone(currStartRaw, moment.ISO_8601, true); + const currEnd = moment.parseZone(currEndRaw, moment.ISO_8601, true); + const shift = moment.duration(shiftRaw); + success(res, await meterCompareReadings(meterIDs, graphicUnitID, currStart, currEnd, shift)); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET meter compare readings: ${err.message}`, { cause: err })); + } }); router.get('/groups/:group_ids', async (req, res) => { if (!(validateGroupCompareReadingsParams(req.params) && validateQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } const groupIDs = req.params.group_ids.split(',').map(id => parseInt(id)); @@ -151,15 +156,19 @@ function createRouter() { const shiftRaw = req.query.shift; if (!isValidCompareDateTime(currStartRaw) || !isValidCompareDateTime(currEndRaw) || !isValidIsoDuration(shiftRaw)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } - // The string sent should set the timezone to UTC so honor that as OED uses UTC. - const currStart = moment.parseZone(currStartRaw, moment.ISO_8601, true); - const currEnd = moment.parseZone(currEndRaw, moment.ISO_8601, true); - const shift = moment.duration(shiftRaw); - res.json(await groupCompareReadings(groupIDs, graphicUnitID, currStart, currEnd, shift)); + try { + // The string sent should set the timezone to UTC so honor that as OED uses UTC. + const currStart = moment.parseZone(currStartRaw, moment.ISO_8601, true); + const currEnd = moment.parseZone(currEndRaw, moment.ISO_8601, true); + const shift = moment.duration(shiftRaw); + success(res, await groupCompareReadings(groupIDs, graphicUnitID, currStart, currEnd, shift)); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET group compare readings: ${err.message}`, { cause: err })); + } }); return router; diff --git a/src/server/routes/conversionArray.js b/src/server/routes/conversionArray.js index 9202bc62ac..ed3a06b1c1 100644 --- a/src/server/routes/conversionArray.js +++ b/src/server/routes/conversionArray.js @@ -9,6 +9,7 @@ const { refreshAllReadingViews } = require('../services/refreshAllReadingViews') const validate = require('jsonschema').validate; const { adminAuthMiddleware } = require('./authenticator'); const { HTTP_CODES } = require('../util/httpCodes'); +const { success, failure } = require('./response'); const router = express.Router(); @@ -30,20 +31,22 @@ router.post('/refresh', adminAuthMiddleware('conversion refresh system data'), a }; if (!validate(req.body, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } - // TODO: Add try/catch error handling to properly handle failures during Cik refresh - // or reading view refresh operations and return appropriate error responses. - if (req.body.redoCik) { - const conn = getConnection(); - await redoCik(conn); - } - if (req.body.refreshReadingViews) { - await refreshAllReadingViews(); + try { + if (req.body.redoCik) { + const conn = getConnection(); + await redoCik(conn); + } + if (req.body.refreshReadingViews) { + await refreshAllReadingViews(); + } + success(res); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing conversion array refresh: ${err.message}`, { cause: err })); } - res.sendStatus(HTTP_CODES.OK); }); module.exports = router; diff --git a/src/server/routes/conversions.js b/src/server/routes/conversions.js index dd719c6052..2429696ffc 100644 --- a/src/server/routes/conversions.js +++ b/src/server/routes/conversions.js @@ -3,10 +3,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const express = require('express'); -const { log } = require('../log'); const { getConnection } = require('../db'); const Conversion = require('../models/Conversion'); -const { success, failure } = require('./response'); +const { success, failure, LogLevel } = require('./response'); const { HTTP_CODES } = require('../util/httpCodes'); const validate = require('jsonschema').validate; @@ -79,8 +78,7 @@ router.get('/', optionalAuthMiddleware, async (req, res) => { const rows = await Conversion.getAll(conn); res.json(rows.map(formatConversionForResponse)); } catch (err) { - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); - log.error(`Error while performing GET conversions details query: ${err}`); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET conversions details query: ${err.message}`, { cause: err })); } }); @@ -91,8 +89,8 @@ router.post('/edit', adminAuthMiddleware('edit conversions'), async (req, res) = const validatorResult = validateConversionsParams(req.body); if (!validatorResult.valid) { - log.warn(`Got request to edit conversions with invalid conversion data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, `Got request to edit conversions with invalid conversion data, errors: ${validatorResult.errors}`); + const message = `Got request to edit conversions with invalid conversion data, errors: ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, message, LogLevel.WARN); return; } else { const conn = getConnection(); @@ -100,11 +98,10 @@ router.post('/edit', adminAuthMiddleware('edit conversions'), async (req, res) = const updatedConversion = new Conversion(req.body.sourceId, req.body.destinationId, req.body.bidirectional, req.body.slope, req.body.intercept, req.body.note); await updatedConversion.update(conn); + success(res); } catch (err) { - log.error(`Error while editing conversion with error(s): ${err}`); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, `Error while editing conversion with error(s): ${err}`); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while editing conversion with error(s): ${err.message}`, { cause: err })); } - success(res); } }); @@ -115,8 +112,8 @@ router.post('/addConversion', adminAuthMiddleware('add conversions'), async (req const validatorResult = validateConversionsParams(req.body); if (!validatorResult.valid) { - log.error(`Got request to insert conversion with invalid conversion data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, `Got request to insert conversion with invalid conversion data. Error(s): ${validatorResult.errors}`); + const message = `Got request to insert conversion with invalid conversion data. Error(s): ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const conn = getConnection(); try { @@ -131,10 +128,9 @@ router.post('/addConversion', adminAuthMiddleware('add conversions'), async (req ); await newConversion.insert(t); }); - res.sendStatus(HTTP_CODES.OK); + success(res); } catch (err) { - log.error(`Error while inserting new conversion with error(s): ${err}`); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, `Error while inserting new conversion with errors(s): ${err}`); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while inserting new conversion with error(s): ${err.message}`, { cause: err })); } } }); @@ -174,8 +170,8 @@ router.post('/delete', adminAuthMiddleware('delete conversions'), async (req, re const validatorResult = validate(req.body, validConversion); if (!validatorResult.valid) { - log.error(`Got request to delete conversions with invalid conversion data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, `Got request to delete conversions with invalid conversion data. Error(s): ${validatorResult.errors}`); + const message = `Got request to delete conversions with invalid conversion data. Error(s): ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const { sourceId, destinationId, meterIds = [], groupIds = [] } = req.body; const conn = getConnection(); @@ -194,8 +190,7 @@ router.post('/delete', adminAuthMiddleware('delete conversions'), async (req, re }); success(res, 'Successfully deleted conversion and updated meters/groups'); } catch (err) { - log.error(`Error while deleting conversion and updating meters/groups: ${err}`); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, `Error while deleting conversion and updating meters/groups: ${err}`); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while deleting conversion and updating meters/groups: ${err.message}`, { cause: err })); } } }); @@ -211,16 +206,15 @@ router.post('/simulate-delete', adminAuthMiddleware('simulate deleting conversio }; const validatorResult = validate(req.body, validConversion); if (!validatorResult.valid) { - log.warn(`Got request to simulate deletion of conversions with invalid conversion data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, `Got request to delete conversions with invalid conversion data. Error(s): ${validatorResult.errors}`); + const message = `Got request to simulate deletion of conversions with invalid conversion data. Error(s): ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, message, LogLevel.WARN); } else { try { const conn = getConnection(); const result = await simulateDeleteConversion(req.body, conn); - return res.json(result); + success(res, result); } catch (err) { - log.error(`Error while simulating deletion of conversion with error(s): ${err}`); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, `Error while simulating deletion of conversion with errors(s): ${err}`); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while simulating deletion of conversion with error(s): ${err.message}`, { cause: err })); } } }); diff --git a/src/server/routes/groups.js b/src/server/routes/groups.js index 9eed070d9c..020ca904c4 100644 --- a/src/server/routes/groups.js +++ b/src/server/routes/groups.js @@ -10,9 +10,8 @@ const Unit = require('../models/Unit'); const { getConnection } = require('../db'); const Group = require('../models/Group'); const { adminAuthMiddleware, optionalAuthMiddleware } = require('./authenticator'); -const { log } = require('../log'); const Point = require('../models/Point'); -const { failure, success } = require('./response'); +const { failure, success, LogLevel } = require('./response'); const { HTTP_CODES } = require('../util/httpCodes'); const { STRING_GENERAL_MAX_LENGTH, STRING_SHORT_MAX_LENGTH: SHORT_STRING_MAX_LENGTH, NUMERIC_ID_MAX_LENGTH } = require('../util/validationConstants'); @@ -138,15 +137,14 @@ router.get('/', optionalAuthMiddleware, async (req, res) => { const conn = getConnection(); try { const rows = await Group.getAll(conn); - const promises = await rows.map(async (row) => { + const promises = rows.map(async (row) => { const deepMeters = await Group.getDeepMetersByGroupID(row.id, conn); return { ...row, deepMeters: deepMeters}; }); - Promise.all(promises).then(function (values) { - res.json(values.map(formatGroupForResponse)); - }) + const values = await Promise.all(promises); + res.json(values.map(formatGroupForResponse)); } catch (err) { - log.error(`Error while preforming GET all groups query: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET all groups query: ${err.message}`, { cause: err })); } }); @@ -159,7 +157,7 @@ router.get('/idname', optionalAuthMiddleware, async (req, res) => { const rows = await Group.getAll(conn); res.json(rows.map(formatToOnlyNameID)); } catch (err) { - log.error(`Error while performing GET all groups query: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET all groups id and name query: ${err.message}`, { cause: err })); } }); @@ -171,15 +169,14 @@ router.get('/deep/groups', adminAuthMiddleware('view deep groups'), async (req, const conn = getConnection(); try{ const rows = await Group.getAll(conn); - const promises = await rows.map(async (row) => { + const promises = rows.map(async (row) => { const deepGroups = await Group.getDeepGroupsByGroupID(row.id, conn); return { ...row, deepGroups: deepGroups, deepMeters: [] }; }); - Promise.all(promises).then(function (values) { - res.json(values.map(formatGroupForResponse)); - }); + const values = await Promise.all(promises); + res.json(values.map(formatGroupForResponse)); } catch (err) { - log.error(`Error while performing GET deep groups for all groups query: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET deep groups for all groups query: ${err.message}`, { cause: err })); } }); @@ -200,7 +197,7 @@ router.get('/children/:group_id', optionalAuthMiddleware, async (req, res) => { ]); res.json({ meters, groups, deepMeters }); } catch (err) { - log.error(`Error while preforming GET on all immediate children (meters and groups) of specific group: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET on all immediate children (meters and groups) of specific group: ${err.message}`, { cause: err })); } }); @@ -217,7 +214,7 @@ router.get('/allChildren/', optionalAuthMiddleware, async (req, res) => { const allChildren = await Group.getImmediateChildren(conn); res.json(allChildren); } catch (err) { - log.error(`Error while preforming GET on all immediate children (meters and groups) of all groups: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET on all immediate children (meters and groups) of all groups: ${err.message}`, { cause: err })); } }); @@ -236,16 +233,15 @@ router.get('/deep/groups/:group_id', optionalAuthMiddleware, async (req, res) => }; const validatorResult = validate(req.params, validParams); if (!validatorResult.valid) { - log.error(`Got request group deep group children with invalid data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, "Got request group deep group children with invalid data. Error(s): " + validatorResult.errors.toString()); + const message = "Got request group deep group children with invalid data. Error(s): " + validatorResult.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const conn = getConnection(); try { const deepGroups = await Group.getDeepGroupsByGroupID(req.params.group_id, conn); res.json({ deepGroups }); } catch (err) { - log.error(`Error while preforming GET on all deep child groups of specific group: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET on all deep child groups of specific group: ${err.message}`, { cause: err })); } } }); @@ -265,16 +261,15 @@ router.get('/deep/meters/:group_id', optionalAuthMiddleware, async (req, res) => }; const validatorResult = validate(req.params, validParams); if (!validatorResult.valid) { - log.error(`Got request group deep meter children with invalid data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, "Got request group deep meter children with invalid data. Error(s): " + validatorResult.errors.toString()); + const message = "Got request group deep meter children with invalid data. Error(s): " + validatorResult.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const conn = getConnection(); try { const deepMeters = await Group.getDeepMetersByGroupID(req.params.group_id, conn); res.json({ deepMeters }); } catch (err) { - log.error(`Error while preforming GET on all deep child meters of specific group: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET on all deep child meters of specific group: ${err.message}`, { cause: err })); } } }); @@ -294,16 +289,15 @@ router.get('/parents/:group_id', optionalAuthMiddleware, async (req, res) => { }; const validatorResult = validate(req.params, validParams); if (!validatorResult.valid) { - log.error(`Got request group parents with invalid data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, "Got request group parents with invalid data. Error(s): " + validatorResult.errors.toString()); + const message = "Got request group parents with invalid data. Error(s): " + validatorResult.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const conn = getConnection(); try { const parentGroups = await Group.getParentsByGroupID(req.params.group_id, conn); res.json(parentGroups); } catch (err) { - log.error(`Error while preforming GET on all parents of specific group: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET on all parents of specific group: ${err.message}`, { cause: err })); } } }); @@ -313,8 +307,8 @@ router.post('/create', adminAuthMiddleware('create groups'), async (req, res) => const validatorResult = validateGroupsParams(req.body, false); if (!validatorResult.valid) { - log.error(`Got request to create group with invalid data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, "Got request to create group with invalid data. Error(s): " + validatorResult.errors.toString()); + const message = "Got request to create group with invalid data. Error(s): " + validatorResult.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const conn = getConnection(); try { @@ -340,10 +334,10 @@ router.post('/create', adminAuthMiddleware('create groups'), async (req, res) => } catch (err) { // Group duplicate-name DB errors to a safe 400 response if (err.toString() === 'error: duplicate key value violates unique constraint "groups_name_key"') { - failure(res, HTTP_CODES.BAD_REQUEST, 'Group name already exists'); + failure(res, HTTP_CODES.BAD_REQUEST, err, 'Group name already exists', LogLevel.SILENT); } else { - log.error(`Error while inserting new group ${err}`, err); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, err.toString() + ' with detail ' + err['detail']); + const detail = err['detail'] ? ` with detail ${err['detail']}` : ''; + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while inserting new group: ${err.message}${detail}`, { cause: err })); } } } @@ -354,8 +348,8 @@ router.put('/edit', adminAuthMiddleware('edit groups'), async (req, res) => { const validatorResult = validateGroupsParams(req.body, true); if (!validatorResult.valid) { - log.error(`Got request to edit group with invalid data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, "Got request to edit group with invalid data. Error(s): " + validatorResult.errors.toString()); + const message = "Got request to edit group with invalid data. Error(s): " + validatorResult.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { try { const conn = getConnection(); @@ -393,13 +387,12 @@ router.put('/edit', adminAuthMiddleware('edit groups'), async (req, res) => { return t.batch(flatten([adoptGroupsQueries, disownGroupsQueries, adoptMetersQueries, disownMetersQueries])); }); - res.sendStatus(HTTP_CODES.OK); + success(res); } catch (err) { if (err.message && err.message === 'Cyclic group detected') { - res.status(HTTP_CODES.BAD_REQUEST).send({ message: err.message }); + failure(res, HTTP_CODES.BAD_REQUEST, err, err.message, LogLevel.SILENT); } else { - log.error(`Error while editing existing group ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while editing existing group: ${err.message}`, { cause: err })); } } } @@ -417,16 +410,15 @@ router.post('/delete', adminAuthMiddleware('delete groups'), async (req, res) => const validatorResult = validate(req.body, validParams); if (!validatorResult.valid) { - log.error(`Got request to delete group with invalid data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, "Got request to delete group with invalid data. Error(s): " + validatorResult.errors.toString()); + const message = "Got request to delete group with invalid data. Error(s): " + validatorResult.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const conn = getConnection(); try { await Group.delete(req.body.id, conn); - res.sendStatus(HTTP_CODES.OK); + success(res); } catch (err) { - log.error(`Error while deleting group ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while deleting group: ${err.message}`, { cause: err })); } } }); diff --git a/src/server/routes/loginLogout.js b/src/server/routes/loginLogout.js index fd6d68745b..95670214f1 100644 --- a/src/server/routes/loginLogout.js +++ b/src/server/routes/loginLogout.js @@ -8,11 +8,11 @@ const jwt = require('jsonwebtoken'); const User = require('../models/User'); const secretToken = require('../config').secretToken; const validate = require('jsonschema').validate; -const { log } = require('../log'); const { getConnection } = require('../db'); const { credentialsRequestValidationMiddleware, verifyActiveTokenAndGetUser } = require('./authenticator'); const { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, TOKEN_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); +const { success, failure, LogLevel } = require('./response'); const router = express.Router(); /** @@ -40,7 +40,7 @@ router.post('/login', credentialsRequestValidationMiddleware, async (req, res) = }; if (!validate(req.body, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { const conn = getConnection(); try { @@ -55,16 +55,15 @@ router.post('/login', credentialsRequestValidationMiddleware, async (req, res) = const isValid = user !== null && passwordMatches; if (isValid) { const token = jwt.sign({ data: user.id }, secretToken, { expiresIn: 86400 }); - res.json({ token: token, username: user.username, role: user.role }); + success(res, { token: token, username: user.username, role: user.role }); } else { throw new Error('Unauthorized password'); } } catch (err) { if (err.message === 'Unauthorized password' || err.message === 'No data returned from the query.') { - res.status(HTTP_CODES.UNAUTHORIZED).send({ text: 'Not authorized' }); + failure(res, HTTP_CODES.UNAUTHORIZED, err, { text: 'Not authorized' }, LogLevel.SILENT); } else { - log.error(`Unable to check user password for ${req.body.username}`, err); - res.status(HTTP_CODES.INTERNAL_SERVER_ERROR).send({ text: 'Internal Server Error' }); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Unable to check user password for ${req.body.username}: ${err.message}`, { cause: err })); } } } @@ -94,7 +93,7 @@ router.post('/logout', async (req, res) => { }; if (!validate(req.body, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); return; } @@ -106,15 +105,14 @@ router.post('/logout', async (req, res) => { const { user } = await verifyActiveTokenAndGetUser(req.body.token); const conn = getConnection(); await User.invalidateTokensBeforeNow(user.id, conn); - res.status(HTTP_CODES.OK).json({ success: true, message: 'Logout successful.' }); + success(res, { success: true, message: 'Logout successful.' }); } catch (error) { if (error.code === 'TOKEN_INVALIDATED') { - res.status(HTTP_CODES.OK).json({ success: true, message: 'Logout successful.' }); + success(res, { success: true, message: 'Logout successful.' }); } else if (error.message === 'No data returned from the query.') { - res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'Logout failed.' }); + failure(res, HTTP_CODES.UNAUTHORIZED, null, { success: false, message: 'Logout failed.' }); } else { - log.error('Logout failed while invalidating user tokens.', error); - res.status(HTTP_CODES.INTERNAL_SERVER_ERROR).json({ success: false, message: 'Logout failed.' }); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Logout failed while invalidating user tokens: ${error.message}`, { cause: error })); } } }); diff --git a/src/server/routes/logs.js b/src/server/routes/logs.js index e1f19015e5..f1c7ab0be6 100644 --- a/src/server/routes/logs.js +++ b/src/server/routes/logs.js @@ -14,6 +14,7 @@ const { TimeInterval } = require('../../common/TimeInterval'); const { STRING_GENERAL_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); const { isValidTimeInterval } = require('../util/timeValidation'); +const { success, failure } = require('./response'); const router = express.Router(); @@ -54,10 +55,9 @@ router.post('/info', adminAuthMiddleware('create info log'), async (req, res) => const validationResult = validate(req.body, validLog); if (validationResult.valid) { log.info(req.body.message); - res.sendStatus(HTTP_CODES.OK); + success(res); } else { - log.error('invalid input from client logger'); - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST, 'invalid input from client logger'); } }); @@ -65,10 +65,9 @@ router.post('/warn', adminAuthMiddleware('create warn log'), async (req, res) => const validationResult = validate(req.body, validLog); if (validationResult.valid) { log.warn(req.body.message); - res.sendStatus(HTTP_CODES.OK); + success(res); } else { - log.error('invalid input from client logger'); - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST, 'invalid input from client logger'); } }); @@ -76,18 +75,16 @@ router.post('/error', adminAuthMiddleware('create error log'), async (req, res) const validationResult = validate(req.body, validLog); if (validationResult.valid) { log.error(req.body.message); - res.sendStatus(HTTP_CODES.OK); + success(res); } else { - log.error('invalid input from client logger'); - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST, 'invalid input from client logger'); } }); router.get('/logsmsg/getLogsByDateRangeAndType', adminAuthMiddleware('view logs'), async (req, res) => { const validationResult = validate(req.query, validLogMsg); if (!validationResult.valid || !isValidTimeInterval(req.query.timeInterval)) { - log.error('invalid request to getLogsByDateRangeAndType'); - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST, 'invalid request to getLogsByDateRangeAndType'); } else { try { const conn = getConnection(); @@ -97,10 +94,9 @@ router.get('/logsmsg/getLogsByDateRangeAndType', adminAuthMiddleware('view logs' const rows = await LogMsg.getLogsByDateRangeAndType( timeInterval.startTimestamp, timeInterval.endTimestamp, logTypes, logLimit, conn ); - res.json(rows); + success(res, rows); } catch (err) { - log.error(`Failed to fetch logs filtered by date range and type: ${err}`); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Failed to fetch logs filtered by date range and type: ${err.message}`, { cause: err })); } } }); diff --git a/src/server/routes/maps.js b/src/server/routes/maps.js index d147a7b2b0..2c72ac02b8 100644 --- a/src/server/routes/maps.js +++ b/src/server/routes/maps.js @@ -16,6 +16,7 @@ const { STRING_GENERAL_MAX_LENGTH, STRING_SHORT_MAX_LENGTH: SHORT_STRING_MAX_LEN const { HTTP_CODES } = require('../util/httpCodes'); const { isValidIsoDateTime } = require('../util/timeValidation'); const omit = require('lodash/omit'); +const { success, failure, LogLevel } = require('./response'); const router = express.Router(); @@ -156,7 +157,7 @@ router.get('/', optionalAuthMiddleware, async (req, res) => { const rows = await query(conn); res.json(rows.map(row => formatMapForResponse(row))); } catch (err) { - log.error(`Error while performing GET all maps query: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET all maps query: ${err.message}`, { cause: err })); } }); @@ -174,15 +175,14 @@ router.get('/:map_id', optionalAuthMiddleware, async (req, res) => { } }; if (!validate(req.params, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { const conn = getConnection(); try { const map = await Map.getByID(req.params.map_id, conn); res.json(formatMapForResponse(map)); } catch (err) { - log.error(`Error while performing GET specific map by id query: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET specific map by id query: ${err.message}`, { cause: err })); } } }); @@ -201,8 +201,7 @@ router.post('/create', adminAuthMiddleware('create maps'), async (req, res) => { // This is a comment so if if fails someone knows to see if the second parameter should be false. If it works // then this can be removed. if (!validationResult.valid || !isValidIsoDateTime(req.body.modifiedDate)) { - log.error(`Invalid input for mapAPI. ${validationResult.errors}`); - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST, `Invalid input for mapAPI. ${validationResult.errors}`); } else { const conn = getConnection(); try { @@ -227,13 +226,12 @@ router.post('/create', adminAuthMiddleware('create maps'), async (req, res) => { ); await newMap.insert(t); }); - res.sendStatus(HTTP_CODES.OK); + success(res); } catch (err) { if (err.toString() === 'error: duplicate key value violates unique constraint "maps_name_key"') { - res.status(HTTP_CODES.BAD_REQUEST).json({ error: `Map "${req.body.name}" is already in use.` }); + failure(res, HTTP_CODES.BAD_REQUEST, err, { error: `Map "${req.body.name}" is already in use.` }, LogLevel.SILENT); } else { - log.error(`Error while inserting new map ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while inserting new map: ${err.message}`, { cause: err })); } } } @@ -250,8 +248,7 @@ router.post('/edit', adminAuthMiddleware('edit maps'), async (req, res) => { // TODO edit, unlike create, is not currently sending a time zone with the modifiedDate. It is unclear // why they differ but for now don't require it here. if (!validatorResult.valid || !isValidIsoDateTime(req.body.modifiedDate, false)) { - log.error(`Invalid map data supplied, err: ${validatorResult.errors}`); - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST, `Invalid map data supplied, err: ${validatorResult.errors}`); } else { const conn = getConnection(); try { @@ -273,15 +270,13 @@ router.post('/edit', adminAuthMiddleware('edit maps'), async (req, res) => { ); await editedMap.update(t); }); - res.sendStatus(HTTP_CODES.OK); + success(res); log.info(`Successfully edited map ${req.body.id}`); } catch (err) { if (err.toString() === 'error: duplicate key value violates unique constraint "maps_name_key"') { - res.sendStatus(HTTP_CODES.BAD_REQUEST); - log.error(`Map "${req.body.name}" is already in use.`); + failure(res, HTTP_CODES.BAD_REQUEST, `Map "${req.body.name}" is already in use.`); } else { - log.error(`Error while updating map ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while updating map: ${err.message}`, { cause: err })); } } } @@ -301,15 +296,14 @@ router.post('/delete', adminAuthMiddleware('delete maps'), async (req, res) => { } }; if (!validate(req.body, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { const conn = getConnection(); try { await Map.delete(req.body.id, conn); - res.sendStatus(HTTP_CODES.OK); + success(res); } catch (err) { - log.error(`Error while deleting group ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while deleting map: ${err.message}`, { cause: err })); } } }); diff --git a/src/server/routes/meters.js b/src/server/routes/meters.js index 73a869cd31..0e4804f197 100644 --- a/src/server/routes/meters.js +++ b/src/server/routes/meters.js @@ -6,7 +6,6 @@ const express = require('express'); const Meter = require('../models/Meter'); const User = require('../models/User'); const Unit = require('../models/Unit'); -const { log } = require('../log'); const validate = require('jsonschema').validate; const { getConnection } = require('../db'); const { isTokenAuthorized } = require('../util/userRoles'); @@ -15,7 +14,7 @@ const Point = require('../models/Point'); const moment = require('moment'); const { MeterTimeSortTypesJS } = require('../services/csvPipeline/validateCsvUploadParams'); const merge = require('lodash/merge'); -const { failure, success } = require('./response'); +const { failure, success, LogLevel } = require('./response'); const { updateNonNullExpression } = require('typescript'); const { STRING_GENERAL_MAX_LENGTH, STRING_SHORT_MAX_LENGTH: SHORT_STRING_MAX_LENGTH, NUMERIC_ID_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); @@ -117,7 +116,7 @@ router.get('/', optionalAuthMiddleware, async (req, res) => { const rows = await query(conn); res.json(rows.map(row => formatMeterForResponse(row, isAuthorizedCSV))); } catch (err) { - log.error(`Error while performing GET all meters query: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET all meters query: ${err.message}`, { cause: err })); } }); @@ -140,7 +139,8 @@ router.get('/:meter_id', optionalAuthMiddleware, async (req, res) => { } }; if (!validate(req.params, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + // No message given so as not to reveal whether the meter exists, matching the branch below. + failure(res, HTTP_CODES.BAD_REQUEST); } else { const conn = getConnection(); try { @@ -150,11 +150,11 @@ router.get('/:meter_id', optionalAuthMiddleware, async (req, res) => { // not displayable but the user is logged in, also fine. res.json(formatMeterForResponse(meter, req.hasValidAuthToken)); } else { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + // No message given so as not to reveal that the meter exists but is non-displayable. + failure(res, HTTP_CODES.BAD_REQUEST); } } catch (err) { - log.error(`Error while performing GET specific meter by id query: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET specific meter by id query: ${err.message}`, { cause: err })); } } }); @@ -279,8 +279,8 @@ router.post('/edit', adminAuthMiddleware('edit meters'), async (req, res) => { // isEdit=true: id is required here since the client must tell us which meter to update. const response = validateMeterParams(req.body, true) if (!response.valid) { - log.warn(`Got request to edit a meter with invalid meter data, errors: ${response.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, 'validation failed with ' + response.errors.toString()); + const message = 'validation failed with ' + response.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message, LogLevel.WARN); } else if ( (req.body.startTimestamp && !isValidIsoDateTime(req.body.startTimestamp, false)) || (req.body.endTimestamp && !isValidIsoDateTime(req.body.endTimestamp, false)) || @@ -288,7 +288,7 @@ router.post('/edit', adminAuthMiddleware('edit meters'), async (req, res) => { (req.body.minDate && !isValidIsoDateTime(req.body.minDate)) || (req.body.maxDate && !isValidIsoDateTime(req.body.maxDate)) ) { - failure(res, HTTP_CODES.BAD_REQUEST, 'invalid date/time format'); + failure(res, HTTP_CODES.BAD_REQUEST, null, 'invalid date/time format'); } else { const conn = getConnection(); try { @@ -334,13 +334,17 @@ router.post('/edit', adminAuthMiddleware('edit meters'), async (req, res) => { // The frequency may be different since DB stores as interval so it is returned // and the meter updated by this value. meter.readingFrequency = await meter.update(conn); - // TODO This is not using the success function since it needs to return values. - // At some point we probably should fuse the success and returning values. // Need to format since some properties have different names than come from DB. - res.json(formatMeterForResponse(meter, true)); + success(res, formatMeterForResponse(meter, true)); } catch (err) { - log.error(`Error while editing a meter with detail "${err['detail']}"`, err); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, err.toString() + ' with detail ' + err['detail']); + if (err.toString().includes('duplicate key value violates unique constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Meter name "${req.body.name}" already exists`, LogLevel.SILENT); + } else if (err.toString().includes('violates check constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Invalid meter data: ${err.toString()}`, LogLevel.SILENT); + } else { + const detail = err['detail'] ? ` with detail "${err['detail']}"` : ''; + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while editing a meter${detail}: ${err.message}`, { cause: err })); + } } } }); @@ -352,8 +356,8 @@ router.post('/addMeter', adminAuthMiddleware('add meter'), async (req, res) => { // isEdit=false: id must not be present, since it's assigned by the DB on insert. const response = validateMeterParams(req.body, false) if (!response.valid) { - log.warn(`Got request to create a meter with invalid meter data, errors: ${response.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, 'validation failed with ' + response.errors.toString()); + const message = 'validation failed with ' + response.errors.toString(); + failure(res, HTTP_CODES.BAD_REQUEST, message, message, LogLevel.WARN); } else if ( // The default value for start/endTimestamp does have a timezone but it is not required nor put // in when OED sets the value later so not checked here. @@ -363,7 +367,7 @@ router.post('/addMeter', adminAuthMiddleware('add meter'), async (req, res) => { (req.body.minDate && !isValidIsoDateTime(req.body.minDate)) || (req.body.maxDate && !isValidIsoDateTime(req.body.maxDate)) ) { - failure(res, HTTP_CODES.BAD_REQUEST, 'invalid date/time format'); + failure(res, HTTP_CODES.BAD_REQUEST, null, 'invalid date/time format'); } else { const conn = getConnection(); try { @@ -405,13 +409,17 @@ router.post('/addMeter', adminAuthMiddleware('add meter'), async (req, res) => { ); // insert updates the newMeter values from DB. await newMeter.insert(conn); - // TODO This is not using the success function since it needs to return values. - // At some point we probably should fuse the success and returning values. // Need to format since some properties have different names than come from DB. - res.json(formatMeterForResponse(newMeter, true)); + success(res, formatMeterForResponse(newMeter, true)); } catch (err) { - log.error(`Error while inserting new meter with detail "${err['detail']}"`, err); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, err.toString() + ' with detail ' + err['detail']); + if (err.toString().includes('duplicate key value violates unique constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Meter name "${req.body.name}" already exists`, LogLevel.SILENT); + } else if (err.toString().includes('violates check constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Invalid meter data: ${err.toString()}`, LogLevel.SILENT); + } else { + const detail = err['detail'] ? ` with detail "${err['detail']}"` : ''; + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while inserting new meter${detail}: ${err.message}`, { cause: err })); + } } } }); diff --git a/src/server/routes/obvius.js b/src/server/routes/obvius.js index 66661c7359..e526c4dea6 100644 --- a/src/server/routes/obvius.js +++ b/src/server/routes/obvius.js @@ -54,7 +54,7 @@ router.use(middleware.paramsLookupMixin); * @param {string} reason The reason for the failure. * */ -function failure(req, res, reason = '') { +function failureObvius(req, res, reason = '') { reason = escapeHtml(reason); // escape html to sanitize html const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress; log.error(`Obvius protocol request from ${ip} failed due to ${reason}`); @@ -71,7 +71,7 @@ function failure(req, res, reason = '') { * @param {string} comment Any additional data to be returned to the client. * */ -function success(req, res, comment = '') { +function successObvius(req, res, comment = '') { comment = escapeHtml(comment); // escape html to sanitize html res.status(HTTP_CODES.OK) // 200 OK .send(`
\nSUCCESS\n${comment}
\n`); @@ -102,7 +102,7 @@ function handleStatus(req, res) { } log.info(s); - success(req, res); + successObvius(req, res); } /** @@ -138,14 +138,14 @@ function verifyObviusUser(req, res, next) { // The test for password and username existence is redone later with JSONSchema but left since error // message is different for historical reasons. if (!password) { - failure(req, res, 'password parameter is required.'); + failureObvius(req, res, 'password parameter is required.'); } else if (!username) { - failure(req, res, 'username parameter is required.'); + failureObvius(req, res, 'username parameter is required.'); } else if (typeof password !== 'string' || password.length > PASSWORD_MAX_LENGTH) { - failure(req, res, 'Invalid password format.'); + failureObvius(req, res, 'Invalid password format.'); // TODO 254 should be checked as accurate and then a global const here and in tests. } else if (typeof username !== 'string' || username.length > 254) { - failure(req, res, 'Invalid username format.'); + failureObvius(req, res, 'Invalid username format.'); } else { // Authenticate Obvius user after all validation passes. // See above for why only have username and not email. @@ -163,7 +163,7 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { const mode = req.param('mode', false); if (mode === false) { - failure(req, res, 'Request must include mode parameter.'); + failureObvius(req, res, 'Request must include mode parameter.'); return; } @@ -175,11 +175,11 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { if (mode === obvius.mode.logfile_upload) { const serialNumber = req.param('serialnumber', false); if (!serialNumber) { - failure(req, res, 'Logfile Upload Requires Serial Number'); + failureObvius(req, res, 'Logfile Upload Requires Serial Number'); return; } if (typeof serialNumber !== 'string' || serialNumber.length > 100) { - failure(req, res, 'Invalid serial number format'); + failureObvius(req, res, 'Invalid serial number format'); return; } const conn = getConnection(); @@ -192,7 +192,7 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { data = zlib.gunzipSync(fx.buffer); } catch (err) { log.error(err); - failure(req, res, `Unable to gunzip incoming buffer: ${err}`); + failureObvius(req, res, `Unable to gunzip incoming buffer: ${err}`); return; } // The original code did not await for the Promise to finish. The new version @@ -202,23 +202,23 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { } // TODO This version returns an error. Should check all usage to be sure it is properly handled. Promise.all(loadLogfilePromises).then(() => { - success(req, res, 'Logfile Upload IS PROVISIONAL'); + successObvius(req, res, 'Logfile Upload IS PROVISIONAL'); }).catch((err) => { log.warn(`Logfile Upload had issues from ip: ${ip}`, err) - failure(req, res, 'Logfile Upload had issues'); + failureObvius(req, res, 'Logfile Upload had issues'); }); // This return may not be needed. return; } if (mode === obvius.mode.config_file_download) { - failure(req, res, 'Config Download Not Implemented'); + failureObvius(req, res, 'Config Download Not Implemented'); return; } if (mode === obvius.mode.config_file_manifest) { const conn = getConnection(); - success(req, res, await listConfigfiles(conn)); + successObvius(req, res, await listConfigfiles(conn)); return; } @@ -228,21 +228,21 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { const modbusDevice = req.param('modbusdevice', false); if (!serialNumber) { - failure(req, res, 'Config Upload Requires Serial Number'); + failureObvius(req, res, 'Config Upload Requires Serial Number'); return; } if (!modbusDevice) { - failure(req, res, 'Config Upload Requires Modbus Device ID'); + failureObvius(req, res, 'Config Upload Requires Modbus Device ID'); return; } // Basic parameter validation if (typeof serialNumber !== 'string' || serialNumber.length > 100) { - failure(req, res, 'Invalid serial number format'); + failureObvius(req, res, 'Invalid serial number format'); return; } if (typeof modbusDevice !== 'string' || modbusDevice.length > 50) { - failure(req, res, 'Invalid modbus device format'); + failureObvius(req, res, 'Invalid modbus device format'); return; } const conn = getConnection(); @@ -258,17 +258,17 @@ router.all('/', obviusLog, verifyObviusUser, async (req, res) => { const cf = new Configfile(undefined, req.param('serialnumber'), req.param('modbusdevice'), moment(), md5(data), data, true); await cf.insert(conn); - success(req, res, `Acquired config log with (pseudo)filename ${cf.makeFilename()}.`); + successObvius(req, res, `Acquired config log with (pseudo)filename ${cf.makeFilename()}.`); } return; } if (mode === obvius.mode.test) { - failure(req, res, 'Test Not Implemented'); + failureObvius(req, res, 'Test Not Implemented'); return; } - failure(req, res, `Unknown mode '${mode}'`); + failureObvius(req, res, `Unknown mode '${mode}'`); }); module.exports = router; diff --git a/src/server/routes/preferences.js b/src/server/routes/preferences.js index 3c6ae829a4..242862fc31 100644 --- a/src/server/routes/preferences.js +++ b/src/server/routes/preferences.js @@ -4,13 +4,13 @@ const express = require('express'); const Preferences = require('../models/Preferences'); -const { log } = require('../log'); const { adminAuthMiddleware, optionalAuthMiddleware } = require('./authenticator'); const validate = require('jsonschema').validate; const { getConnection } = require('../db'); const { STRING_GENERAL_MAX_LENGTH, STRING_SHORT_MAX_LENGTH: SHORT_STRING_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); const { isValidIsoDateTime } = require('../util/timeValidation'); +const { success, failure } = require('./response'); const router = express.Router(); @@ -21,9 +21,9 @@ router.get('/', optionalAuthMiddleware, async (req, res) => { const conn = getConnection(); try { const rows = await Preferences.get(conn); - res.json(rows); + success(res, rows); } catch (err) { - log.error(`Error while performing GET all preferences query: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET all preferences query: ${err.message}`, { cause: err })); } }); @@ -112,7 +112,8 @@ router.post('/', adminAuthMiddleware('edit site preferences'), async (req, res) } }; if (!validate(req.body, validParams).valid) { - return res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); + return; } const prefs = req.body.preferences; @@ -121,16 +122,16 @@ router.post('/', adminAuthMiddleware('edit site preferences'), async (req, res) (prefs.defaultMeterMinimumDate && !isValidIsoDateTime(prefs.defaultMeterMinimumDate)) || (prefs.defaultMeterMaximumDate && !isValidIsoDateTime(prefs.defaultMeterMaximumDate)) ) { - return res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); + return; } const conn = getConnection(); try { const rows = await Preferences.update(prefs, conn); - return res.json(rows); + success(res, rows); } catch (err) { - log.error(`Error while performing POST update preferences: ${err}`, err); - return res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing POST update preferences: ${err.message}`, { cause: err })); } }); diff --git a/src/server/routes/readings.js b/src/server/routes/readings.js index d5f5d78df6..49e7582ade 100644 --- a/src/server/routes/readings.js +++ b/src/server/routes/readings.js @@ -6,7 +6,6 @@ const express = require('express'); const { optionalAuthMiddleware } = require('./authenticator'); const Reading = require('../models/Reading'); const TimeInterval = require('../../common/TimeInterval').TimeInterval; -const { log } = require('../log'); const validate = require('jsonschema').validate; const { getConnection } = require('../db'); const { STRING_GENERAL_MAX_LENGTH: GENERAL_STRING_MAX_LENGTH, NUMERIC_ID_MAX_LENGTH } = require('../util/validationConstants'); @@ -46,7 +45,7 @@ router.get('/line/count/meters/:meter_ids', optionalAuthMiddleware, async (req, } }; if (!validate(req.params, validParams).valid || !validate(req.query, validQueries).valid || !isValidTimeInterval(req.query.timeInterval, true)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { let meterIDs; let timeInterval; @@ -62,8 +61,7 @@ router.get('/line/count/meters/:meter_ids', optionalAuthMiddleware, async (req, // nosemgrep: javascript.express.security.audit.xss.direct-response-write.direct-response-write res.send(JSON.stringify(count)); } catch (err) { - log.error(`Error while performing GET readings COUNT for line with meters ${meterIDs} with time interval ${timeInterval}: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET readings COUNT for line with meters ${meterIDs} with time interval ${timeInterval}: ${err.message}`, { cause: err })); } } }) @@ -138,8 +136,7 @@ router.get('/line/raw/meter/:meter_id', optionalAuthMiddleware, async (req, res) success(res, rawReadings); } } catch (err) { - log.error(`Error while performing GET raw readings for line with meter ${meterID} with time interval ${timeInterval}: ${err}`, err); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET raw readings for line with meter ${meterID} with time interval ${timeInterval}: ${err.message}`, { cause: err })); } } }); diff --git a/src/server/routes/response.js b/src/server/routes/response.js index 698ab043d3..01f1eae723 100644 --- a/src/server/routes/response.js +++ b/src/server/routes/response.js @@ -4,6 +4,7 @@ // Functions to return a code and comment from an Express request. const { HTTP_CODES } = require('../util/httpCodes'); +const { log, LogLevel } = require('../log'); /** * Inform the client of a success (200 OK). @@ -18,19 +19,35 @@ function success(res, comment = '') { } /** - * Inform the client of a failure with provided code or 500. + * Inform the client of a failure with provided code or 500. Logs internally so routes + * do not need to call log.error()/log.warn()/etc. themselves. * * @param res The Express response object * @param code The code number to send back for request - * @param comment Any additional data to be returned to the client as a string + * @param error The Error (or detail) causing the failure. Only ever logged, never sent to the client. + * @param safeMessage A message safe to show the client. Only used for codes under 500; + * 500-level failures always get the generic message below regardless of what is passed here. + * @param {LogLevel} severity The LogLevel (DEBUG/INFO/WARN/ERROR/SILENT β€” see log.js) to log this + * failure at. Defaults to LogLevel.ERROR so logging happens at the most visible level unless a + * route chooses otherwise. Pass LogLevel.SILENT to suppress logging for this failure entirely + * (replaces the old boolean skipLog). The level chosen also determines whether this failure + * triggers an admin e-mail, per Logger's emailLevel threshold in log.js β€” e.g. LogLevel.WARN + * will not e-mail admins by default, while LogLevel.ERROR will. * */ -function failure(res, code = HTTP_CODES.INTERNAL_SERVER_ERROR, comment = '') { - // Return a generic message for 500-level failures. - const responseBody = code >= HTTP_CODES.INTERNAL_SERVER_ERROR ? 'Internal Server Error. Details are in the OED logs that are available to your site admin(s).' : comment; +function failure(res, code = HTTP_CODES.INTERNAL_SERVER_ERROR, error = null, safeMessage = '', severity = LogLevel.ERROR) { + if (severity !== LogLevel.SILENT && error) { + const logMessage = error instanceof Error ? error.message : String(error); + log.log(severity, logMessage, error instanceof Error ? error : undefined); + } + // Return a generic message for 500-level failures so internal details never reach the client. + const responseBody = code >= HTTP_CODES.INTERNAL_SERVER_ERROR + ? 'Internal Server Error. Details are in the OED logs that are available to your site admin(s).' + : safeMessage; res.status(code) - .send(responseBody); - + .send(responseBody); } -module.exports = { success, failure }; +// Re-export LogLevel so routes only need to import from here, not from log.js directly, +// to pass a severity (e.g. LogLevel.WARN, LogLevel.SILENT) into failure(). +module.exports = { success, failure, LogLevel }; diff --git a/src/server/routes/unitReadings.js b/src/server/routes/unitReadings.js index f40fbf9b87..44276e7f77 100644 --- a/src/server/routes/unitReadings.js +++ b/src/server/routes/unitReadings.js @@ -15,6 +15,7 @@ const moment = require('moment'); const { STRING_GENERAL_MAX_LENGTH, NUMERIC_ID_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); const { isValidTimeInterval } = require('../util/timeValidation'); +const { success, failure } = require('./response'); function validateMeterLineReadingsParams(params) { const validParams = { @@ -406,120 +407,148 @@ function createRouter() { // Route for fetching line readings by meter IDs router.get('/line/meters/:meter_ids', optionalAuthMiddleware, async (req, res) => { if (!(validateMeterLineReadingsParams(req.params) && validateLineReadingsQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval, true)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); - const graphicUnitID = req.query.graphicUnitId; - const timeInterval = TimeInterval.fromString(req.query.timeInterval); - const forJson = await meterLineReadings(meterIDs, graphicUnitID, timeInterval); - res.json(forJson); + try { + const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); + const graphicUnitID = req.query.graphicUnitId; + const timeInterval = TimeInterval.fromString(req.query.timeInterval); + const forJson = await meterLineReadings(meterIDs, graphicUnitID, timeInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET line readings for meters: ${err.message}`, { cause: err })); + } } }); // Route for fetching line readings by group IDs router.get('/line/groups/:group_ids', optionalAuthMiddleware, async (req, res) => { if (!(validateGroupLineReadingsParams(req.params) && validateLineReadingsQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval, true)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const groupIDs = req.params.group_ids.split(',').map(idStr => Number(idStr)); - const graphicUnitID = req.query.graphicUnitId; - const timeInterval = TimeInterval.fromString(req.query.timeInterval); - const forJson = await groupLineReadings(groupIDs, graphicUnitID, timeInterval); - res.json(forJson); + try { + const groupIDs = req.params.group_ids.split(',').map(idStr => Number(idStr)); + const graphicUnitID = req.query.graphicUnitId; + const timeInterval = TimeInterval.fromString(req.query.timeInterval); + const forJson = await groupLineReadings(groupIDs, graphicUnitID, timeInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET line readings for groups: ${err.message}`, { cause: err })); + } } }); // Route for fetching bar readings by meter IDs router.get('/bar/meters/:meter_ids', optionalAuthMiddleware, async (req, res) => { if (!(validateMeterBarReadingsParams(req.params) && validateBarReadingsQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval, true)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); - const timeInterval = TimeInterval.fromString(req.query.timeInterval); - const barWidthDays = Number(req.query.barWidthDays); - const graphicUnitID = req.query.graphicUnitId; - const forJson = await meterBarReadings(meterIDs, graphicUnitID, barWidthDays, timeInterval); - res.json(forJson); + try { + const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); + const timeInterval = TimeInterval.fromString(req.query.timeInterval); + const barWidthDays = Number(req.query.barWidthDays); + const graphicUnitID = req.query.graphicUnitId; + const forJson = await meterBarReadings(meterIDs, graphicUnitID, barWidthDays, timeInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET bar readings for meters: ${err.message}`, { cause: err })); + } } }); // Route for fetching bar readings by group IDs router.get('/bar/groups/:group_ids', optionalAuthMiddleware, async (req, res) => { if (!(validateGroupBarReadingsParams(req.params) && validateBarReadingsQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval, true)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const groupIDs = req.params.group_ids.split(',').map(idStr => Number(idStr)); - const timeInterval = TimeInterval.fromString(req.query.timeInterval); - const barWidthDays = Number(req.query.barWidthDays); - const graphicUnitID = req.query.graphicUnitId; - const forJson = await groupBarReadings(groupIDs, graphicUnitID, barWidthDays, timeInterval); - res.json(forJson); + try { + const groupIDs = req.params.group_ids.split(',').map(idStr => Number(idStr)); + const timeInterval = TimeInterval.fromString(req.query.timeInterval); + const barWidthDays = Number(req.query.barWidthDays); + const graphicUnitID = req.query.graphicUnitId; + const forJson = await groupBarReadings(groupIDs, graphicUnitID, barWidthDays, timeInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET bar readings for groups: ${err.message}`, { cause: err })); + } } }); // Route for fetching radar readings by meter IDs router.get('/radar/meters/:meter_ids', optionalAuthMiddleware, async (req, res) => { if (!(validateMeterRadarReadingsParams(req.params) && validateRadarReadingsQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval, true)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); - const graphicUnitID = req.query.graphicUnitId; - const timeInterval = TimeInterval.fromString(req.query.timeInterval); - const forJson = await meterRadarReadings(meterIDs, graphicUnitID, timeInterval); - res.json(forJson); + try { + const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); + const graphicUnitID = req.query.graphicUnitId; + const timeInterval = TimeInterval.fromString(req.query.timeInterval); + const forJson = await meterRadarReadings(meterIDs, graphicUnitID, timeInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET radar readings for meters: ${err.message}`, { cause: err })); + } } }); // Route for fetching radar readings by group IDs router.get('/radar/groups/:group_ids', optionalAuthMiddleware, async (req, res) => { if (!(validateGroupRadarReadingsParams(req.params) && validateRadarReadingsQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval, true)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const groupIDs = req.params.group_ids.split(',').map(idStr => Number(idStr)); - const graphicUnitID = req.query.graphicUnitId; - const timeInterval = TimeInterval.fromString(req.query.timeInterval); - const forJson = await groupRadarReadings(groupIDs, graphicUnitID, timeInterval); - res.json(forJson); + try { + const groupIDs = req.params.group_ids.split(',').map(idStr => Number(idStr)); + const graphicUnitID = req.query.graphicUnitId; + const timeInterval = TimeInterval.fromString(req.query.timeInterval); + const forJson = await groupRadarReadings(groupIDs, graphicUnitID, timeInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET radar readings for groups: ${err.message}`, { cause: err })); + } } }); // Route for fetching 3D readings by meter IDs router.get('/threeD/meters/:meter_ids', optionalAuthMiddleware, async (req, res) => { if (!(validateMeterThreeDReadingsParams(req.params) && validateThreeDQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { // Get time range to validate 1 year or less. const timeInterval = TimeInterval.fromString(req.query.timeInterval); if (!timeInterval.getIsBounded()) { // Cannot do if not bounded. - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { const duration = moment.duration(timeInterval.endTimestamp.diff(timeInterval.startTimestamp)); // Gets 0 unless one day beyond a year but that okay since don't do partial days. const durationInYears = duration.years(); if (durationInYears >= 1) { // Limit 3D to one year of data. - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); - const graphicUnitID = req.query.graphicUnitId; - const readingInterval = req.query.readingInterval; - const forJson = await meterThreeDReadings(meterIDs, graphicUnitID, timeInterval, readingInterval); - res.json(forJson); + try { + const meterIDs = req.params.meter_ids.split(',').map(idStr => Number(idStr)); + const graphicUnitID = req.query.graphicUnitId; + const readingInterval = req.query.readingInterval; + const forJson = await meterThreeDReadings(meterIDs, graphicUnitID, timeInterval, readingInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET threeD readings for meters: ${err.message}`, { cause: err })); + } } } } @@ -528,28 +557,32 @@ function createRouter() { // Route for fetching 3D readings by group ID router.get('/threeD/groups/:group_id', optionalAuthMiddleware, async (req, res) => { if (!(validateGroupThreeDReadingsParams(req.params) && validateThreeDQueryParams(req.query))) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else if (!isValidTimeInterval(req.query.timeInterval)) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { // Get time range to validate 1 year or less. const timeInterval = TimeInterval.fromString(req.query.timeInterval); if (!timeInterval.getIsBounded()) { // Cannot do if not bounded. - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { const duration = moment.duration(timeInterval.endTimestamp.diff(timeInterval.startTimestamp)); // Gets 0 unless one day beyond a year but that okay since don't do partial days. const durationInYears = duration.years(); if (durationInYears >= 1) { // Limit 3D to one year of data. - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { - const groupID = req.params.group_id; - const graphicUnitID = req.query.graphicUnitId; - const readingInterval = req.query.readingInterval; - const forJson = await groupThreeDReadings(groupID, graphicUnitID, timeInterval, readingInterval); - res.json(forJson); + try { + const groupID = req.params.group_id; + const graphicUnitID = req.query.graphicUnitId; + const readingInterval = req.query.readingInterval; + const forJson = await groupThreeDReadings(groupID, graphicUnitID, timeInterval, readingInterval); + success(res, forJson); + } catch (err) { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET threeD readings for group: ${err.message}`, { cause: err })); + } } } } diff --git a/src/server/routes/units.js b/src/server/routes/units.js index 359517bb06..3b94c44374 100644 --- a/src/server/routes/units.js +++ b/src/server/routes/units.js @@ -4,12 +4,11 @@ const express = require('express'); const { adminAuthMiddleware, optionalAuthMiddleware } = require('./authenticator'); -const { log } = require('../log'); const { getConnection } = require('../db'); const Unit = require('../models/Unit'); const { removeAdditionalConversionsAndUnits } = require('../services/graph/handleSuffixUnits'); const validate = require('jsonschema').validate; -const { success, failure } = require('./response'); +const { success, failure, LogLevel } = require('./response'); const { HTTP_CODES } = require('../util/httpCodes'); const { STRING_GENERAL_MAX_LENGTH, STRING_SHORT_MAX_LENGTH } = require('../util/validationConstants'); const router = express.Router(); @@ -124,8 +123,7 @@ router.get('/', optionalAuthMiddleware, async (req, res) => { const rows = await Unit.getAll(conn); res.json(rows.map(formatUnitForResponse)); } catch (err) { - log.error(`Error fetching units: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error fetching units: ${err.message}`, { cause: err })); } }); @@ -137,8 +135,8 @@ router.post('/edit', adminAuthMiddleware('edit units'), async (req, res) => { const validatorResult = validateUnitsParams(req.body, true); if (!validatorResult.valid) { - log.warn(`Got request to edit units with invalid unit data, errors: ${validatorResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, `Got request to edit units with invalid unit data, errors: ${validatorResult.errors}`); + const message = `Got request to edit units with invalid unit data, errors: ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, message, LogLevel.WARN); } else { const conn = getConnection(); try { @@ -157,8 +155,13 @@ router.post('/edit', adminAuthMiddleware('edit units'), async (req, res) => { await unit.update(conn); success(res, 'Successfully edited unit'); } catch (err) { - log.error(`Failed to update unit: ${err}`, err); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, 'Unable to update unit'); + if (err.toString().includes('duplicate key value violates unique constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Unit name "${req.body.name}" already exists`, LogLevel.SILENT); + } else if (err.toString().includes('violates check constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Invalid unit data: ${err.toString()}`, LogLevel.SILENT); + } else { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Failed to update unit: ${err.message}`, { cause: err })); + } } } }); @@ -171,8 +174,8 @@ router.post('/addUnit', adminAuthMiddleware('add units'), async (req, res) => { const validationResult = validateUnitsParams(req.body, false); if (!validationResult.valid) { - log.error(`Got request to edit units with invalid unit data, errors: ${validationResult.errors}`); - failure(res, HTTP_CODES.BAD_REQUEST, `Got request to add units with invalid unit data, errors: ${validationResult.errors}`); + const message = `Got request to add units with invalid unit data, errors: ${validationResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, message); } else { const conn = getConnection(); try { @@ -196,8 +199,13 @@ router.post('/addUnit', adminAuthMiddleware('add units'), async (req, res) => { }); success(res, 'Unit created successfully'); } catch (err) { - log.error(`Error while inserting new unit: ${err}`, err); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, `Error while inserting new unit: ${err}`); + if (err.toString().includes('duplicate key value violates unique constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Unit name "${req.body.name}" already exists`, LogLevel.SILENT); + } else if (err.toString().includes('violates check constraint')) { + failure(res, HTTP_CODES.BAD_REQUEST, err, `Invalid unit data: ${err.toString()}`, LogLevel.SILENT); + } else { + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while inserting new unit: ${err.message}`, { cause: err })); + } } } }); @@ -216,9 +224,8 @@ router.post('/delete', adminAuthMiddleware('delete units'), async (req, res) => // Ensure delete request is valid const validatorResult = validate(req.body, validParams); if (!validatorResult.valid) { - const errorMsg = `Got request to delete a unit with invalid data, error(s): ${validatorResult.errors}`; - log.warn(errorMsg); - failure(res, HTTP_CODES.BAD_REQUEST, errorMsg); + const message = `Got request to delete a unit with invalid data, error(s): ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, message, LogLevel.WARN); } else { const conn = getConnection(); const unitId = req.body.id; @@ -228,9 +235,7 @@ router.post('/delete', adminAuthMiddleware('delete units'), async (req, res) => await Unit.delete(unitId, conn); success(res, 'Successfully deleted unit'); } catch (err) { - const errorMsg = `Error while deleting unit with error(s): ${err}`; - log.error(errorMsg); - failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, errorMsg); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while deleting unit with error(s): ${err.message}`, { cause: err })); } } }); diff --git a/src/server/routes/users.js b/src/server/routes/users.js index 602bef38ec..3a31b60877 100644 --- a/src/server/routes/users.js +++ b/src/server/routes/users.js @@ -6,13 +6,13 @@ const bcrypt = require('bcryptjs'); const { adminAuthMiddleware, optionalAuthMiddleware } = require('./authenticator'); const express = require('express'); const User = require('../models/User'); -const { log } = require('../log'); const validate = require('jsonschema').validate; const { getConnection } = require('../db'); const jwt = require('jsonwebtoken'); const secretToken = require('../config').secretToken; const { STRING_GENERAL_MAX_LENGTH, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, TOKEN_MAX_LENGTH, USERNAME_MIN_LENGTH, USERNAME_MAX_LENGTH, NUMERIC_ID_MAX_LENGTH } = require('../util/validationConstants'); const { HTTP_CODES } = require('../util/httpCodes'); +const { success, failure, LogLevel } = require('./response'); const router = express.Router(); @@ -89,7 +89,7 @@ router.get('/', adminAuthMiddleware('get all users'), async (req, res) => { const rows = await User.getAll(conn); res.json(rows); } catch (err) { - log.error(`Error while performing GET all users query: ${err}`, err); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET all users query: ${err.message}`, { cause: err })); } }); @@ -101,11 +101,11 @@ router.get('/token', optionalAuthMiddleware, async (req, res) => { maxLength: TOKEN_MAX_LENGTH }; if (!validate(token, validParams).valid) { - res.status(HTTP_CODES.FORBIDDEN).json({ message: 'No token provided or JSON was invalid.' }); + failure(res, HTTP_CODES.FORBIDDEN, null, { message: 'No token provided or JSON was invalid.' }); } else if (token) { jwt.verify(token, secretToken, async (err, decoded) => { if (err) { - res.status(HTTP_CODES.UNAUTHORIZED).json({ message: 'Failed to authenticate token.' }); + failure(res, HTTP_CODES.UNAUTHORIZED, err, { message: 'Failed to authenticate token.' }, LogLevel.SILENT); } else { try { const conn = getConnection(); @@ -116,12 +116,12 @@ router.get('/token', optionalAuthMiddleware, async (req, res) => { role: userProfile.role }); } catch (error) { - res.status(HTTP_CODES.UNAUTHORIZED).json({ message: 'User does not exist in database.' }); + failure(res, HTTP_CODES.UNAUTHORIZED, error, { message: 'User does not exist in database.' }, LogLevel.SILENT); } } }); } else { - res.status(HTTP_CODES.FORBIDDEN).send({ message: 'No token provided.' }); + failure(res, HTTP_CODES.FORBIDDEN, null, { message: 'No token provided.' }); } }); @@ -140,15 +140,14 @@ router.get('/:user_id', adminAuthMiddleware('get one user'), async (req, res) => } }; if (!validate(req.params, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { const conn = getConnection(); try { const rows = await User.getByID(req.params.user_id, conn); res.json(rows); } catch (err) { - log.error(`Error while performing GET specific user by id query: ${err}`, err); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing GET specific user by id query: ${err.message}`, { cause: err })); } } }); @@ -156,8 +155,11 @@ router.get('/:user_id', adminAuthMiddleware('get one user'), async (req, res) => // Route for creating a new user. router.post('/create', adminAuthMiddleware('create a user.'), async (req, res) => { // isEdit=false: id must not be present, since it's assigned by the DB on insert, and password is required. - if (!validateUsersParams(req.body, false).valid) { - res.status(HTTP_CODES.BAD_REQUEST).json({ message: 'Invalid params' }); + const validatorResult = validateUsersParams(req.body, false); + + if (!validatorResult.valid) { + const message = `Got request to insert user with invalid user data. Error(s): ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, { message }); } else { try { const { username, password, role, note } = req.body; @@ -165,17 +167,15 @@ router.post('/create', adminAuthMiddleware('create a user.'), async (req, res) = // Check if user already exists const currentUser = await User.getByUsername(username, conn); if (currentUser !== null) { - res.status(HTTP_CODES.BAD_REQUEST).send({ message: `user ${username} already exists so cannot create` }); + failure(res, HTTP_CODES.BAD_REQUEST, null, { message: `user ${username} already exists so cannot create` }); } else { const hashedPassword = await bcrypt.hash(password, 10); const user = new User(undefined, username, hashedPassword, role, note); await user.insert(conn); - res.sendStatus(HTTP_CODES.OK); + success(res); } } catch (error) { - // Log the error internally and return a generic response - log.error(`Error while performing POST request to create user: ${error}`, error); - res.status(HTTP_CODES.INTERNAL_SERVER_ERROR).send({ message: 'Internal Server Error' }); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing POST request to create user: ${error.message}`, { cause: error })); } } }); @@ -184,8 +184,11 @@ router.post('/create', adminAuthMiddleware('create a user.'), async (req, res) = router.post('/edit', adminAuthMiddleware('edit a user'), async (req, res) => { // isEdit=true: id is required and password remains optional, since a user is only sent a new // password if it should change. The wrapper ('user' key) is validated inside the helper too. - if (!validateUsersParams(req.body, true).valid) { - res.status(HTTP_CODES.BAD_REQUEST).json({ message: 'Invalid params' }); + const validatorResult = validateUsersParams(req.body, true); + if (!validatorResult.valid) { + const message = `Got request to edit users with invalid user data, errors: ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, { message }, LogLevel.WARN); + return; } else { try { const conn = getConnection(); @@ -197,10 +200,8 @@ router.post('/edit', adminAuthMiddleware('edit a user'), async (req, res) => { const numberOfAdmins = await User.getNumberOfAdmins(conn); if (numberOfAdmins < 2) { const errorMessage = 'There must be at least one admin remaining to avoid lockout!'; - log.error(errorMessage); - return res.status(HTTP_CODES.BAD_REQUEST).json({ - message: errorMessage, - }); + failure(res, HTTP_CODES.BAD_REQUEST, errorMessage, { message: errorMessage }); + return; } } @@ -221,12 +222,10 @@ router.post('/edit', adminAuthMiddleware('edit a user'), async (req, res) => { } await Promise.all(userUpdates); - return res.sendStatus(HTTP_CODES.OK); + success(res); } catch (error) { - // Log internally and send a generic error response. - log.error('Error while performing edit user request.', error); - res.status(HTTP_CODES.INTERNAL_SERVER_ERROR).json({ message: 'Internal Server Error' }); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing edit user request: ${error.message}`, { cause: error })); } } }); @@ -245,8 +244,10 @@ router.post('/delete', adminAuthMiddleware('delete a user'), async (req, res) => } } }; - if (!validate(req.body, validParams).valid) { - res.status(HTTP_CODES.BAD_REQUEST).json({ message: 'Invalid params' }); + const validatorResult = validate(req.body, validParams); + if (!validatorResult.valid) { + const message = `Got request to delete users with invalid user data. Error(s): ${validatorResult.errors}`; + failure(res, HTTP_CODES.BAD_REQUEST, message, { message }); } else { try { const conn = getConnection(); @@ -254,14 +255,13 @@ router.post('/delete', adminAuthMiddleware('delete a user'), async (req, res) => const id = req.decoded.data; const user = await User.getByID(id, conn); if (user.username === username) {// Admins cannot delete themselves - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { await User.deleteUser(username, conn); - res.sendStatus(HTTP_CODES.OK); + success(res); } } catch (error) { - log.error('Error while performing delete user request', error); - res.sendStatus(HTTP_CODES.INTERNAL_SERVER_ERROR); + failure(res, HTTP_CODES.INTERNAL_SERVER_ERROR, new Error(`Error while performing delete user request: ${error.message}`, { cause: error })); } } }); diff --git a/src/server/routes/verification.js b/src/server/routes/verification.js index 572fa51f31..0eddd01c75 100644 --- a/src/server/routes/verification.js +++ b/src/server/routes/verification.js @@ -5,9 +5,9 @@ const express = require('express'); const validate = require('jsonschema').validate; const { TOKEN_MAX_LENGTH } = require('../util/validationConstants'); -const { log } = require('../log'); const { verifyActiveTokenAndGetUser } = require('./authenticator'); const { HTTP_CODES } = require('../util/httpCodes'); +const { success, failure } = require('./response'); const router = express.Router(); @@ -32,17 +32,15 @@ router.post('/', (req, res) => { }; if (!validate(req.body, validParams).valid) { - res.sendStatus(HTTP_CODES.BAD_REQUEST); + failure(res, HTTP_CODES.BAD_REQUEST); } else { const token = req.body.token; - verifyActiveTokenAndGetUser(token) .then(() => { - res.status(HTTP_CODES.OK).json({ success: true }); + success(res, { success: true }); }) .catch(error => { - log.error('Token verification failed.', error); - res.status(HTTP_CODES.UNAUTHORIZED).json({ success: false, message: 'Failed to authenticate token.' }); + failure(res, HTTP_CODES.UNAUTHORIZED, new Error(`Token verification failed: ${error.message}`, { cause: error }), { success: false, message: 'Failed to authenticate token.' }); }); } }); diff --git a/src/server/test/routes/responseParamsTest.js b/src/server/test/routes/responseParamsTest.js index c403edbd0a..e3844ce2f4 100644 --- a/src/server/test/routes/responseParamsTest.js +++ b/src/server/test/routes/responseParamsTest.js @@ -123,7 +123,7 @@ mocha.describe('Response Utility Functions', () => { const mockRes = createMockResponse(); const comment = 'Operation failed'; - failure(mockRes, HTTP_CODES.BAD_REQUEST, comment); + failure(mockRes, HTTP_CODES.BAD_REQUEST, null, comment); expect(mockRes.statusCode).to.equal(HTTP_CODES.BAD_REQUEST); expect(mockRes.sentData).to.equal(comment); @@ -135,7 +135,7 @@ mocha.describe('Response Utility Functions', () => { errorCodes.forEach(code => { const freshMockRes = createMockResponse(); - failure(freshMockRes, code, `Error ${code}`); + failure(freshMockRes, code, null, `Error ${code}`); expect(freshMockRes.statusCode).to.equal(code); const expectedData = code >= 500 ? internalErrorMsg : `Error ${code}`; @@ -251,7 +251,7 @@ mocha.describe('Response Utility Functions', () => { const mockRes = createMockResponse(); const unicodeComment = 'πŸš€πŸ‘¨β€πŸ’»πŸ”πŸ’ΎπŸ“±'; - failure(mockRes, HTTP_CODES.BAD_REQUEST, unicodeComment); + failure(mockRes, HTTP_CODES.BAD_REQUEST, null, unicodeComment); expect(mockRes.statusCode).to.equal(HTTP_CODES.BAD_REQUEST); expect(mockRes.sentData).to.equal(unicodeComment);