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
+39 -3
View File
@@ -84,6 +84,7 @@ src/
link-timers.ts LinkTimers: the enquire_link heartbeat and the idle timeout
log.ts SmppLog, the logger contract, and silentLog — the default
message.ts Encoding detection, splitting, bit counting, SMPP date formatting
message-body.ts Where an inbound body is: short_message, or the message_payload TLV
outgoing-requests.ts OutgoingRequests: the gate, the window, the pending map and the retry
pdu.ts pduToObj / objToPdu / pduReturn — synchronous, result-returning
pdu-framer.ts PduFramer: a byte stream cut into complete PDUs
@@ -250,8 +251,8 @@ Grouped by what each one constrains.
- **`acceptsOptionalParams()` and `bindAllows()` are predicates, not chokepoints.** The library's own
senders consult them; `session.send({ tlvs })` is passed through as written, because silently
stripping a caller's explicit TLVs off a deliberately public low-level surface would be worse than
sending them. Only `submit_sm` and `deliver_sm` are policed by bind direction — the only two the
library sends and dispatches by it.
sending them. Only `submit_sm`, `deliver_sm` and `data_sm` are policed by bind direction — the
three the library dispatches by it, of which it sends the first two.
- **`session.sock` is a getter over `PduTransport`.** Reading it is unchanged; assigning it no longer
compiles, which never rewired the handlers and so never worked.
@@ -314,6 +315,41 @@ Grouped by what each one constrains.
MC-vendor-specific values, so an unnameable one keeps its raw `statusId` and leaves `statusMsg` to
the body.
- **A body is read from `message_payload` where `short_message` carries none, and `short_message`
wins where a peer filled both.** Maintainer's call, 2026-09-06, from the Jasmin interoperability
phase: SMPP 3.4 5.3.2.32 makes the TLV the alternative for a body the mandatory field cannot
carry, several SMSCs use it, and Jasmin relays one faithfully — reading `short_message` alone
handed the application an empty message
([interop-tests/findings/03-jasmin.md](interop-tests/findings/03-jasmin.md)). `messageOctets()` is
the single answer to where a body is, so the message path, the reassembler and `dlrFromPdu()`
cannot disagree about it, and `esm_class` still says whether that body starts with a UDH wherever
it was carried, which leaves concatenation reading exactly as before. Filling both contradicts the
spec's own instruction to leave `sm_length` zero, and taking the mandatory field there keeps the
rule purely additive: no PDU that parsed before reads differently now. Rejected: preferring the
TLV, which re-reads every message a peer echoes into both. Rejected: refusing a PDU carrying both,
which discards a message that is almost certainly present twice over, where goal 3 keeps the
traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
any other segment.
- **An inbound `data_sm` stands in for whichever of `submit_sm` and `deliver_sm` its direction makes
it, and none goes out.** Maintainer's call, 2026-09-06, from the Jasmin interoperability phase:
SMPP 3.4 4.7.1 makes it a peer of both that always carries its body in `message_payload`, and
Jasmin's `[dlr-thrower] dlr_pdu = data_sm` throws real receipts on it, which `ESME_RINVCMDID`
dropped with nothing reported to the application at all. Every command but this one names its own
direction, which is why the bind gate and the dispatch never had to be told which end of the link
they are on; `linkEnd` is that fact, and it decides both. At the ESME end an inbound one is a
delivery, so `esm_class` classifies it as it classifies a `deliver_sm`; at the SMSC end it is a
submission and is read as one, because a report about a message this end never sent is goal 2's
wrong answer whatever `esm_class` a peer wrote on it. A concatenated one is answered segment by
segment either way, and 4.7.2 gives `data_sm_resp` a `message_id` where 4.6.2 leaves
`deliver_sm_resp`'s unused, so the answer carries one. `linkEnd` is a field beside `boundAs`
rather than a `SessionOptions` entry, so the code that knows which end this is writes it and
nothing else can contradict what the session then binds as. Rejected: grouping the command with
`deliver_sm` in the gate, which refuses a transmitter-bound ESME's legitimate submission, and with
`submit_sm`, which refuses the receiver-bound delivery this was fixed for. Rejected: sending one —
`send()` reaches the command raw, and an option choosing which command a message goes out on would
be a second spelling of `sendSms()` whose only difference is which peers accept it.
- **A receipt's body is read as octets, and its own `data_coding` never says how.** Maintainer's
call, 2026-09-05 via the SMPPSim interop run: SMPPSim copies the reported message's `data_coding`
onto a receipt whose body it always writes as plain text, and Melrose Labs documents the same
@@ -514,7 +550,7 @@ Grouped by what each one constrains.
and both exits are logged.
- **A reconnect keeps the delivery-receipt merges; everything else the link held is dropped.**
`onDeliverSm()` answers each receipt before the group it belongs to is complete, and `teardown()`
`onDelivery()` answers each receipt before the group it belongs to is complete, and `teardown()`
runs on every path — an idle timeout and a failed rebind, not only `close()` — so clearing the
merges there loses receipts no peer has a reason to send again. They are cleared where the session
is over instead. Inbound segments stay in `teardown()`: an 8-bit concatenation reference is the
+21 -1
View File
@@ -148,6 +148,15 @@ to tell the two apart. `esm_class` is what tells them apart; where it names no m
declares, since SMSCs commonly copy the reported message's onto it. An intermediate delivery
notification is the SMSC reporting as well, not an inbound message.
Where the body sits, and which command carried it, changes none of that. An SMSC that leaves
`sm_length` 0 and puts the body in the `message_payload` TLV — SMPP's way of carrying up to 64 KB,
and the only place a `data_sm` has for one — reads exactly like one that fills `short_message`,
concatenated messages and receipts included. A peer that fills both is read from `short_message`.
`data_sm` itself is a peer of both `deliver_sm` and `submit_sm`, and its direction says which: a
client reads one as a delivery, so a message on it arrives as `sms` and a receipt as `dlr`, while a
`server()` session reads one as the submission it is and always hands it to you as `sms`. Either
way it is answered `data_sm_resp`.
Matching a receipt to a send means comparing `dlr.smsId` against the `smsIds` that `sendSms()`
returned. Some SMSCs write the two in different notations — a hex `message_id` on the
`submit_sm_resp` and a decimal `id:` in the receipt, or one of them zero-padded — and the comparison
@@ -258,6 +267,11 @@ link the session is:
fail with an `err` before anything reaches the wire.
- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one,
is answered `ESME_RINVBNDSTS`.
- A `data_sm` carries a message either way, so which end the session is decides what its bind
forbids: a client refuses one on a transmitter bind, a `server()` session on a receiver bind.
`bindAllows('data_sm')` answers for the direction that reaches this session, since the library
sends none. A `Session` you construct yourself is the ESME end, which is what `client()` builds;
a hand-wired SMSC sets `session.linkEnd = 'smsc'`, as `server()` does.
A `transceiver` bind, the default, carries both. `session.send()` stays a low-level passthrough and
is not checked, so the raw surface can still put whatever a test or a proxy needs on the wire.
@@ -445,7 +459,9 @@ if (isCommand(pduObj, 'submit_sm')) {
```
`params.short_message` is decoded with the PDU's own `data_coding`; `shortMessageOctets` is that
same field exactly as it arrived.
same field exactly as it arrived. Neither holds the body of a PDU that carried it in the
`message_payload` TLV instead, which a `data_sm` always does — `messageOctets(pduObj)` is the one
answer to which of the two the peer used.
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
`types`, and the matching `*ById` maps) and grouped as `defs`.
@@ -510,6 +526,10 @@ have worked around any of these, remove the workaround:
into a hex string and written back as the ASCII of that string, so every one that made a round
trip went out corrupt. They are `Buffer`s in both directions now, so drop any hex encoding of
your own.
- A body carried in the `message_payload` TLV was ignored, so the message arrived empty, and a
`data_sm` was answered `ESME_RINVCMDID`, so a receipt thrown on one was lost with nothing said.
Both reach the application now — a receipt as `dlr`, answered for you, and a message as `sms` for
you to answer like any other.
- Short or malformed PDUs threw out of the codec instead of being reported as a parse failure.
- Binds now declare `interface_version` 0x34. 0.4.0 declared 0x00, which tells the SMSC the ESME
speaks SMPP 3.3 or earlier — and a spec-following SMSC then withholds every optional parameter,
+18
View File
@@ -99,6 +99,13 @@ send it over the connector's session, and inspect the `sms` event at a client bo
**Severity.** Medium: silent data loss, not a wire error - the message is fully present on the wire
and Jasmin forwards it faithfully; only this library's read of it is incomplete.
**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): the body is read from
`message_payload` where `short_message` carries none. The relay is confirmed on the wire - the
capture's one `sm_length` 0 `deliver_sm` is Jasmin's own, out of `smpps` to our client, carrying the
`0x0424` TLV intact. The reproducer's own fixture was wrong as well as the library: it built the TLV
as Latin-1 while the PDU declared `data_coding` 0, so `_` (GSM 03.38 0x11, Latin-1 0x5F) came back
as `§` even once the body was read. It now encodes the payload the way the PDU says it is written.
### `sar_*`/UDH segmentation from an upstream SMSC reassembles fine (target 3, MO direction) - not reproduced as a defect here
C8's SAR and UDH MO pushes both reassembled into one whole `sms` at our real client. This does not
@@ -121,6 +128,12 @@ Our client's `incomingPduObj` sees it arrive; no `dlr` event ever fires. The rec
**Severity.** Medium: a real, documented Jasmin configuration (`dlr_pdu = data_sm`) that a receipt
depends on silently drops delivery reports, with no error surfaced to the application either.
**Fixed** in [#84](https://github.com/larvit/larvitsmpp/pull/84): `data_sm` is classified and
answered exactly as `deliver_sm` is, and the receipt reaches `dlr` naming the id the `submit_sm_resp`
carried, `DELIVERED`. The wire histogram still shows no `data_sm`: `capture` runs
`network_mode: service:jasmin`, so it only ever sees the main instance's namespace, never
`jasmin-datasm`'s.
### A multi-part MT send deadlocks against Jasmin's serialized per-connector relay - a library/peer interaction, not a wire defect
**What happened.** A 2-, 3-, 10-segment GSM or UCS-2 message sent through Jasmin's `smpps` (our real
@@ -215,6 +228,11 @@ arrives, with `<base>-<n>` off an id the group is opened with. All four multi-se
- Whether the deadlock above is specific to a message requesting a receipt (`registered_delivery`
set) or would also occur for a plain multi-segment send with no `dlr` - not isolated separately,
since every `C3+C7` case here requests one.
- Whether a real peer accepts a `data_sm_resp` carrying a `message_id`. SMPP 3.4 4.7.2 defines the
field, unlike `deliver_sm_resp`'s, and this library fills it when a `data_sm` carried a message -
but Jasmin only ever sends one as a receipt, which is answered with the field empty, so the filled
case has met no peer. Jasmin FINing over a `deliver_sm_resp` that carried one is the nearest
precedent there is.
- Whether Jasmin, given an *upstream* connector that itself defaults to SAR (rather than our
library's own UDH), would relay an MT message using SAR instead of preserving our UDH bytes - the
120s-timeout deadlock always intervened before a second segment could be observed on the wire in
+16 -11
View File
@@ -11,7 +11,7 @@ import { client } from '../src/client.ts';
import { closeAfter } from '../test/teardown.ts';
import { paramText } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import { splitMessage } from '../src/message.ts';
import { encodeMessage, splitMessage } from '../src/message.ts';
import { submitSmParams } from '../src/send-sms.ts';
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
@@ -246,7 +246,8 @@ async function sendMessagePayloadMo(session: Session, opts: { from: string; mess
source_addr: opts.from,
},
tlvs: {
message_payload: { tagValue: Buffer.from(opts.message, 'latin1') },
// The body is octets under the PDU's own data_coding wherever it is carried, and 0 is GSM.
message_payload: { tagValue: encodeMessage(opts.message, 'ASCII').buffer },
},
});
}
@@ -565,20 +566,20 @@ describe('C8 (target 2) - message_payload with sm_length 0', () => {
assert.equal(pushed.err, undefined, 'expected Jasmin to accept a message_payload-only deliver_sm from its connector');
const arrived = await waitFor(() => sms.find(s => s.message === ''), 5000);
const arrived = await waitFor(() => sms.find(s => s.message === text), 5000);
await session.close({ signal: AbortSignal.abort() });
// Confirmed (target 2): Jasmin relays message_payload faithfully (sm_length 0, the real text
// in the TLV) - our own incoming-requests.ts reads short_message only, so the sms that arrives
// here has the right envelope (from/to) but an empty message, never the text carried in the
// TLV. See findings/03-jasmin.md for the reproducer.
assert.ok(arrived, 'expected an sms to arrive (with an empty message, per target 2) for the message_payload push');
// Jasmin relays message_payload faithfully (sm_length 0, the real text in the TLV), so the
// whole body has to reach the application from there.
assert.ok(arrived, 'expected the message_payload body to arrive as the sms text');
assert.equal(arrived.from, TO);
assert.equal(arrived.to, FROM);
});
});
describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => {
test('a receipt thrown as data_sm is not read as a dlr; the raw PDU still arrives', async t => {
test('a receipt thrown as data_sm reaches the dlr event', async t => {
await waitForUpstreamSession('datasm');
const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
@@ -597,12 +598,16 @@ describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', ()
assert.equal(sent.err, undefined);
const [smsId] = sent.smsIds;
const arrived = await waitFor(() => incomingDataSm[0], 15_000);
assert.ok(smsId);
assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)');
await delay(2000);
assert.deepEqual(dlrs, [], 'data_sm is answered ESME_RINVCMDID and never reaches the dlr event (target 4)');
const dlr = await waitFor(() => dlrs.find(one => one.smsId === smsId), 10_000);
assert.ok(dlr, `no dlr for the receipt Jasmin threw as data_sm, id ${smsId}`);
assert.equal(dlr.statusMsg, 'DELIVERED');
});
});
+3 -2
View File
@@ -4,6 +4,7 @@ import type { PduObject } from './pdu.ts';
import type { SmsIdFormat } from './sms-id.ts';
import { consts, constsById, hasUdh, messageTypeOf } from './defs/constants.ts';
import { encodings } from './defs/encodings.ts';
import { messageOctets } from './message-body.ts';
import { normaliseSmsId } from './sms-id.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { udhLength } from './udh.ts';
@@ -157,7 +158,7 @@ function messageType(pduObj: PduObject): MessageType {
/** SMPP 3.4 Appendix B makes a receipt fixed text, so its octets are read as octets, not decoded. */
function receiptBody(pduObj: PduObject): string {
const octets = pduObj.shortMessageOctets;
const octets = messageOctets(pduObj);
if (octets === undefined) return paramText(pduObj.params.short_message);
@@ -203,7 +204,7 @@ function receiptStatus(
return { statusId: tlvState, statusMsg: isMessageState(named) ? named : scraped };
}
/** The delivery report a deliver_sm carries, or nothing when it carries a message instead. */
/** The delivery report a deliver_sm or data_sm carries, or nothing where it carries a message. */
export function dlrFromPdu(pduObj: PduObject, format: SmsIdFormat = {}): Dlr | undefined {
const type = messageType(pduObj);
+24 -9
View File
@@ -1,4 +1,3 @@
import type { CommandName } from './defs/commands.ts';
import type { DlrMerger } from './dlr-merger.ts';
import type { ErrorName } from './defs/errors.ts';
import type { LostGroup, Refusal } from './reassembly.ts';
@@ -10,19 +9,20 @@ import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts';
import { HeldMessages } from './held-messages.ts';
import { Reassembler, decodeSegments } from './reassembly.ts';
import { bindCommands, defaults } from './session-options.ts';
import { bindCommands, defaults, standsInFor } from './session-options.ts';
import { concatInfo } from './udh.ts';
import { hasUdh } from './defs/constants.ts';
import { createSms } from './sms.ts';
import { dlrFromPdu } from './dlr.ts';
import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { respIdParams, segmentId } from './sms-id.ts';
/** SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another. */
export function refusedSegmentStatus(cmdName: CommandName, refusal: Refusal): ErrorName {
export function refusedSegmentStatus(carriedAs: string, refusal: Refusal): ErrorName {
if (refusal === 'unplaceable') return 'ESME_RINVESMCLASS';
return cmdName === 'deliver_sm' ? 'ESME_RX_T_APPN' : 'ESME_RMSGQFUL';
return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN';
}
const lostReasons: Record<LostGroup['reason'], string> = {
@@ -104,9 +104,17 @@ export class IncomingRequests {
return;
}
await this.route(pduObj);
}
private async route(pduObj: PduObject): Promise<void> {
switch (pduObj.cmdName) {
case 'data_sm':
case 'deliver_sm':
await this.onDeliverSm(pduObj);
// A data_sm at the SMSC end is a submission, and a submission is never a report.
await (this.carriedAs(pduObj) === 'submit_sm'
? this.onMessage(pduObj)
: this.onDelivery(pduObj));
break;
case 'enquire_link':
await this.session.sendReturn(pduObj);
@@ -161,8 +169,12 @@ export class IncomingRequests {
await this.session.sendReturn(pduObj, 'ESME_RINVCMDID');
}
private carriedAs(pduObj: PduObject): string {
return standsInFor(pduObj.cmdName, this.session.linkEnd);
}
/** SMPP carries a mobile-originated message and a delivery receipt on the same command. */
private async onDeliverSm(pduObj: PduObject): Promise<void> {
private async onDelivery(pduObj: PduObject): Promise<void> {
const dlr = dlrFromPdu(pduObj, this.smsIdFormat);
if (!dlr) {
@@ -185,9 +197,9 @@ export class IncomingRequests {
* one request at a time never sends the second segment until the first has been answered.
*/
private async onMessage(pduObj: PduObject): Promise<void> {
const message = pduObj.params.short_message;
const message = messageOctets(pduObj);
const carriesUdh = hasUdh(paramNumber(pduObj.params.esm_class, 0));
const concat = carriesUdh && Buffer.isBuffer(message) ? concatInfo(message) : undefined;
const concat = carriesUdh && message ? concatInfo(message) : undefined;
if (!concat) {
this.emitSms([pduObj]);
@@ -198,7 +210,10 @@ export class IncomingRequests {
const collected = this.reassembler.collect(pduObj, concat);
if (!collected.kept) {
await this.session.sendReturn(pduObj, refusedSegmentStatus(pduObj.cmdName, collected.refusal));
await this.session.sendReturn(
pduObj,
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal),
);
return;
}
+1
View File
@@ -30,6 +30,7 @@ export {
} from './message.ts';
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
export { messageOctets } from './message-body.ts';
export { concatInfo } from './udh.ts';
export { PduFramer } from './pdu-framer.ts';
export { uuidv7 } from './uuid.ts';
+17
View File
@@ -0,0 +1,17 @@
import type { PduObject } from './pdu.ts';
/**
* The user data, wherever the peer put it. SMPP 3.4 5.3.2.32 carries up to 64 KB in
* `message_payload` with `sm_length` 0, and a `data_sm` has nowhere else to put a body at all.
* A filled `short_message` is the peer using the mandatory field, so it wins over a TLV the spec
* only allows in its place.
*/
export function messageOctets(pduObj: PduObject): Buffer | undefined {
const shortMessage = pduObj.shortMessageOctets;
if (shortMessage !== undefined && shortMessage.length > 0) return shortMessage;
const payload = pduObj.tlvs.message_payload?.tagValue;
return Buffer.isBuffer(payload) ? payload : shortMessage;
}
+4 -1
View File
@@ -45,7 +45,10 @@ export type PduObject = {
cmdStatusId: number;
params: Record<string, ParamValue>;
seqNr: number;
/** short_message as it arrived, whatever data_coding turned `params.short_message` into. */
/**
* short_message as it arrived, whatever data_coding turned `params.short_message` into. A body
* the peer put in `message_payload` is not here; `messageOctets()` is what reads either.
*/
shortMessageOctets: Buffer | undefined;
tlvs: Record<string, Tlv>;
};
+9 -8
View File
@@ -5,6 +5,7 @@ import type { SmppLog } from './log.ts';
import type { Tlv } from './defs/tlvs.ts';
import { ExpiringGroups } from './expiring-groups.ts';
import { decodeMessage } from './message.ts';
import { messageOctets } from './message-body.ts';
import { paramNumber, paramText } from './defs/types.ts';
import { uuidv7 } from './uuid.ts';
@@ -108,15 +109,15 @@ export function decodeSegments(pduObjs: PduObject[]): string {
let message = '';
for (const pduObj of pduObjs) {
const part = pduObj.params.short_message;
const part = messageOctets(pduObj);
message += Buffer.isBuffer(part)
? decodeMessage(
part,
paramNumber(pduObj.params.data_coding, 0),
paramNumber(pduObj.params.esm_class, 0),
).message
: paramText(part);
if (part === undefined) continue;
message += decodeMessage(
part,
paramNumber(pduObj.params.data_coding, 0),
paramNumber(pduObj.params.esm_class, 0),
).message;
}
return message;
+1
View File
@@ -241,6 +241,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
systemId: options.systemId ?? defaults.systemId,
});
session.linkEnd = 'smsc';
server.sessions.add(session);
session.on('close', () => server.sessions.delete(session));
+22 -3
View File
@@ -32,6 +32,9 @@ export const bindCommands: readonly string[] = [
export type BindType = 'receiver' | 'transceiver' | 'transmitter';
/** Which end of the link a session is. Only `server()` is the SMSC; everything else is the ESME. */
export type LinkEnd = 'esme' | 'smsc';
export function bindTypeFromCommand(cmdName: string): BindType | undefined {
if (cmdName === 'bind_receiver') return 'receiver';
if (cmdName === 'bind_transceiver') return 'transceiver';
@@ -40,14 +43,30 @@ export function bindTypeFromCommand(cmdName: string): BindType | undefined {
return undefined;
}
/**
* Which message-carrying command an inbound one stands in for. Every command but `data_sm` names
* its own direction; that one travels either way, so the end it arrived at is what says.
*/
export function standsInFor(cmdName: string, linkEnd: LinkEnd): string {
if (cmdName !== 'data_sm') return cmdName;
return linkEnd === 'smsc' ? 'submit_sm' : 'deliver_sm';
}
/**
* Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a
* transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that
* has not bound carries everything, since nothing has declared a direction yet.
*/
export function bindCarries(bindType: BindType | undefined, cmdName: string): boolean {
if (bindType === 'receiver') return cmdName !== 'submit_sm';
if (bindType === 'transmitter') return cmdName !== 'deliver_sm';
export function bindCarries(
bindType: BindType | undefined,
cmdName: string,
linkEnd: LinkEnd,
): boolean {
const carried = standsInFor(cmdName, linkEnd);
if (bindType === 'receiver') return carried !== 'submit_sm';
if (bindType === 'transmitter') return carried !== 'deliver_sm';
return true;
}
+4 -2
View File
@@ -3,7 +3,7 @@ import type { MessageDlr } from './dlr-merger.ts';
import type { ParamValue } from './defs/types.ts';
import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts';
import type { PduRefusedError } from './pdu-refusal.ts';
import type { BindType, CloseOptions, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { BindType, CloseOptions, LinkEnd, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts';
import type { Result, VoidResult } from './result.ts';
import type { SendSmsOptions, SendSmsResult } from './send-sms.ts';
import type { SmppLog } from './log.ts';
@@ -54,6 +54,8 @@ export class Session extends EventEmitter<SessionEvents> {
/** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */
boundAs: BindType | undefined = undefined;
/** Which end of the link this is. `server()` sets it; a hand-wired SMSC must set it too. */
linkEnd: LinkEnd = 'esme';
loggedIn = false;
/** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */
peerInterfaceVersion: number | undefined = undefined;
@@ -154,7 +156,7 @@ export class Session extends EventEmitter<SessionEvents> {
/** Whether this session's bind direction carries a command. Consulted by the library's senders. */
bindAllows(cmdName: string): boolean {
return bindCarries(this.boundAs, cmdName);
return bindCarries(this.boundAs, cmdName, this.linkEnd);
}
/** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */
+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); });