From 154734d2935db3510ec9f39d011398920b3b9cba Mon Sep 17 00:00:00 2001 From: Tedd Mason Date: Wed, 1 Oct 2025 14:03:38 +0100 Subject: [PATCH] NI-117 - Incorporating xmldom and update message class for processMessage Sonarcloud stuff sonarcloud removing file spelling fix --- docker/.env | 2 +- lib/functions/processMessage.js | 124 +++++++++-------- lib/models/message.js | 113 ++++++++++++---- package-lock.json | 32 +++++ package.json | 2 + .../lib/functions/processMessageValidation.js | 64 ++++----- test/lib/models/message.js | 128 ++++++++++++++++++ 7 files changed, 341 insertions(+), 124 deletions(-) create mode 100644 test/lib/models/message.js diff --git a/docker/.env b/docker/.env index f3f7902..d3fe79e 100644 --- a/docker/.env +++ b/docker/.env @@ -14,7 +14,7 @@ AWS_REGION=eu-west-2 AWS_DEFAULT_REGION=eu-west-2 # LocalStack community edition is sufficient IMAGE_NAME=localstack/localstack:latest -LAMBDA_TIMEOUT=120 +LAMBDA_TIMEOUT=9999999 # Comment the line below and create a new containerised development environment to disable debugging of Lambda functions. # IMPORTANT - If cloning the remote repository into a container volume, the change must be pushed to a pull request # branch from which a new containerised development environment is created. If a new containerised development environment diff --git a/lib/functions/processMessage.js b/lib/functions/processMessage.js index 2ea736f..36f305a 100644 --- a/lib/functions/processMessage.js +++ b/lib/functions/processMessage.js @@ -3,77 +3,66 @@ const xml2js = require('xml2js') const moment = require('moment') const service = require('../helpers/service') -const Message = require('../models/message') const eventSchema = require('../schemas/processMessageEventSchema') const aws = require('../helpers/aws') const { validateXML } = require('xmllint-wasm') -const fs = require('fs') -const path = require('path') +const fs = require('node:fs') +const path = require('node:path') const xsdSchema = fs.readFileSync(path.join(__dirname, '..', 'schemas', 'CAP-v1.2.xsd'), 'utf8') const additionalCapMessageSchema = require('../schemas/additionalCapMessageSchema') +const Message = require('../models/message') module.exports.processMessage = async (event) => { try { + // validate the event await eventSchema.validateAsync(event) - // Add in the references field - const xmlMessage = event.bodyXml.replace('', '\n') - - const xmlResult = await new Promise((resolve, reject) => { - xml2js.parseString(xmlMessage, (err, value) => { - if (err) return reject(err) - resolve(value) - }) - }) - - const results = await Promise.allSettled([ - (async () => { - const validationResult = await validate(event.bodyXml, xsdSchema) - if (validationResult.errors?.length > 0) { - throw validationResult.errors - } - })(), - (async () => { - const joiValidation = additionalCapMessageSchema.validate(xmlResult, { abortEarly: false }) - if (joiValidation.error) { - throw joiValidation.error.details ?? [joiValidation.error] - } - })() - ]) - - const errors = results.filter(r => r.status === 'rejected').flatMap(r => r.reason) - - if (errors.length > 0) { - throw new Error(JSON.stringify(errors)) - } - - const dbResult = await service.getLastMessage(xmlResult.alert.info[0].area[0].geocode[0].value[0]) + // parse the xml + const message = new Message(event.bodyXml) + console.log(`Processing CAP message: ' + ${message.identifier} for ${message.fwisCode}`) + // get Last message + const dbResult = await service.getLastMessage(message.fwisCode) const lastMessage = (!!dbResult && dbResult.rows.length > 0) ? dbResult.rows[0] : undefined // If not production set status to test if (process.env.stage !== 'prd') { - xmlResult.alert.status[0] = 'Test' + message.status = 'Test' } - updateReferences(lastMessage, xmlResult) + // Add in the references field and update msgtype to Update if references exist and is Alert + const references = getReferences(lastMessage, message.sender) + if (references) { + message.references = references + } - const message = new Message(xmlResult) + // do validation + const results = await Promise.allSettled([ + // Validate xml against CAP XSD schema https://eaflood.atlassian.net/browse/NI-95 + validateAgainstXsdSchema(message), + // Convert xml to js object for joi extended validation https://eaflood.atlassian.net/browse/NI-113 + validateAgainstJoiSchema(message) + ]) - console.log('Processing CAP message: ' + message.data.identifier + ' for ' + message.data.fwis_code) + // Check for validation failures and throw + const errors = results.filter(r => r.status === 'rejected').flatMap(r => r.reason) + if (errors.length > 0) { + throw new Error(JSON.stringify(errors)) + } - await service.putMessage(message.putQuery) + // store the message in database + await service.putMessage(message.putQuery()) + console.log(`Finished processing CAP message: ${message.identifier} for ${message.fwisCode}`) - console.log('Finished processing CAP message: ' + message.data.identifier + ' for ' + message.data.fwis_code) return { statusCode: 200, body: { - message: 'Cap message successfully stored for ' + message.data.fwis_code, - identifier: message.data.identifier, - fwisCode: message.data.fwis_code, - sent: message.data.sent, - expires: message.data.expires, - status: xmlResult.alert.status[0] + message: `Cap message successfully stored for ${message.fwisCode}`, + identifier: message.identifier, + fwisCode: message.fwisCode, + sent: message.sent, + expires: message.expires, + status: message.status } } } catch (err) { @@ -84,7 +73,7 @@ module.exports.processMessage = async (event) => { } } -async function processFailedMessage (originalError, xmlResult) { +const processFailedMessage = async (originalError, xmlResult) => { // For backwards compapibility, only send a notification if an AWS SNS topic // is configured. if (process.env.CPX_SNS_TOPIC) { @@ -109,22 +98,39 @@ async function processFailedMessage (originalError, xmlResult) { } } -const validate = (message, schema) => { - return validateXML({ +const getReferences = (lastMessage, sender) => { + if (lastMessage && lastMessage.expires > new Date()) { + const newReference = `${sender},${lastMessage.identifier},${moment(lastMessage.sent).utc().format('YYYY-MM-DDTHH:mm:ssZ')}` + return lastMessage.references ? `${lastMessage.references} ${newReference}` : newReference + } else { + return '' + } +} + +const validateAgainstXsdSchema = async (message) => { + const validationResult = await validateXML({ xml: [{ fileName: 'message.xml', - contents: message + contents: message.toString() }], - schema: [schema] + schema: [xsdSchema] }) + + if (validationResult.errors?.length > 0) { + throw validationResult.errors + } } -const updateReferences = (lastMessage, xmlResult) => { - if (lastMessage && lastMessage.expires > new Date()) { - const newReference = `${xmlResult.alert.sender[0]},${lastMessage.identifier},${moment(lastMessage.sent).utc().format('YYYY-MM-DDTHH:mm:ssZ')}` - xmlResult.alert.references = [lastMessage.references ? `${lastMessage.references} ${newReference}` : newReference] - xmlResult.alert.msgType[0] = xmlResult.alert.msgType[0] === 'Alert' ? 'Update' : xmlResult.alert.msgType[0] - } else { - delete xmlResult.alert.references +const validateAgainstJoiSchema = async (message) => { + const jsMessage = await new Promise((resolve, reject) => { + xml2js.parseString(message.toString(), (err, value) => { + if (err) return reject(err) + return resolve(value) + }) + }) + + const joiValidation = additionalCapMessageSchema.validate(jsMessage, { abortEarly: false }) + if (joiValidation.error) { + throw joiValidation.error.details ?? [joiValidation.error] } } diff --git a/lib/models/message.js b/lib/models/message.js index b1646a6..2370b44 100644 --- a/lib/models/message.js +++ b/lib/models/message.js @@ -1,37 +1,98 @@ -'use strict' - -const xml2js = require('xml2js') +const xmldom = require('@xmldom/xmldom') +const xmlFormat = require('xml-formatter') const { Sql } = require('sql-ts') const sql = new Sql('postgres') const messages = sql.define({ name: 'messages', columns: ['identifier', 'msg_type', 'references', 'alert', 'fwis_code', 'expires', 'sent', 'created'] }) -const xmlBuilder = new xml2js.Builder({ - headless: true, - cdata: true -}) -function Message (xmlMessage) { - const message = { - identifier: xmlMessage.alert.identifier[0], - msg_type: xmlMessage.alert.msgType[0], - references: xmlMessage.alert.references ? xmlMessage.alert.references[0] : '', - alert: xmlBuilder.buildObject(xmlMessage).replace(/ /g, ''), - fwis_code: xmlMessage.alert.info[0].area[0].geocode[0].value[0], - expires: xmlMessage.alert.info[0].expires[0], - sent: xmlMessage.alert.sent[0], - created: new Date().toISOString() - } - - Object.defineProperties(this, { - data: { - value: message - }, - putQuery: { - value: messages.insert(message).toQuery() +class Message { + constructor (xmlString) { + this.doc = new xmldom.DOMParser().parseFromString(xmlString, 'text/xml') + } + + get fwisCode () { + return this.getFirstElement('geocode').getElementsByTagName('value')[0].textContent + } + + get identifier () { + return this.getFirstElement('identifier').textContent + } + + get sender () { + return this.getFirstElement('sender').textContent + } + + get msgType () { + return this.getFirstElement('msgType').textContent + } + + set msgType (value) { + this.getFirstElement('msgType').textContent = value + } + + get references () { + return this.getFirstElement('references') ? this.getFirstElement('references').textContent : '' + } + + set references (value) { + if (value) { + if (this.references) { + this.getFirstElement('references').textContent = value + } else { + this.addElement('scope', 'references', value) + } + if (this.msgType === 'Alert') { + this.msgType = 'Update' + } + } + } + + get status () { + return this.getFirstElement('status').textContent + } + + set status (value) { + this.getFirstElement('status').textContent = value + } + + get expires () { + return this.getFirstElement('expires').textContent + } + + get sent () { + return this.getFirstElement('sent').textContent + } + + getFirstElement (tagName) { + return this.doc.getElementsByTagName(tagName)[0] + } + + addElement (parentTag, elTag, elValue) { + const parentEl = this.doc.getElementsByTagName(parentTag)[0] + const newEl = this.doc.createElement(elTag) + newEl.textContent = elValue + return parentEl.parentNode.insertBefore(newEl, parentEl.nextSibling) + } + + toString () { + return xmlFormat(new xmldom.XMLSerializer().serializeToString(this.doc), { indentation: ' ', collapseContent: true }) + } + + putQuery () { + const message = { + identifier: this.identifier, + msg_type: this.msgType, + references: this.references, + alert: this.toString(), + fwis_code: this.fwisCode, + expires: this.expires, + sent: this.sent, + created: new Date().toISOString() } - }) + return messages.insert(message).toQuery() + } } module.exports = Message diff --git a/package-lock.json b/package-lock.json index 289af99..9650667 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,13 @@ "license": "OGL", "dependencies": { "@aws-sdk/client-sns": "^3.873.0", + "@xmldom/xmldom": "^0.8.11", "feed": "5.1.0", "joi": "^18.0.1", "moment": "^2.30.1", "pg": "8.16.3", "sql-ts": "7.1.0", + "xml-formatter": "^3.6.7", "xml2js": "0.6.2", "xmllint-wasm": "^5.0.0" }, @@ -2310,6 +2312,15 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -6593,6 +6604,18 @@ "node": ">=8" } }, + "node_modules/xml-formatter": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.6.7.tgz", + "integrity": "sha512-IsfFYJQuoDqtUlKhm4EzeoBOb+fQwzQVeyxxAQ0sThn/nFnQmyLPTplqq4yRhaOENH/tAyujD2TBfIYzUKB6hg==", + "license": "MIT", + "dependencies": { + "xml-parser-xo": "^4.1.5" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/xml-js": { "version": "1.6.11", "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", @@ -6609,6 +6632,15 @@ "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" }, + "node_modules/xml-parser-xo": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/xml-parser-xo/-/xml-parser-xo-4.1.5.tgz", + "integrity": "sha512-TxyRxk9sTOUg3glxSIY6f0nfuqRll2OEF8TspLgh5mZkLuBgheCn3zClcDSGJ58TvNmiwyCCuat4UajPud/5Og==", + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index 2424439..c9e3bef 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,13 @@ "license": "OGL", "dependencies": { "@aws-sdk/client-sns": "^3.873.0", + "@xmldom/xmldom": "^0.8.11", "feed": "5.1.0", "joi": "^18.0.1", "moment": "^2.30.1", "pg": "8.16.3", "sql-ts": "7.1.0", + "xml-formatter": "^3.6.7", "xml2js": "0.6.2", "xmllint-wasm": "^5.0.0" }, diff --git a/test/lib/functions/processMessageValidation.js b/test/lib/functions/processMessageValidation.js index 637a8eb..806b933 100644 --- a/test/lib/functions/processMessageValidation.js +++ b/test/lib/functions/processMessageValidation.js @@ -11,16 +11,7 @@ const fakeService = { getLastMessage: async () => ({ rows: [] }), putMessage: async () => {} } -const FakeMessage = function () { - this.data = { - alert: 'test', - identifier: 'id123', - fwis_code: 'FWC', - sent: '2020-01-01T00:00:00Z', - expires: '2020-01-02T00:00:00Z' - } - this.putQuery = {} -} + const fakeSchema = { validateAsync: async () => ({ error: null }) } const fakeAws = { email: { publishMessage: sinon.stub() } } @@ -28,7 +19,6 @@ const loadWithValidateMock = (validateMock) => { return Proxyquire('../../../lib/functions/processMessage', { 'xmllint-wasm': { validateXML: validateMock }, '../helpers/service': fakeService, - '../models/message': FakeMessage, '../schemas/processMessageEventSchema': fakeSchema, '../helpers/aws': fakeAws }).processMessage @@ -72,7 +62,7 @@ lab.experiment('processMessage validation logging', () => { try { await processMessage(capAlert) - Code.expect(logs).to.include('Finished processing CAP message: id123 for FWC') + Code.expect(logs).to.include('Finished processing CAP message: 4eb3b7350ab7aa443650fc9351f02940E for TESTAREA1') Code.expect(logs.some(l => l.includes('failed validation'))).to.be.false() } finally { console.log = origLog @@ -144,28 +134,27 @@ lab.experiment('processMessage validation logging', () => { .reject() const errors = JSON.parse(ret.message.replace('[500] ', '')) - Code.expect(errors.length).to.equal(16) + Code.expect(errors.length).to.equal(15) // Helper to generate message asserts below // errors.forEach((er, i) => { // console.log(`Code.expect(errors[${i}].message).to.equal('${er.message.replace(/'/g, "\\'")}')`) // }) Code.expect(errors[0].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}sent\': \'\' is not a valid value of the local atomic type.') - Code.expect(errors[1].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}status\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Actual\', \'Exercise\', \'System\', \'Test\', \'Draft\'}.') - Code.expect(errors[2].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}msgType\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Alert\', \'Update\', \'Cancel\', \'Ack\', \'Error\'}.') - Code.expect(errors[3].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}scope\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Public\', \'Restricted\', \'Private\'}.') - Code.expect(errors[4].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}category\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Geo\', \'Met\', \'Safety\', \'Security\', \'Rescue\', \'Fire\', \'Health\', \'Env\', \'Transport\', \'Infra\', \'CBRNE\', \'Other\'}.') - Code.expect(errors[5].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}urgency\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Immediate\', \'Expected\', \'Future\', \'Past\', \'Unknown\'}.') - Code.expect(errors[6].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}severity\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Extreme\', \'Severe\', \'Moderate\', \'Minor\', \'Unknown\'}.') - Code.expect(errors[7].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}certainty\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Observed\', \'Likely\', \'Possible\', \'Unlikely\', \'Unknown\'}.') - Code.expect(errors[8].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}expires\': \'\' is not a valid value of the local atomic type.') - Code.expect(errors[9].message).to.equal('"alert.identifier[0]" is not allowed to be empty') - Code.expect(errors[10].message).to.equal('"alert.sender[0]" must be [www.gov.uk/environment-agency]') - Code.expect(errors[11].message).to.equal('"alert.sender[0]" is not allowed to be empty') - Code.expect(errors[12].message).to.equal('"alert.source[0]" is not allowed to be empty') - Code.expect(errors[13].message).to.equal('"alert.info[0].senderName[0]" is not allowed to be empty') - Code.expect(errors[14].message).to.equal('"alert.info[0].area[0].areaDesc[0]" is not allowed to be empty') - Code.expect(errors[15].message).to.equal('"alert.info[0].area[0].polygon[0]" is not allowed to be empty') + Code.expect(errors[1].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}msgType\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Alert\', \'Update\', \'Cancel\', \'Ack\', \'Error\'}.') + Code.expect(errors[2].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}scope\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Public\', \'Restricted\', \'Private\'}.') + Code.expect(errors[3].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}category\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Geo\', \'Met\', \'Safety\', \'Security\', \'Rescue\', \'Fire\', \'Health\', \'Env\', \'Transport\', \'Infra\', \'CBRNE\', \'Other\'}.') + Code.expect(errors[4].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}urgency\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Immediate\', \'Expected\', \'Future\', \'Past\', \'Unknown\'}.') + Code.expect(errors[5].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}severity\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Extreme\', \'Severe\', \'Moderate\', \'Minor\', \'Unknown\'}.') + Code.expect(errors[6].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}certainty\': [facet \'enumeration\'] The value \'\' is not an element of the set {\'Observed\', \'Likely\', \'Possible\', \'Unlikely\', \'Unknown\'}.') + Code.expect(errors[7].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}expires\': \'\' is not a valid value of the local atomic type.') + Code.expect(errors[8].message).to.equal('"alert.identifier[0]" is not allowed to be empty') + Code.expect(errors[9].message).to.equal('"alert.sender[0]" must be [www.gov.uk/environment-agency]') + Code.expect(errors[10].message).to.equal('"alert.sender[0]" is not allowed to be empty') + Code.expect(errors[11].message).to.equal('"alert.source[0]" is not allowed to be empty') + Code.expect(errors[12].message).to.equal('"alert.info[0].senderName[0]" is not allowed to be empty') + Code.expect(errors[13].message).to.equal('"alert.info[0].area[0].areaDesc[0]" is not allowed to be empty') + Code.expect(errors[14].message).to.equal('"alert.info[0].area[0].polygon[0]" is not allowed to be empty') Code.expect(awsStub.email.publishMessage.callCount).to.equal(1) }) @@ -213,17 +202,16 @@ lab.experiment('processMessage validation logging', () => { .to .reject() const errors = JSON.parse(ret.message.replace('[500] ', '')) - Code.expect(errors.length).to.equal(10) + Code.expect(errors.length).to.equal(9) Code.expect(errors[0].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}sent\': \'2026-05-28\' is not a valid value of the local atomic type.') - Code.expect(errors[1].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}status\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Actual\', \'Exercise\', \'System\', \'Test\', \'Draft\'}.') - Code.expect(errors[2].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}msgType\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Alert\', \'Update\', \'Cancel\', \'Ack\', \'Error\'}.') - Code.expect(errors[3].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}scope\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Public\', \'Restricted\', \'Private\'}.') - Code.expect(errors[4].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}category\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Geo\', \'Met\', \'Safety\', \'Security\', \'Rescue\', \'Fire\', \'Health\', \'Env\', \'Transport\', \'Infra\', \'CBRNE\', \'Other\'}.') - Code.expect(errors[5].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}urgency\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Immediate\', \'Expected\', \'Future\', \'Past\', \'Unknown\'}.') - Code.expect(errors[6].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}severity\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Extreme\', \'Severe\', \'Moderate\', \'Minor\', \'Unknown\'}.') - Code.expect(errors[7].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}certainty\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Observed\', \'Likely\', \'Possible\', \'Unlikely\', \'Unknown\'}.') - Code.expect(errors[8].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}expires\': \'2026-05-29\' is not a valid value of the local atomic type.') - Code.expect(errors[9].message).to.equal('"alert.sender[0]" must be [www.gov.uk/environment-agency]') + Code.expect(errors[1].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}msgType\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Alert\', \'Update\', \'Cancel\', \'Ack\', \'Error\'}.') + Code.expect(errors[2].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}scope\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Public\', \'Restricted\', \'Private\'}.') + Code.expect(errors[3].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}category\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Geo\', \'Met\', \'Safety\', \'Security\', \'Rescue\', \'Fire\', \'Health\', \'Env\', \'Transport\', \'Infra\', \'CBRNE\', \'Other\'}.') + Code.expect(errors[4].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}urgency\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Immediate\', \'Expected\', \'Future\', \'Past\', \'Unknown\'}.') + Code.expect(errors[5].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}severity\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Extreme\', \'Severe\', \'Moderate\', \'Minor\', \'Unknown\'}.') + Code.expect(errors[6].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}certainty\': [facet \'enumeration\'] The value \'invalid\' is not an element of the set {\'Observed\', \'Likely\', \'Possible\', \'Unlikely\', \'Unknown\'}.') + Code.expect(errors[7].message).to.equal('Schemas validity error : Element \'{urn:oasis:names:tc:emergency:cap:1.2}expires\': \'2026-05-29\' is not a valid value of the local atomic type.') + Code.expect(errors[8].message).to.equal('"alert.sender[0]" must be [www.gov.uk/environment-agency]') Code.expect(awsStub.email.publishMessage.callCount).to.equal(1) }) diff --git a/test/lib/models/message.js b/test/lib/models/message.js new file mode 100644 index 0000000..8edbdc1 --- /dev/null +++ b/test/lib/models/message.js @@ -0,0 +1,128 @@ +'use strict' + +const Lab = require('@hapi/lab') +const lab = exports.lab = Lab.script() +const Code = require('@hapi/code') + +const Message = require('../../../lib/models/message') + +const xml = ` + + 123456 + www.gov.uk/environment-agency + 2026-05-28T11:00:02-00:00 + Actual + Alert + Flood warning service + Public + + en-GB + Met + + Immediate + Minor + Likely + 2026-05-29T11:00:02-00:00 + Environment Agency + + Area description + points + + TargetAreaCode + + + + +` + +lab.experiment('Message class', () => { + let message + + lab.beforeEach(() => { + message = new Message(xml) + }) + + lab.test('parses identifier', () => { + Code.expect(message.identifier).to.equal('123456') + }) + + lab.test('parses sender', () => { + Code.expect(message.sender).to.equal('www.gov.uk/environment-agency') + }) + + lab.test('parses fwisCode (geocode value)', () => { + Code.expect(message.fwisCode).to.equal('TESTAREA') + }) + + lab.test('parses msgType', () => { + Code.expect(message.msgType).to.equal('Alert') + }) + + lab.test('can set msgType', () => { + message.msgType = 'Update' + Code.expect(message.msgType).to.equal('Update') + }) + + lab.test('parses status', () => { + Code.expect(message.status).to.equal('Actual') + }) + + lab.test('can set status', () => { + message.status = 'TestStatus' + Code.expect(message.status).to.equal('TestStatus') + }) + + lab.test('parses sent timestamp', () => { + Code.expect(message.sent).to.equal('2026-05-28T11:00:02-00:00') + }) + + lab.test('parses expires timestamp', () => { + Code.expect(message.expires).to.equal('2026-05-29T11:00:02-00:00') + }) + + lab.test('references defaults to empty string when missing', () => { + Code.expect(message.references).to.equal('') + }) + + lab.test('setting references adds element and flips msgType to Update', () => { + message.references = 'REF123' + Code.expect(message.references).to.equal('REF123') + Code.expect(message.msgType).to.equal('Update') + + // ensure XML now contains + Code.expect(message.toString()).to.include('REF123') + }) + + lab.test('does not add references if value is falsy', () => { + // Initial: no references + Code.expect(message.references).to.equal('') + message.references = '' // falsy value + Code.expect(message.references).to.equal('') // still unchanged + Code.expect(message.toString()).to.not.include('') + }) + + lab.test('updating references when already set goes into else branch', () => { + // First set adds it + message.references = 'REF1' + Code.expect(message.references).to.equal('REF1') + + // Second set should update existing + message.references = 'REF2' + Code.expect(message.references).to.equal('REF2') + Code.expect(message.toString()).to.include('REF2') + }) + + lab.test('toString returns valid XML string containing identifier', () => { + const xmlOut = message.toString() + Code.expect(xmlOut).to.be.a.string() + Code.expect(xmlOut).to.include('123456') + }) + + lab.test('putQuery generates SQL insert with correct values', () => { + const sql = message.putQuery() + Code.expect(sql.text).to.equal('INSERT INTO "messages" ("identifier", "msg_type", "references", "alert", "fwis_code", "expires", "sent", "created") VALUES ($1, $2, $3, $4, $5, $6, $7, $8)') + Code.expect(sql.values).to.include('123456') + Code.expect(sql.values).to.include('TESTAREA') + Code.expect(sql.values).to.include('2026-05-29T11:00:02-00:00') + }) +})