diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index de67df35..43d0fc86 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,6 +12,7 @@ jobs: node-version: 20 - run: npm ci - run: npm run jslint + - run: npm run test:unit - name: Install Docker Compose run: | sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose diff --git a/lib/http-routes/api/create-call.js b/lib/http-routes/api/create-call.js index e2f84142..ceb8e2da 100644 --- a/lib/http-routes/api/create-call.js +++ b/lib/http-routes/api/create-call.js @@ -339,7 +339,7 @@ router.post('/', cs.callInfo.sbcCallid = prov.get('X-CID'); if ([180, 183].includes(prov.status) && prov.body) connectStream(prov.body); restDial.emit('callStatus', prov.status, !!prov.body); - cs.emit('callStatusChange', {callStatus, sipStatus: prov.status}); + cs.emit('callStatusChange', {callStatus, sipStatus: prov.status, msg: prov}); } }); connectStream(dlg.remote.sdp); @@ -352,7 +352,8 @@ router.post('/', cs.emit('callStatusChange', { callStatus: CallStatus.InProgress, sipStatus: 200, - sipReason: 'OK' + sipReason: 'OK', + msg: dlg.res }); restDial.emit('callStatus', 200); restDial.emit('connect', dlg); @@ -367,7 +368,8 @@ router.post('/', if (cs) cs.emit('callStatusChange', { callStatus, sipStatus: err.status, - sipReason: err.reason + sipReason: err.reason, + msg: err.res }); cs.callGone = true; } diff --git a/lib/middleware.js b/lib/middleware.js index a8ef630c..d8ee0b7e 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -120,6 +120,11 @@ module.exports = function(srf, logger) { req.once('cancel', (sipMsg) => { logger.info(`${callId} got CANCEL request`); req.locals.canceled = true; + /* keep the CANCEL: it may carry an RFC 3326 Reason (e.g. SIP;cause=200 + ;text="Call completed elsewhere"), which is the only thing distinguishing a + forked branch losing the race from a caller who simply gave up - the 487 and + its reason phrase are ours, so both look identical without it */ + req.locals.cancelReq = sipMsg; }); next(); } diff --git a/lib/session/adulting-call-session.js b/lib/session/adulting-call-session.js index 03063d87..4302b48d 100644 --- a/lib/session/adulting-call-session.js +++ b/lib/session/adulting-call-session.js @@ -23,6 +23,10 @@ class AdultingCallSession extends CallSession { this.sd = singleDialer; this.req = callInfo.req; + /* The BYE is deliberately not threaded on from here: the Completed status event for + this leg - and with it any Reason header on the BYE - is emitted by SingleDialer's + own 'destroy' handler, which already carries the request. Emitting it here too would + double-notify. */ this.sd.dlg.on('destroy', () => { this.logger.info('AdultingCallSession: called party hung up'); this._callReleased(); diff --git a/lib/session/call-info.js b/lib/session/call-info.js index 2527dc99..9dd87450 100644 --- a/lib/session/call-info.js +++ b/lib/session/call-info.js @@ -2,6 +2,21 @@ const {CallDirection, CallStatus} = require('../utils/constants'); const parseUri = require('drachtio-srf').parseUri; const crypto = require('crypto'); const {JAMBONES_API_BASE_URL} = require('../config'); +/** + * Fields that belong on the status webhook but must never enter the redis call record. + * + * That record is written with hmset, which MERGES, so a field able to go from set back to + * unset - as sipReasonHeader does, unlike sipStatus/sipReason which are only ever + * overwritten - would strand a value from an earlier status change where GET /Calls/:sid + * reports it: a leg that saw "183 + Reason: Q.850;cause=31" then answered cleanly and ended + * on a plain BYE would report a temporary-failure cause for a call that completed normally. + * + * Excluding it removes that problem instead of managing it. Redis holds calls that are + * still live, where there is usually no interesting cause yet; by the time there is one the + * call is over and the history belongs in the CDR. Add any future webhook-only field here. + */ +const WEBHOOK_ONLY_FIELDS = ['sipReasonHeader']; + /** * @classdesc Represents the common information for all calls * that is provided in call status webhooks @@ -105,11 +120,26 @@ class CallInfo { * update the status of the call * @param {string} callStatus - current call status * @param {number} sipStatus - current sip status + * @param {string} [sipReason] - reason phrase from the SIP status line + * @param {string} [sipReasonHeader] - RFC 3326 Reason header of the SIP message that caused + * this status change, if it carried one. Unlike the fields above this is assigned + * unconditionally, so that it always describes the current change rather than lingering + * from an earlier one. + * + * This is a webhook-only field, deliberately kept OUT of the redis call record: that + * record is written with hmset, which MERGES, so a field that can legitimately go from + * set back to unset - as this one does, unlike sipStatus/sipReason - would strand a cause + * from an earlier status change where GET /Calls/:sid reports it. Keeping it undefined + * when absent means realtimedb-helpers filters it out and it is never written at all, + * which removes the problem rather than managing it. Redis holds calls that are still + * live, where there is usually no interesting cause yet; by the time there is one the + * call is over and the history belongs in the CDR. */ - updateCallStatus(callStatus, sipStatus, sipReason) { + updateCallStatus(callStatus, sipStatus, sipReason, sipReasonHeader) { this.callStatus = callStatus; if (sipStatus) this.sipStatus = sipStatus; if (sipReason) this.sipReason = sipReason; + this.sipReasonHeader = sipReasonHeader; } /** @@ -132,6 +162,17 @@ class CallInfo { return this._sipHeaders; } + /** + * Project a call-status shape into what may be written to the redis call record. + * Both writers go through this, from different bases: CallSession writes the webhook + * payload, SingleDialer writes the CallInfo instance itself. + */ + static toRedisRecord(obj) { + const record = Object.assign({}, obj); + WEBHOOK_ONLY_FIELDS.forEach((f) => delete record[f]); + return record; + } + toJSON() { const obj = { callSid: this.callSid, @@ -149,7 +190,8 @@ class CallInfo { applicationSid: this.applicationSid, fsSipAddress: this.localSipAddress }; - ['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy'].forEach((prop) => { + ['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy', + 'sipReasonHeader'].forEach((prop) => { if (this[prop]) obj[prop] = this[prop]; }); if (typeof this.duration === 'number') obj.duration = this.duration; diff --git a/lib/session/call-session.js b/lib/session/call-session.js index 6499caff..bca9c5b0 100644 --- a/lib/session/call-session.js +++ b/lib/session/call-session.js @@ -41,6 +41,8 @@ const { NonFatalTaskError} = require('../utils/error'); const { createMediaEndpoint } = require('../utils/media-endpoint'); const { isOnhold } = require('../utils/sdp-utils'); const SttLatencyCalculator = require('../utils/stt-latency-calculator'); +const {reasonHeaderFromSipMessage} = require('../utils/sip-reason'); +const CallInfo = require('./call-info'); const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks WHERE webhook_sid = ( @@ -109,7 +111,12 @@ class CallSession extends Emitter { this.tmpFiles = new Set(); if (!this.isSmsCallSession) { - this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus; + /* Route every call-record write through the redis projection here rather than at each + call site: this class writes the record from more than one place (status change, + recording flag, conference state) and a new one must not be able to leak a + webhook-only field into a store with merge semantics by forgetting to ask. */ + const {updateCallStatus} = srf.locals.dbHelpers; + this.updateCallStatus = (obj, serviceUrl) => updateCallStatus(CallInfo.toRedisRecord(obj), serviceUrl); this.serviceUrl = srf.locals.serviceUrl; } @@ -2541,7 +2548,8 @@ Duration=${duration} ` this._notifyCallStatusChange({ callStatus: CallStatus.Failed, sipStatus: err.status, - sipReason: err.reason || 'Endpoint Allocation Failed' + sipReason: err.reason || 'Endpoint Allocation Failed', + sipReasonHeader }); this._callReleased(); } @@ -3105,7 +3113,7 @@ Duration=${duration} ` * @param {number} sipStatus - current sip status * @param {number} [duration] - duration of a completed call, in seconds */ - async _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration, headers}) { + async _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration, headers, msg, sipReasonHeader}) { if (this.callMoved) return; // manage record all call. @@ -3127,7 +3135,10 @@ Duration=${duration} ` (!duration && callStatus !== CallStatus.Completed), 'duration MUST be supplied when call completed AND ONLY when call completed'); - this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason); + /* a caller that already extracted the header (endpoint allocation failure, where the + error is an fsmrf object rather than a SipMessage) passes it directly */ + this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason, + sipReasonHeader ?? reasonHeaderFromSipMessage(msg)); if (typeof duration === 'number') this.callInfo.duration = duration; if (headers) this.callInfo.sipHeaders = headers; this.executeStatusCallback(callStatus, sipStatus); diff --git a/lib/session/inbound-call-session.js b/lib/session/inbound-call-session.js index 4b1bb1b6..d0f60e97 100644 --- a/lib/session/inbound-call-session.js +++ b/lib/session/inbound-call-session.js @@ -25,7 +25,10 @@ class InboundCallSession extends CallSession { // if the call was canceled before we got here, handle it if (this.req.locals.canceled) { req.locals.logger.info('InboundCallSession: constructor - call was already canceled'); - this._onCancel(); + /* the CANCEL landed before we got here, so it came in via middleware rather than + the listener below; without this the header would survive only when the CANCEL + lost the race with the application fetch */ + this._onCancel(req.locals.cancelReq); } req.once('cancel', this._onCancel.bind(this)); @@ -38,13 +41,14 @@ class InboundCallSession extends CallSession { }); } - _onCancel() { + _onCancel(cancelReq) { this.rootSpan.setAttributes({'call.termination': 'caller abandoned'}); this.callInfo.callTerminationBy = 'caller'; this._notifyCallStatusChange({ callStatus: CallStatus.NoAnswer, sipStatus: 487, - sipReason: 'Request Terminated' + sipReason: 'Request Terminated', + msg: cancelReq }); this._callReleased(); } @@ -113,6 +117,7 @@ class InboundCallSession extends CallSession { this.emit('callStatusChange', { callStatus: CallStatus.Completed, duration, + msg: req, ...(headers && {headers}) }); this._callReleased(); diff --git a/lib/session/rest-call-session.js b/lib/session/rest-call-session.js index ba131be2..a63262a3 100644 --- a/lib/session/rest-call-session.js +++ b/lib/session/rest-call-session.js @@ -66,7 +66,7 @@ class RestCallSession extends CallSession { this.callInfo.callTerminationBy = terminatedBy; const duration = moment().diff(this.dlg.connectTime, 'seconds'); const headers = this._extractCustomHeaders(req); - this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration, ...(headers && {headers})}); + this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration, msg: req, ...(headers && {headers})}); this.logger.info(`RestCallSession: called party hung up by ${terminatedBy}`); this._callReleased(); } diff --git a/lib/utils/place-outdial.js b/lib/utils/place-outdial.js index 843c747b..da05301c 100644 --- a/lib/utils/place-outdial.js +++ b/lib/utils/place-outdial.js @@ -17,6 +17,7 @@ const HttpRequestor = require('./http-requestor'); const WsRequestor = require('./ws-requestor'); const {makeOpusFirst, removeVideoSdp} = require('./sdp-utils'); const { createMediaEndpoint } = require('./media-endpoint'); +const {reasonHeaderFromSipMessage} = require('./sip-reason'); class SingleDialer extends Emitter { constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask, @@ -136,7 +137,12 @@ class SingleDialer extends Emitter { assert(false, `invalid dial type ${this.target.type}: must be phone, user, or sip`); } - this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus; + /* Route the call-record write through the redis projection here rather than at the + call site, matching CallSession: this class has only one writer today, but the + redis record has merge semantics and a second one must not be able to leak a + webhook-only field into it by forgetting to ask. */ + const {updateCallStatus} = srf.locals.dbHelpers; + this.updateCallStatus = (obj, serviceUrl) => updateCallStatus(CallInfo.toRedisRecord(obj), serviceUrl); this.serviceUrl = srf.locals.serviceUrl; this.ep = await this._createMediaEndpoint(); @@ -221,7 +227,7 @@ class SingleDialer extends Emitter { }); }, cbProvisional: (prov) => { - const status = {sipStatus: prov.status, sipReason: prov.reason}; + const status = {sipStatus: prov.status, sipReason: prov.reason, msg: prov}; // Update call-id for sbc outbound INVITE this.callInfo.sbcCallid = prov.get('X-CID'); if ([180, 183].includes(prov.status) && prov.body) { @@ -246,7 +252,8 @@ class SingleDialer extends Emitter { this.emit('callStatusChange', { sipStatus: 200, sipReason: 'OK', - callStatus: CallStatus.InProgress + callStatus: CallStatus.InProgress, + msg: this.dlg.res }); this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`); const connectTime = this.dlg.connectTime = moment(); @@ -273,7 +280,9 @@ class SingleDialer extends Emitter { const duration = moment().diff(connectTime, 'seconds'); const headers = this._extractCustomHeaders(req); this.logger.debug('SingleDialer:exec called party hung up'); - this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration, ...(headers && {headers})}); + this.emit('callStatusChange', { + callStatus: CallStatus.Completed, duration, msg: req, ...(headers && {headers}) + }); this.ep && this.ep.destroy(); }) .on('refresh', () => this.logger.info('SingleDialer:exec - dialog refreshed by uas')) @@ -315,6 +324,7 @@ class SingleDialer extends Emitter { if (err instanceof SipError) { status.sipStatus = err.status; status.sipReason = err.reason; + status.msg = err.res; if (err.status === 487) status.callStatus = CallStatus.NoAnswer; else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy; this.logger.info(`SingleDialer:exec outdial failure ${err.status}`); @@ -547,13 +557,14 @@ class SingleDialer extends Emitter { return Object.keys(headers).length ? headers : null; } - _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration, headers}) { + _notifyCallStatusChange({callStatus, sipStatus, sipReason, duration, headers, msg, sipReasonHeader}) { assert((typeof duration === 'number' && callStatus === CallStatus.Completed) || (!duration && callStatus !== CallStatus.Completed), 'duration MUST be supplied when call completed AND ONLY when call completed'); if (this.callInfo) { - this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason); + this.callInfo.updateCallStatus(callStatus, sipStatus, sipReason, + sipReasonHeader ?? reasonHeaderFromSipMessage(msg)); if (typeof duration === 'number') this.callInfo.duration = duration; if (headers) this.callInfo.sipHeaders = headers; try { @@ -562,7 +573,8 @@ class SingleDialer extends Emitter { this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`); } // update calls db - this.updateCallStatus(this.callInfo, this.serviceUrl).catch((err) => this.logger.error(err, 'redis error')); + this.updateCallStatus(this.callInfo, this.serviceUrl) + .catch((err) => this.logger.error(err, 'redis error')); } else { this.logger.info('SingleDialer:_notifyCallStatusChange: call status change before sending the outbound INVITE!!'); diff --git a/lib/utils/sip-reason.js b/lib/utils/sip-reason.js new file mode 100644 index 00000000..34b45160 --- /dev/null +++ b/lib/utils/sip-reason.js @@ -0,0 +1,41 @@ +/** + * RFC 3326 Reason header support. + * + * Carriers fronting ISDN/E1 PRI trunks put the authoritative disconnect cause in + * a Reason header rather than in the SIP status line, e.g. + * + * SIP/2.0 408 Request Timeout + * Reason: Q.850 ;cause=18 + * + * (Q.850 cause 18 is "no user responding" - i.e. nobody answered, not a fault.) + * Different Q.850 causes can arrive under the same SIP status - 503 may carry + * cause=38 (network out of order) or cause=41 (temporary failure) - so the status + * code on its own is not enough to classify the outcome of the call. We surface + * the header verbatim on call status events and leave interpretation to the + * application. + */ + +/** + * Return the Reason header of a SIP message, or undefined if it has none. + * + * A message may legally carry more than one Reason header (RFC 3326), and trunks + * that report both a SIP and a Q.850 cause commonly do. The drachtio parser joins + * repeated headers into a single comma-separated string; we pass that through + * unchanged rather than picking one of them. + * + * What reaches us is the header as the SBC relayed it, NOT necessarily the + * carrier's exact bytes: proxying re-serializes the header, which normalizes the + * optional whitespace RFC 3326 permits around ';'. A carrier's + * "Q.850 ;cause=18" therefore arrives here as "Q.850;cause=18" - confirmed on + * the wire (external leg vs the leg into this process) and visible in customer + * captures too. Consumers should parse tolerantly rather than string-match. + * + * @param {object} [msg] - a drachtio SipMessage (request or response), if we have one + * @returns {string|undefined} the Reason header value, or undefined + */ +const reasonHeaderFromSipMessage = (msg) => { + if (!msg || typeof msg.get !== 'function') return; + return msg.get('Reason') || undefined; +}; + +module.exports = {reasonHeaderFromSipMessage}; diff --git a/package.json b/package.json index 4525639a..56404cc2 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "scripts": { "start": "node app", "test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 JAMBONES_TTS_TRIM_SILENCE=1 ENCRYPTION_SECRET=foobar DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=error ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:JambonzR0ck$:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ", + "test:unit": "node --test test/unit/*.test.js", "coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test", "jslint": "eslint app.js tracer.js lib", "jslint:fix": "eslint app.js tracer.js lib --fix" diff --git a/test/unit/sip-reason-header.test.js b/test/unit/sip-reason-header.test.js new file mode 100644 index 00000000..79ac9ecc --- /dev/null +++ b/test/unit/sip-reason-header.test.js @@ -0,0 +1,163 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const SipMessage = require('drachtio-srf/lib/sip-parser/message'); +const CallInfo = require('../../lib/session/call-info'); +const snakeCaseKeys = require('../../lib/utils/snakecase-keys'); +const {reasonHeaderFromSipMessage} = require('../../lib/utils/sip-reason'); +const {CallDirection, CallStatus} = require('../../lib/utils/constants'); + +/* build the outbound INVITE that CallInfo is constructed from */ +const makeReq = () => { + const req = new SipMessage([ + 'INVITE sip:+971555551234@example.com SIP/2.0', + 'Call-ID: daa1269b-0b91-1240-9db3-022758ab7fff', + 'From: ;tag=abc123', + 'To: ', + 'Content-Length: 0', + '', '' + ].join('\r\n')); + req.srf = {locals: {localSipAddress: '172.30.29.123:5060'}}; + return req; +}; + +const makeSipMessage = (startLine, headers = []) => new SipMessage([ + startLine, + 'Call-ID: daa24f5d-0b91-1240-14ab-0ec7040a32ad', + ...headers, + 'Content-Length: 0', + '', '' +].join('\r\n')); + +const makeCallInfo = () => new CallInfo({ + direction: CallDirection.Outbound, + req: makeReq(), + to: '+971555551234', + callSid: '9921be00-ced0-45cb-add1-e02f9ce555ab', + accountSid: 'e43117dc-4b91-430c-82ad-74d2725f3026', + applicationSid: '72c5c38f-9bba-40ce-aa83-aaa6be55e1b5', + traceId: '615e314ac26241863b905931d9aad440' +}); + + +/* mirrors filterNullsAndObjects in realtimedb-helpers, which decides what actually + reaches the redis call hash via hmset */ +const redisFields = (callInfo) => Object.keys(callInfo) + .filter((k) => callInfo[k] !== null && typeof callInfo[k] !== 'undefined' && typeof callInfo[k] !== 'object'); + +/* the payload a call status webhook consumer actually receives */ +const statusPayload = (callInfo) => snakeCaseKeys(callInfo.toJSON(), ['customerData', 'sip', 'env_vars', 'args']); + +test('Reason header on a final failure response is surfaced as sip_reason_header', () => { + const callInfo = makeCallInfo(); + const res = makeSipMessage('SIP/2.0 408 Request Timeout', ['Reason: Q.850 ;cause=18']); + + callInfo.updateCallStatus(CallStatus.Failed, 408, 'Request Timeout', reasonHeaderFromSipMessage(res)); + const payload = statusPayload(callInfo); + + assert.strictEqual(payload.sip_reason_header, 'Q.850 ;cause=18'); + /* sip_reason must keep meaning the status-line phrase - existing consumers depend on it */ + assert.strictEqual(payload.sip_reason, 'Request Timeout'); + assert.strictEqual(payload.sip_status, 408); +}); + +test('spacing variants of the Reason header are passed through verbatim', () => { + for (const raw of ['Q.850 ;cause=31', 'Q.850;cause=31', 'Q.850 ; cause=31']) { + const callInfo = makeCallInfo(); + const res = makeSipMessage('SIP/2.0 480 Temporarily Unavailable', [`Reason: ${raw}`]); + callInfo.updateCallStatus(CallStatus.Failed, 480, 'Temporarily Unavailable', reasonHeaderFromSipMessage(res)); + assert.strictEqual(statusPayload(callInfo).sip_reason_header, raw); + } +}); + +test('a response with no Reason header adds no key to the payload', () => { + const callInfo = makeCallInfo(); + const res = makeSipMessage('SIP/2.0 503 Service Unavailable'); + + callInfo.updateCallStatus(CallStatus.Failed, 503, 'Service Unavailable', reasonHeaderFromSipMessage(res)); + const payload = statusPayload(callInfo); + + assert.ok(!('sip_reason_header' in payload), 'payload must be unchanged for carriers that send no Reason'); +}); + +test('repeated Reason headers are preserved rather than one being dropped', () => { + const callInfo = makeCallInfo(); + const res = makeSipMessage('SIP/2.0 486 Busy Here', [ + 'Reason: SIP ;cause=486 ;text="busy"', + 'Reason: Q.850 ;cause=17' + ]); + + callInfo.updateCallStatus(CallStatus.Busy, 486, 'Busy Here', reasonHeaderFromSipMessage(res)); + const header = statusPayload(callInfo).sip_reason_header; + + assert.match(header, /SIP ;cause=486/); + assert.match(header, /Q\.850 ;cause=17/); +}); + +test('a Reason header on a BYE is surfaced on the completed event', () => { + const callInfo = makeCallInfo(); + const bye = new SipMessage([ + 'BYE sip:+971555551234@example.com SIP/2.0', + 'Call-ID: daa24f5d-0b91-1240-14ab-0ec7040a32ad', + 'Reason: Q.850 ;cause=16', + 'Content-Length: 0', + '', '' + ].join('\r\n')); + + callInfo.duration = 42; + callInfo.updateCallStatus(CallStatus.Completed, 200, 'OK', reasonHeaderFromSipMessage(bye)); + + assert.strictEqual(statusPayload(callInfo).sip_reason_header, 'Q.850 ;cause=16'); +}); + +test('a Reason header does not linger onto a later status change that has none', () => { + const callInfo = makeCallInfo(); + const prov = makeSipMessage('SIP/2.0 183 Session Progress', ['Reason: Q.850 ;cause=31']); + const ok = makeSipMessage('SIP/2.0 200 OK'); + + callInfo.updateCallStatus(CallStatus.EarlyMedia, 183, 'Session Progress', reasonHeaderFromSipMessage(prov)); + assert.strictEqual(statusPayload(callInfo).sip_reason_header, 'Q.850 ;cause=31'); + + callInfo.updateCallStatus(CallStatus.InProgress, 200, 'OK', reasonHeaderFromSipMessage(ok)); + assert.ok(!('sip_reason_header' in statusPayload(callInfo)), + 'each status event must report the Reason of the message that caused it'); +}); + +test('status changes with no SIP message at all are handled', () => { + /* e.g. jambonz hanging up the call itself, or a media timeout */ + assert.strictEqual(reasonHeaderFromSipMessage(undefined), undefined); + assert.strictEqual(reasonHeaderFromSipMessage(null), undefined); + assert.strictEqual(reasonHeaderFromSipMessage({}), undefined); + + const callInfo = makeCallInfo(); + callInfo.duration = 7; + callInfo.updateCallStatus(CallStatus.Completed, 200, 'OK', reasonHeaderFromSipMessage(undefined)); + assert.ok(!('sip_reason_header' in statusPayload(callInfo))); +}); + + +test('the Reason header never enters the redis call record', () => { + const callInfo = makeCallInfo(); + const res = makeSipMessage('SIP/2.0 480 Temporarily Unavailable', ['Reason: Q.850 ;cause=31']); + + callInfo.updateCallStatus(CallStatus.Failed, 480, 'Temporarily Unavailable', reasonHeaderFromSipMessage(res)); + + /* It belongs on the webhook... */ + assert.strictEqual(statusPayload(callInfo).sip_reason_header, 'Q.850 ;cause=31'); + + /* ...and must be kept out of the redis call record, which is written with hmset - a + MERGE. A field that can go from set back to unset would otherwise strand a cause from + an earlier status change where GET /Calls/:sid reports it. + There is more than one writer and they project from DIFFERENT bases - the status + change and recording-flag writes send the webhook payload, SingleDialer sends the + CallInfo instance - so assert the projection holds for both shapes. The sessions apply + it by wrapping updateCallStatus at the boundary rather than at each call site, so a + newly added writer cannot bypass it by forgetting to ask. */ + assert.ok(!redisFields(CallInfo.toRedisRecord(callInfo.toJSON())).includes('sipReasonHeader'), + 'CallSession must not write sipReasonHeader to the call record'); + assert.ok(!redisFields(CallInfo.toRedisRecord(callInfo)).includes('sipReasonHeader'), + 'SingleDialer must not write sipReasonHeader to the call record'); + + /* the exclusion must not take anything else with it */ + assert.ok(redisFields(CallInfo.toRedisRecord(callInfo.toJSON())).includes('sipReason')); + assert.ok(redisFields(CallInfo.toRedisRecord(callInfo.toJSON())).includes('callStatus')); +});