Read a body from message_payload and accept data_sm (#84)
* Regression tests for a body in message_payload and for data_sm * Read a body from message_payload and accept data_sm * Assert the fixed message_payload and data_sm behaviour against Jasmin * Record the message_payload and data_sm fixes in the Jasmin findings * Read an inbound data_sm as the direction it travelled, and export messageOctets * Refuse a segment with the code its stand-in command defines * Name the stand-in the refusal status is read from
This commit is contained in:
@@ -40,6 +40,32 @@ function deliverSm(
|
||||
return pduObj;
|
||||
}
|
||||
|
||||
/** SMPP 3.4 5.3.2.32 puts the body in a TLV instead, which is the only place a data_sm has for one. */
|
||||
function payloadPdu(
|
||||
cmdName: 'data_sm' | 'deliver_sm',
|
||||
body: string,
|
||||
shortMessage = Buffer.alloc(0),
|
||||
): PduObject {
|
||||
const params = {
|
||||
data_coding: 0,
|
||||
destination_addr: '46701113311',
|
||||
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
source_addr: '46709771337',
|
||||
};
|
||||
const tlvs = { message_payload: { tagValue: Buffer.from(body, 'latin1') } };
|
||||
const built = cmdName === 'deliver_sm'
|
||||
? objToPdu({ cmdName, params: { ...params, short_message: shortMessage }, seqNr: 1, tlvs })
|
||||
: objToPdu({ cmdName, params, seqNr: 1, tlvs });
|
||||
|
||||
assert.ok(built.buffer);
|
||||
|
||||
const { pduObj } = pduToObj(built.buffer);
|
||||
|
||||
assert.ok(pduObj);
|
||||
|
||||
return pduObj;
|
||||
}
|
||||
|
||||
describe('parseReceipt()', () => {
|
||||
test('pulls every standard field out of the receipt body', () => {
|
||||
const receipt = parseReceipt(receiptText);
|
||||
@@ -299,6 +325,25 @@ describe('dlrFromPdu()', () => {
|
||||
assert.equal(scheduled.intermediate, true, 'an ordinary receipt reporting a transient state is not final either');
|
||||
});
|
||||
|
||||
test('reads a receipt the peer carried in message_payload, on deliver_sm and on data_sm', () => {
|
||||
for (const cmdName of ['data_sm', 'deliver_sm'] as const) {
|
||||
const dlr = dlrFromPdu(payloadPdu(cmdName, textReceipt));
|
||||
|
||||
assert.ok(dlr, `expected a ${cmdName} carrying its receipt in message_payload to read as one`);
|
||||
assert.equal(dlr.smsId, textReceiptId);
|
||||
assert.equal(dlr.statusMsg, 'DELIVERED');
|
||||
assert.equal(dlr.receipt?.stat, 'DELIVRD');
|
||||
}
|
||||
});
|
||||
|
||||
// The spec has the peer leave sm_length 0 when it uses the TLV, so a filled one is what it meant.
|
||||
test('reads short_message where the peer filled both fields', () => {
|
||||
const dlr = dlrFromPdu(payloadPdu('deliver_sm', textReceipt, Buffer.from(receiptText, 'latin1')));
|
||||
|
||||
assert.ok(dlr);
|
||||
assert.equal(dlr.smsId, '0195f0c7');
|
||||
});
|
||||
|
||||
test('exposes the raw receipt alongside the resolved fields', () => {
|
||||
const dlr = dlrFromPdu(deliverSm(receiptText));
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
smppTime,
|
||||
splitMessage,
|
||||
} from '../src/message.ts';
|
||||
// Through the public surface: an application handed a PduObject needs this same answer.
|
||||
import { messageOctets, objToPdu, pduToObj } from '../src/index.ts';
|
||||
|
||||
describe('bitCount()', () => {
|
||||
test('counts GSM characters as seven bits each', () => {
|
||||
@@ -130,6 +132,41 @@ describe('encodeMessage() and decodeMessage()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('messageOctets()', () => {
|
||||
/** A data_sm has no short_message field at all, which is the case that has neither. */
|
||||
function parsed(short: Buffer | undefined, payload?: Buffer) {
|
||||
const { buffer } = short === undefined
|
||||
? objToPdu({
|
||||
cmdName: 'data_sm',
|
||||
params: { destination_addr: '46709771337', source_addr: '46701113311' },
|
||||
})
|
||||
: objToPdu({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: short, source_addr: '46701113311' },
|
||||
...(payload ? { tlvs: { message_payload: { tagValue: payload } } } : {}),
|
||||
});
|
||||
|
||||
assert.ok(buffer);
|
||||
|
||||
const { pduObj } = pduToObj(buffer);
|
||||
|
||||
assert.ok(pduObj);
|
||||
|
||||
return pduObj;
|
||||
}
|
||||
|
||||
test('reads the TLV only where short_message carries nothing', () => {
|
||||
const short = Buffer.from('in the field');
|
||||
const payload = Buffer.from('in the TLV');
|
||||
|
||||
assert.deepEqual(messageOctets(parsed(Buffer.alloc(0), payload)), payload);
|
||||
assert.deepEqual(messageOctets(parsed(short, payload)), short);
|
||||
assert.deepEqual(messageOctets(parsed(short)), short);
|
||||
assert.deepEqual(messageOctets(parsed(Buffer.alloc(0))), Buffer.alloc(0));
|
||||
assert.equal(messageOctets(parsed(undefined)), undefined, 'a PDU with neither carries no body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('smppDate()', () => {
|
||||
// 0.4.0 used getMonth() without adding one, so January rendered as 00 and every delivery
|
||||
// receipt carried a date a month in the past.
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Session } from '../src/session.ts';
|
||||
import { DlrMerger } from '../src/dlr-merger.ts';
|
||||
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
||||
import { objToPdu } from '../src/pdu.ts';
|
||||
import { checkSessionOptions } from '../src/session-options.ts';
|
||||
import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||
import { consts } from '../src/defs/constants.ts';
|
||||
@@ -1449,6 +1449,21 @@ describe('reassembly bounds', () => {
|
||||
};
|
||||
}
|
||||
|
||||
/** The same segment with its body where SMPP 3.4 5.3.2.32 allows it instead. */
|
||||
function payloadSegment(reference: number, part: number, total: number): PduObject {
|
||||
const carried = segment(reference, part, total);
|
||||
const body = carried.shortMessageOctets;
|
||||
|
||||
assert.ok(body);
|
||||
|
||||
return {
|
||||
...carried,
|
||||
params: { ...carried.params, short_message: Buffer.alloc(0) },
|
||||
shortMessageOctets: Buffer.alloc(0),
|
||||
tlvs: { message_payload: { tagId: 0x0424, tagName: 'message_payload', tagValue: body } },
|
||||
};
|
||||
}
|
||||
|
||||
function collect(
|
||||
reassembler: Reassembler,
|
||||
reference: number,
|
||||
@@ -1504,6 +1519,25 @@ describe('reassembly bounds', () => {
|
||||
assert.deepEqual(lost, [], 'the peer holds the only segment there was, so nothing was lost');
|
||||
});
|
||||
|
||||
// The two addresses are 22 octets, so only the 14 the TLV carries can overrun a cap of 30.
|
||||
test('counts a body carried in message_payload against the octet cap', () => {
|
||||
function collectPayload(maxOctets: number): Collected {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
maxOctets,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
return reassembler.collect(payloadSegment(9, 1, 2), { part: 1, reference: 9, total: 2 });
|
||||
}
|
||||
|
||||
assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
|
||||
assert.equal(collectPayload(40).kept, true);
|
||||
});
|
||||
|
||||
// The segments before it were answered ESME_ROK, so dropping those is not the same as refusing one.
|
||||
test('reports the answered segments of a group that overruns the cap mid-message', () => {
|
||||
const lost: LostGroup[] = [];
|
||||
@@ -1730,6 +1764,17 @@ describe('the status a refused segment is answered with', () => {
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable'), 'ESME_RINVESMCLASS');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable'), 'ESME_RINVESMCLASS');
|
||||
});
|
||||
|
||||
// Which command that is, for the one that travels both ways, is what the end it arrived at says.
|
||||
test('reads a data_sm as the command its direction makes it', () => {
|
||||
assert.equal(standsInFor('data_sm', 'esme'), 'deliver_sm');
|
||||
assert.equal(standsInFor('data_sm', 'smsc'), 'submit_sm');
|
||||
assert.equal(standsInFor('deliver_sm', 'esme'), 'deliver_sm');
|
||||
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
|
||||
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full'), 'ESME_RX_T_APPN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a peer that sends the next segment only once the last one is answered', () => {
|
||||
|
||||
@@ -514,6 +514,58 @@ describe('bind direction', () => {
|
||||
assert.ok(report.err instanceof Error);
|
||||
assert.match(report.err.message, /transmitter-bound/);
|
||||
});
|
||||
|
||||
// data_sm carries a message either way, so which end this is decides which way it may travel.
|
||||
test('refuses a data_sm sent to a peer that bound as a transmitter', async t => {
|
||||
const smpp = await startServer(t);
|
||||
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
|
||||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const peer = await bound;
|
||||
const sent = await peer.send({
|
||||
cmdName: 'data_sm',
|
||||
params: { destination_addr: '46709771337', source_addr: '46701113311' },
|
||||
tlvs: { message_payload: { tagValue: Buffer.from('nope') } },
|
||||
});
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
assert.equal(sent.pduObj.cmdName, 'data_sm_resp');
|
||||
assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
|
||||
});
|
||||
|
||||
test('refuses a data_sm from a receiver-bound peer, and carries one from a transmitter', async t => {
|
||||
const smpp = await startServer(t);
|
||||
|
||||
smpp.on('session', peer => peer.on('sms', sms => void sms.sendResp()));
|
||||
|
||||
const receiving = await connect(t, smpp, { bindType: 'receiver' });
|
||||
|
||||
assert.ok(receiving.session);
|
||||
|
||||
const refused = await receiving.session.send({
|
||||
cmdName: 'data_sm',
|
||||
params: { destination_addr: '46709771337', source_addr: '46701113311' },
|
||||
tlvs: { message_payload: { tagValue: Buffer.from('nope') } },
|
||||
});
|
||||
|
||||
assert.ok(refused.pduObj);
|
||||
assert.equal(refused.pduObj.cmdStatus, 'ESME_RINVBNDSTS');
|
||||
|
||||
const sending = await connect(t, smpp, { bindType: 'transmitter' });
|
||||
|
||||
assert.ok(sending.session);
|
||||
|
||||
const carried = await sending.session.send({
|
||||
cmdName: 'data_sm',
|
||||
params: { destination_addr: '46709771337', source_addr: '46701113311' },
|
||||
tlvs: { message_payload: { tagValue: Buffer.from('a submission the bind carries') } },
|
||||
});
|
||||
|
||||
assert.ok(carried.pduObj);
|
||||
assert.equal(carried.pduObj.cmdStatus, 'ESME_ROK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sending', () => {
|
||||
@@ -707,6 +759,199 @@ describe('receiving', () => {
|
||||
assert.equal(sms.smsId, 'inbound-id', 'the id the application chose is still its own handle');
|
||||
});
|
||||
|
||||
// SMPP 3.4 5.3.2.32: up to 64 KB of body in a TLV, with sm_length 0 and short_message empty.
|
||||
test('reads an inbound message the peer carried in message_payload', async t => {
|
||||
const { peer, session } = await inbound(t);
|
||||
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||||
const text = 'the whole body, carried in the TLV';
|
||||
const delivered = peer.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: Buffer.alloc(0),
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: { message_payload: { tagValue: Buffer.from(text, 'latin1') } },
|
||||
});
|
||||
const sms = await raceWithin(2000, incoming);
|
||||
|
||||
assert.ok(sms, 'a body the peer put in message_payload is still a message');
|
||||
assert.equal(sms.message, text);
|
||||
assert.equal(sms.from, '46701113311');
|
||||
assert.equal(sms.to, '46709771337');
|
||||
|
||||
await sms.sendResp();
|
||||
|
||||
const answered = await delivered;
|
||||
|
||||
assert.ok(answered.pduObj);
|
||||
assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
|
||||
});
|
||||
|
||||
test('hands a client a data_sm carrying a message as an sms, answered data_sm_resp', async t => {
|
||||
const { peer, session } = await inbound(t);
|
||||
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||||
const smsId = '0199e0f1-6c31-7a44-9d02-4b7e51c3a806';
|
||||
const delivered = peer.send({
|
||||
cmdName: 'data_sm',
|
||||
params: { destination_addr: '46709771337', source_addr: '46701113311' },
|
||||
tlvs: { message_payload: { tagValue: Buffer.from('a message carried on the data command', 'latin1') } },
|
||||
});
|
||||
const sms = await raceWithin(2000, incoming);
|
||||
|
||||
assert.ok(sms, 'data_sm is a peer of deliver_sm, not a command to refuse');
|
||||
assert.equal(sms.message, 'a message carried on the data command');
|
||||
assert.equal(sms.from, '46701113311');
|
||||
|
||||
await sms.sendResp({ smsId });
|
||||
|
||||
const answered = await delivered;
|
||||
|
||||
assert.ok(answered.pduObj);
|
||||
assert.equal(answered.pduObj.cmdName, 'data_sm_resp');
|
||||
assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
|
||||
// SMPP 3.4 4.7.2 gives data_sm_resp a message_id, where 4.6.2 leaves deliver_sm_resp's unused.
|
||||
assert.equal(paramText(answered.pduObj.params.message_id), smsId);
|
||||
});
|
||||
|
||||
test('hands a client a receipt carried on data_sm as a dlr', async t => {
|
||||
const { peer, session } = await inbound(t);
|
||||
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
|
||||
const smsId = '0199e0f1-b8a2-7f19-8c63-2d5041fb9e77';
|
||||
let messages = 0;
|
||||
|
||||
session.on('sms', () => { messages++; });
|
||||
|
||||
const delivered = peer.send({
|
||||
cmdName: 'data_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: {
|
||||
message_payload: {
|
||||
tagValue: Buffer.from(`id:${smsId} sub:001 dlvrd:001 stat:DELIVRD err:000 text:`, 'latin1'),
|
||||
},
|
||||
},
|
||||
});
|
||||
const dlr = await raceWithin(2000, reported);
|
||||
|
||||
assert.ok(dlr, 'a receipt thrown as data_sm is still a receipt');
|
||||
assert.equal(dlr.smsId, smsId);
|
||||
assert.equal(dlr.statusMsg, 'DELIVERED');
|
||||
assert.equal(messages, 0);
|
||||
|
||||
const answered = await delivered;
|
||||
|
||||
assert.ok(answered.pduObj);
|
||||
assert.equal(answered.pduObj.cmdName, 'data_sm_resp');
|
||||
assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
|
||||
});
|
||||
|
||||
// At the SMSC end an inbound data_sm is a submission, so nothing in one reports on our own sends.
|
||||
test('reads a receipt-shaped data_sm submitted to a server as the message it is', async t => {
|
||||
const smpp = await startServer(t);
|
||||
const body = 'id:0199e0f2-2d15-7b83-a4c1-6e90b7d2f345 stat:DELIVRD err:000 text:';
|
||||
const messages: Sms[] = [];
|
||||
const reports: Dlr[] = [];
|
||||
|
||||
smpp.on('session', peer => {
|
||||
peer.on('dlr', dlr => { reports.push(dlr); });
|
||||
peer.on('sms', sms => {
|
||||
messages.push(sms);
|
||||
void sms.sendResp();
|
||||
});
|
||||
});
|
||||
|
||||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const submitted = await session.send({
|
||||
cmdName: 'data_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: { message_payload: { tagValue: Buffer.from(body, 'latin1') } },
|
||||
});
|
||||
|
||||
assert.ok(submitted.pduObj);
|
||||
assert.equal(submitted.pduObj.cmdStatus, 'ESME_ROK');
|
||||
assert.notEqual(paramText(submitted.pduObj.params.message_id), '');
|
||||
assert.equal(messages[0]?.message, body);
|
||||
assert.deepEqual(reports, [], 'an ESME submitting is never the network reporting');
|
||||
});
|
||||
|
||||
// The refusal a submission gets is the one submit_sm_resp defines, whichever command carried it.
|
||||
test('refuses a data_sm segment a server has no room for with the submit code', async t => {
|
||||
// The two addresses are 22 octets, so the 6-octet UDH and its text are what overrun 30.
|
||||
const smpp = await startServer(t, { maxOctets: 30 });
|
||||
const { session } = await connect(t, smpp, { bindType: 'transmitter' });
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const segment = splitMessage('one of two, too big to hold. '.repeat(12), { reference: 0x5C })[0];
|
||||
|
||||
assert.ok(segment);
|
||||
|
||||
const refused = await session.send({
|
||||
cmdName: 'data_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: { message_payload: { tagValue: segment } },
|
||||
});
|
||||
|
||||
assert.ok(refused.pduObj);
|
||||
assert.equal(refused.pduObj.cmdName, 'data_sm_resp');
|
||||
assert.equal(refused.pduObj.cmdStatus, 'ESME_RMSGQFUL');
|
||||
});
|
||||
|
||||
test('reassembles a concatenated message whose segments arrived in message_payload', async t => {
|
||||
const { peer, session } = await inbound(t);
|
||||
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||||
const text = 'A body in the TLV is still numbered by its UDH. '.repeat(6);
|
||||
const segments = splitMessage(text, { reference: 0x3B });
|
||||
|
||||
assert.ok(segments.length > 1, 'the fixture must need more than one segment');
|
||||
|
||||
const answers: PduObject[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const sent = await peer.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
|
||||
short_message: Buffer.alloc(0),
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: { message_payload: { tagValue: segment } },
|
||||
});
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
answers.push(sent.pduObj);
|
||||
}
|
||||
|
||||
const sms = await raceWithin(2000, incoming);
|
||||
|
||||
assert.ok(sms, 'the segments join into one message wherever their bodies were carried');
|
||||
assert.equal(sms.message, text);
|
||||
assert.equal(sms.answeredOnArrival, true);
|
||||
assert.deepEqual(answers.map(answer => answer.cmdStatus), answers.map(() => 'ESME_ROK'));
|
||||
assert.deepEqual(
|
||||
answers.map(answer => paramText(answer.params.message_id)),
|
||||
answers.map(() => ''),
|
||||
'SMPP 3.4 4.6.2 leaves deliver_sm_resp\'s message_id unused, segment by segment too',
|
||||
);
|
||||
assert.deepEqual(await sms.sendResp(), {});
|
||||
});
|
||||
|
||||
test('hands a client a report as a dlr rather than as an sms', async t => {
|
||||
const { peer, session } = await inbound(t);
|
||||
const reported = once<Dlr>(resolve => { session.on('dlr', resolve); });
|
||||
|
||||
Reference in New Issue
Block a user