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')
+ })
+})