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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
};
|
||||
|
||||
+6
-5
@@ -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(
|
||||
if (part === undefined) continue;
|
||||
|
||||
message += decodeMessage(
|
||||
part,
|
||||
paramNumber(pduObj.params.data_coding, 0),
|
||||
paramNumber(pduObj.params.esm_class, 0),
|
||||
).message
|
||||
: paramText(part);
|
||||
).message;
|
||||
}
|
||||
|
||||
return message;
|
||||
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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