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:
2026-09-06 05:59:27 +02:00
committed by GitHub
parent 5b7b563dc2
commit 184d1dc7af
17 changed files with 552 additions and 41 deletions
+45
View File
@@ -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));
+37
View File
@@ -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.
+46 -1
View File
@@ -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', () => {
+245
View File
@@ -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); });