Reassemble a concatenated message segmented with the sar_* TLVs (#91)
* Regression tests for inbound sar_* concatenation * Reassemble a concatenated message segmented with the sar_* TLVs * Record the sar_* fix in the jasmin and Java-client findings * Name the sar_* TLVs in the refusal, and separate group keys the addresses cannot forge * Name the field a refused segment got wrong, and key groups on a separator no address holds * Bound the new session tests, and derive Concat from the UDH fields
This commit is contained in:
@@ -73,6 +73,7 @@ src/
|
||||
server.ts server() -> { err, server }, server owns the listener + close()
|
||||
session.ts Session: the socket's life, dispatch, events, and the collaborators below
|
||||
sms.ts The live handle emitted as the 'sms' event (sendResp/sendDlr)
|
||||
concat.ts How a PDU says it is a segment: its UDH, or the sar_* TLVs
|
||||
dlr.ts Delivery receipts: text and TLV parsing, receipt status codes
|
||||
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
|
||||
error-from.ts errorFrom(): whatever was thrown or rejected, as an Error
|
||||
@@ -156,6 +157,7 @@ naming the behaviour.
|
||||
| Alphanumeric sender TON | `sendSms` hardcodes `source_addr_ton` to 1 (international) even for alphanumeric senders, which require TON 5 |
|
||||
| Text-only DLRs refused | `deliver_sm` without both `message_state` and `receipted_message_id` TLVs is rejected with `ESME_RINVTLVSTREAM`, so Kannel-style receipts are unusable |
|
||||
| Unbounded reassembly | Incomplete long-SMS groups are capped by nothing and swept only when other traffic arrives, after 24 hours |
|
||||
| `sar_*` segmentation unread | `session.js` reassembles on the UDH alone, so a message segmented with `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` — SMPP 3.4's other spelling, and Jasmin's documented default — reaches the application one fragment per segment |
|
||||
| Dead DLR aggregation | `longSmsDlrs` is allocated to merge per-segment receipts and then never used |
|
||||
| Trailing NULL truncation | `types.buffer.size()` subtracts one whenever the value's last octet is `0x00`, so the PDU is allocated one octet short while `sm_length` still reports the full length. Any UCS2 message ending in a character like U+4E00 or U+3000 goes out corrupt |
|
||||
| Dormant filters | `defs.filters` is declared on commands and TLVs but never invoked anywhere |
|
||||
@@ -330,6 +332,33 @@ Grouped by what each one constrains.
|
||||
traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
|
||||
any other segment.
|
||||
|
||||
- **A segment's concatenation is read from its UDH, or from the `sar_*` TLVs where it declares none,
|
||||
and each spelling groups in a reference space of its own.** Maintainer's call, 2026-09-06, from
|
||||
the Jasmin and Java-client interoperability phases: SMPP 3.4 5.3.2.31-5.3.2.33 make
|
||||
`sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` the other way to say what a UDH says,
|
||||
Jasmin documents it as its own segmentation and jsmpp writes it, and reading the UDH alone handed
|
||||
the application one `sms` per fragment
|
||||
([interop-tests/findings/05-java-clients.md](interop-tests/findings/05-java-clients.md)).
|
||||
`concatOf()` is the single answer to how a PDU says it is a segment, as `messageOctets()` is to
|
||||
where a body is, and both are exported for the same reason: an application on the low-level
|
||||
surfaces would otherwise rewrite the read this fixed. It carries the spelling beside the
|
||||
reference, so the key is two tokens the reassembler joins and interprets neither of, and so the
|
||||
refusal can name the field the peer got wrong — `ESME_RINVESMCLASS` for a UDH, `ESME_RINVTLVVAL`
|
||||
for the TLVs, whose segment's `esm_class` is 0x00 and correct. Keying them together instead would
|
||||
assemble two of a peer's messages into one, since a UDH reference is 8 bits and `sar_msg_ref_num`
|
||||
is 16 and neither counts the other's messages; the two UDH widths share a space because they are
|
||||
one sender's counter in one layer, where a `sar_*` reference is another layer's. A UDH that names
|
||||
the concatenation wins over the TLVs — one carrying only a port leaves them to say — which keeps
|
||||
the change additive for every message that reassembled before, and leaves the library nothing to
|
||||
guess where the two disagree. Rejected: preferring the TLVs, which regroups every message a
|
||||
gateway derived them from. Rejected: comparing the parts and reporting a disagreement: the
|
||||
references are not comparable at all, and where the parts are, the UDH is still what the message
|
||||
is assembled by, so the report would name a failure the application cannot act on. Accepted: a
|
||||
peer that switches spelling mid-message now has two groups that expire rather than fragments that
|
||||
arrive, which goal 2 prefers to a message assembled from two counters. Receive-only: `sendSms()`
|
||||
goes on writing a UDH with an 8-bit reference, where a send-side `sar_*` would be a second
|
||||
spelling of one message whose only difference is which peers accept it.
|
||||
|
||||
- **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
|
||||
@@ -516,8 +545,8 @@ Grouped by what each one constrains.
|
||||
untouched, and is where a caller-chosen id and a refusal live; `onRequest` is the escape hatch for
|
||||
an application that must refuse a PDU the `sms` event could not have shown it yet. `collect()`
|
||||
answers every segment it will not carry rather than leaving it unanswered, which is the same stall
|
||||
in miniature: `ESME_RINVESMCLASS` where the UDH belongs to no group, `ESME_RMSGQFUL` where the
|
||||
segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
|
||||
in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL`
|
||||
where the segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
|
||||
answering every segment but the one that completes the group, which leaves the peer holding some
|
||||
segments accepted and one refused with nothing in SMPP to retract the rest, and still cannot honour
|
||||
a caller's `smsId` on the segments already gone. Rejected: a hook that mints the id per segment,
|
||||
@@ -608,7 +637,7 @@ Grouped by what each one constrains.
|
||||
`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
|
||||
is over instead. Inbound segments stay in `teardown()`: a concatenation reference is the
|
||||
peer's own counter, so a half-arrived group kept across a drop would take a later message's
|
||||
segments as readily as the rest of its own, and goal 2 will not hand the application a message
|
||||
assembled that way. What goes there is traffic already answered, which is why each group reaches
|
||||
|
||||
@@ -159,6 +159,12 @@ client reads one as a delivery, so a message on it arrives as `sms` and a receip
|
||||
`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`.
|
||||
|
||||
Nor does the way a peer ties a long message's segments together. A user data header at the start of
|
||||
the body and the `sar_msg_ref_num`, `sar_total_segments` and `sar_segment_seqnum` TLVs are the two
|
||||
spellings of the same thing, and either reassembles into one `sms`. A header that numbers the
|
||||
segment is what a PDU carrying both is read from, and the two reference numbers are counters of
|
||||
their own — the same number in each is two different messages.
|
||||
|
||||
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
|
||||
@@ -228,8 +234,8 @@ await smpp.close(); // stop listening, then drain and close every live sess
|
||||
|
||||
A message that arrived in several segments was already answered when you see it: each segment is
|
||||
answered as it lands, because a relaying SMSC will not send the next one until the last is answered.
|
||||
That answer is `ESME_ROK` unless the segment's header names no message this session can join, which
|
||||
refuses it, or the reassembly buffer is full, which asks the SMSC to keep it and try again.
|
||||
That answer is `ESME_ROK` unless the segment numbers itself into no message this session can join,
|
||||
which refuses it, or the reassembly buffer is full, which asks the SMSC to keep it and try again.
|
||||
`sms.answeredOnArrival` says whether the message you are holding was answered that way — a segment count cannot, since a peer
|
||||
may number a concatenated message one part of one. The id was fixed with the first segment, so
|
||||
`sendResp()` there only says you are done with the message, and returns an `err` for an `smsId` or a
|
||||
@@ -507,7 +513,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. 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.
|
||||
answer to which of the two the peer used. `concatOf(pduObj)` is the same for concatenation: the
|
||||
`part`, `total` and `reference` a PDU declares, and the `spelling` — `'udh'` or `'sar'` — that
|
||||
carried them, or `undefined` where the PDU is a whole message.
|
||||
|
||||
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
||||
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
||||
@@ -576,6 +584,9 @@ have worked around any of these, remove the workaround:
|
||||
`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.
|
||||
- A long message whose segments were tied together by the `sar_msg_ref_num`, `sar_total_segments`
|
||||
and `sar_segment_seqnum` TLVs rather than by a user data header was never reassembled, so each
|
||||
segment arrived as its own message. Both spellings reassemble now.
|
||||
- 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,
|
||||
|
||||
@@ -115,6 +115,11 @@ sent SAR MO unprompted), it confirms only that pushing SAR/UDH-tagged `deliver_s
|
||||
directly over the connector session, reassembles correctly on the way out through `smpps` - Jasmin
|
||||
does not re-segment or otherwise disturb an already-short single PDU in transit.
|
||||
|
||||
**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91), where a second peer did reproduce
|
||||
it (`findings/05-java-clients.md`): reassembly now reads the `sar_*` TLVs as well as the UDH. C8's
|
||||
SAR scenario no longer accepts fragments as an outcome - it asserts one whole `sms` and no segment
|
||||
arriving on its own - and a rerun is 19/19 with no malformed frame and no expert error.
|
||||
|
||||
### `data_sm` is refused (target 4) - confirmed against a real peer
|
||||
|
||||
**What happened.** With `jasmin-datasm`'s `[dlr-thrower] dlr_pdu = data_sm`, a receipt requested via
|
||||
|
||||
@@ -75,7 +75,7 @@ an independent dissector agreeing is the expected outcome, not a surprise.
|
||||
| S2 UDH 8-bit (targets 2, 5) | pass | `jsmpp.test.ts` "UDH, 8-bit reference": one reassembled `sms`, segments answered `<base>-1`/`<base>-2` |
|
||||
| S2 UDH 16-bit (target 5) | pass | `jsmpp.test.ts` "UDH, 16-bit reference": also reassembled - confirms both widths are read |
|
||||
| S2 `message_payload` (target 2) | pass | `jsmpp.test.ts` "message_payload: one sms, the full text" |
|
||||
| S2 `sar_*` (target 3) | defect confirmed, second peer | `jsmpp.test.ts` "sar_*: defect (target 3)": two independent `sms` events, not one |
|
||||
| S2 `sar_*` (target 3) | defect confirmed, second peer; fixed in [#91](https://github.com/larvit/larvitsmpp/pull/91) | `jsmpp.test.ts` "sar_* (target 3)": one reassembled `sms`, segments answered `<base>-1`/`<base>-2` |
|
||||
| S3 known-but-unhandled (targets 1, 6) | pass | `jsmpp.test.ts` "query_sm, cancel_sm, replace_sm": `ESME_RINVCMDID`, link survives, jsmpp raises `NegativeResponseException` and keeps going |
|
||||
| S3 unknown command id (target 1) | pass | `jsmpp.test.ts` "an unknown command id gets generic_nack..." |
|
||||
| S3 truncated TLV stream (target 1) | pass | `jsmpp.test.ts` "a deliver_sm with a truncated TLV stream..." |
|
||||
@@ -100,11 +100,19 @@ Spec: SMPP 3.4 5.3.2.16-5.3.2.18 defines `sar_msg_ref_num`/`sar_total_segments`/
|
||||
as an alternative to the UDH for carrying concatenation; nothing in the spec says a receiver may
|
||||
ignore it.
|
||||
|
||||
Reproducer: `jsmpp.test.ts`, "sar_\*: defect (target 3)" - two `submit_sm`s to the same
|
||||
Reproducer: `jsmpp.test.ts`, "sar_\* (target 3)" - two `submit_sm`s to the same
|
||||
`source_addr`/`destination_addr`, `esm_class` 0x00, one `sar_msg_ref_num` (0x77) across both, `1/2`
|
||||
then `2/2` in `sar_total_segments`/`sar_segment_seqnum`. Severity: as already scoped in PLAN.md
|
||||
target 3 / phase 10 - a known, tracked limitation, not new.
|
||||
|
||||
**Fixed** in [#91](https://github.com/larvit/larvitsmpp/pull/91): `concatOf()` reads the
|
||||
concatenation from the UDH, or from the `sar_*` TLVs where the PDU declares none, and each spelling
|
||||
groups in a reference space of its own. The reproducer now asserts what a rerun shows - one `sms`
|
||||
carrying the whole 200-char text, its two `submit_sm`s answered `<base>-1` and `<base>-2`, and
|
||||
neither half ever reaching the application on its own. The rest of the suite is unchanged: 12/12,
|
||||
frames 37, `submit_sm` 8/8, malformed 2 and expert errors 2 - the two deliberately malformed PDUs
|
||||
the S3 scenarios send.
|
||||
|
||||
### A 4-octet truncated TLV tail is silently accepted rather than refused (target 1)
|
||||
|
||||
What happened: a `deliver_sm` whose mandatory fields are complete, followed by exactly one bare TLV
|
||||
|
||||
@@ -514,15 +514,13 @@ describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation
|
||||
|
||||
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||
|
||||
// Recorded either way, per the task: one whole `sms` (Jasmin reassembled the SAR segments
|
||||
// before forwarding) or several fragments (it relayed them, and our SAR-blind reassembler -
|
||||
// keyed on UDH only - never groups them; see findings for which happened and the reproducer).
|
||||
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||
const fragments = sms.filter(s => text.includes(s.message) && s.message !== '');
|
||||
const fragments = sms.filter(s => s.message !== text && text.includes(s.message) && s.message !== '');
|
||||
|
||||
await session.close({ signal: AbortSignal.abort() });
|
||||
|
||||
assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or SAR fragments to arrive');
|
||||
assert.ok(whole, 'expected the SAR segments to reassemble into one whole sms');
|
||||
assert.deepEqual(fragments.map(s => s.message), [], 'no segment reaches the application on its own');
|
||||
});
|
||||
|
||||
test('UDH-segmented deliver_sm from the fake upstream', async () => {
|
||||
|
||||
@@ -173,7 +173,7 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => {
|
||||
assert.equal(sms.answeredOnArrival, false);
|
||||
});
|
||||
|
||||
test('sar_*: defect (target 3) - each segment reaches the application as its own sms, not one', async () => {
|
||||
test('sar_* (target 3): one reassembled sms, each segment answered <base>-<n>', async () => {
|
||||
await waitForSessions(1);
|
||||
|
||||
const text = 'sar-'.padEnd(200, 'c');
|
||||
@@ -184,17 +184,13 @@ describe('S2 - long messages in every spelling (targets 2, 3, 5)', () => {
|
||||
|
||||
assert.equal(segments.length, 2);
|
||||
|
||||
// Neither segment ever arrives as the whole 200-char text: each is its own ordinary sms
|
||||
// carrying only its own ~130-char slice, with its own unrelated (non-<base>-<n>) message id.
|
||||
const first = await waitForSms(text.slice(0, 130), 15_000);
|
||||
const second = await waitForSms(text.slice(130), 15_000);
|
||||
const sms = await waitForSms(text, 15_000);
|
||||
|
||||
assert.equal(first.answeredOnArrival, false);
|
||||
assert.equal(second.answeredOnArrival, false);
|
||||
assert.notEqual(first.smsId, second.smsId);
|
||||
assert.equal(allSms.some(entry => entry.sms.message === text), false);
|
||||
void first;
|
||||
void second;
|
||||
assert.equal(sms.answeredOnArrival, true);
|
||||
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
|
||||
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
|
||||
// Neither ~130-char slice ever reached the application on its own.
|
||||
assert.equal(allSms.filter(entry => text.includes(entry.sms.message)).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ConcatInfo } from './udh.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import { concatInfo } from './udh.ts';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { messageOctets } from './message-body.ts';
|
||||
import { paramNumber } from './defs/types.ts';
|
||||
|
||||
/** Where a segment sits in its message, and what ties it to the rest of that message. */
|
||||
export type Concat = ConcatInfo & {
|
||||
/** Which of the two carried the numbering: their references are counters of their own. */
|
||||
spelling: 'sar' | 'udh';
|
||||
};
|
||||
|
||||
function sarConcat(pduObj: PduObject): Concat | undefined {
|
||||
const reference = pduObj.tlvs.sar_msg_ref_num?.tagValue;
|
||||
const part = pduObj.tlvs.sar_segment_seqnum?.tagValue;
|
||||
const total = pduObj.tlvs.sar_total_segments?.tagValue;
|
||||
|
||||
if (typeof reference !== 'number' || typeof part !== 'number' || typeof total !== 'number') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { part, reference, spelling: 'sar', total };
|
||||
}
|
||||
|
||||
/**
|
||||
* How a PDU says it is one segment of a longer message: the UDH its body starts with, or the
|
||||
* sar_* TLVs SMPP 3.4 5.3.2.31-5.3.2.33 define in its place. A UDH that names the concatenation
|
||||
* wins; one carrying only a port or a language indicator leaves the TLVs to say.
|
||||
*/
|
||||
export function concatOf(pduObj: PduObject): Concat | undefined {
|
||||
const body = messageOctets(pduObj);
|
||||
const udh = body !== undefined && hasUdh(paramNumber(pduObj.params.esm_class, 0))
|
||||
? concatInfo(body)
|
||||
: undefined;
|
||||
|
||||
if (udh) return { ...udh, spelling: 'udh' };
|
||||
|
||||
return sarConcat(pduObj);
|
||||
}
|
||||
+14
-10
@@ -1,3 +1,4 @@
|
||||
import type { Concat } from './concat.ts';
|
||||
import type { DlrMerger } from './dlr-merger.ts';
|
||||
import type { ErrorName } from './defs/errors.ts';
|
||||
import type { LostGroup, Refusal } from './reassembly.ts';
|
||||
@@ -10,17 +11,22 @@ import type { SmsIdFormat } from './sms-id.ts';
|
||||
import { HeldMessages } from './held-messages.ts';
|
||||
import { Reassembler, decodeSegments } from './reassembly.ts';
|
||||
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
||||
import { concatInfo } from './udh.ts';
|
||||
import { hasUdh } from './defs/constants.ts';
|
||||
import { concatOf } from './concat.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 { 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(carriedAs: string, refusal: Refusal): ErrorName {
|
||||
if (refusal === 'unplaceable') return 'ESME_RINVESMCLASS';
|
||||
export function refusedSegmentStatus(
|
||||
carriedAs: string,
|
||||
refusal: Refusal,
|
||||
spelling: Concat['spelling'],
|
||||
): ErrorName {
|
||||
// A sar_* segment's esm_class is 0x00 and correct: naming it would name the part the peer got right.
|
||||
if (refusal === 'unplaceable') {
|
||||
return spelling === 'sar' ? 'ESME_RINVTLVVAL' : 'ESME_RINVESMCLASS';
|
||||
}
|
||||
|
||||
return carriedAs === 'submit_sm' ? 'ESME_RMSGQFUL' : 'ESME_RX_T_APPN';
|
||||
}
|
||||
@@ -197,9 +203,7 @@ 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 = messageOctets(pduObj);
|
||||
const carriesUdh = hasUdh(paramNumber(pduObj.params.esm_class, 0));
|
||||
const concat = carriesUdh && message ? concatInfo(message) : undefined;
|
||||
const concat = concatOf(pduObj);
|
||||
|
||||
if (!concat) {
|
||||
this.emitSms([pduObj]);
|
||||
@@ -212,7 +216,7 @@ export class IncomingRequests {
|
||||
if (!collected.kept) {
|
||||
await this.session.sendReturn(
|
||||
pduObj,
|
||||
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal),
|
||||
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling),
|
||||
);
|
||||
|
||||
return;
|
||||
|
||||
@@ -31,6 +31,7 @@ export {
|
||||
|
||||
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
|
||||
export { messageOctets } from './message-body.ts';
|
||||
export { concatOf } from './concat.ts';
|
||||
export { concatInfo } from './udh.ts';
|
||||
export { PduFramer } from './pdu-framer.ts';
|
||||
export { uuidv7 } from './uuid.ts';
|
||||
@@ -38,6 +39,7 @@ export { uuidv7 } from './uuid.ts';
|
||||
export type { BindType, ClientOptions } from './client.ts';
|
||||
export type { Dlr, Receipt } from './dlr.ts';
|
||||
export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts';
|
||||
export type { Concat } from './concat.ts';
|
||||
export type { ConcatInfo } from './udh.ts';
|
||||
export type { Result, VoidResult } from './result.ts';
|
||||
export type { SmppLog } from './log.ts';
|
||||
|
||||
+12
-10
@@ -1,4 +1,4 @@
|
||||
import type { ConcatInfo } from './udh.ts';
|
||||
import type { Concat } from './concat.ts';
|
||||
import type { ParamValue } from './defs/types.ts';
|
||||
import type { PduObject } from './pdu.ts';
|
||||
import type { SmppLog } from './log.ts';
|
||||
@@ -96,12 +96,14 @@ function octetsOf(pduObj: PduObject): number {
|
||||
return octets;
|
||||
}
|
||||
|
||||
function groupKey(pduObj: PduObject, reference: number): string {
|
||||
// NUL: the one octet a C-Octet String address cannot hold, so no sender can forge another's key.
|
||||
function groupKey(pduObj: PduObject, concat: Concat): string {
|
||||
return [
|
||||
paramText(pduObj.params.source_addr),
|
||||
paramText(pduObj.params.destination_addr),
|
||||
String(reference),
|
||||
].join('_');
|
||||
concat.spelling,
|
||||
String(concat.reference),
|
||||
].join('\u0000');
|
||||
}
|
||||
|
||||
/** The text of a message, joining its segments in the order they were reassembled. */
|
||||
@@ -152,10 +154,10 @@ export class Reassembler {
|
||||
}
|
||||
|
||||
/** The group the segment joined, and always an answer for it: an unanswered one stalls a peer. */
|
||||
collect(pduObj: PduObject, concat: ConcatInfo): Collected {
|
||||
collect(pduObj: PduObject, concat: Concat): Collected {
|
||||
this.sweep();
|
||||
|
||||
const key = groupKey(pduObj, concat.reference);
|
||||
const key = groupKey(pduObj, concat);
|
||||
const existing = this.groups.get(key);
|
||||
|
||||
if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' };
|
||||
@@ -204,10 +206,10 @@ export class Reassembler {
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a segment's UDH can join a group at all: its own numbering, and the group's total. */
|
||||
private placeable(concat: ConcatInfo, existing: Group | undefined): boolean {
|
||||
/** Whether a segment can join a group at all: its own numbering, and the group's total. */
|
||||
private placeable(concat: Concat, existing: Group | undefined): boolean {
|
||||
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
|
||||
this.log.warn('reassembler - dropping a segment the UDH numbers impossibly', {
|
||||
this.log.warn('reassembler - dropping an impossibly numbered segment', {
|
||||
part: concat.part,
|
||||
total: concat.total,
|
||||
});
|
||||
@@ -217,7 +219,7 @@ export class Reassembler {
|
||||
|
||||
// Parts 1/2 and 2/3 would otherwise complete the stored two-part group as a truncated message.
|
||||
if (existing && existing.total !== concat.total) {
|
||||
this.log.warn('reassembler - dropping a segment with an inconsistent UDH total', {
|
||||
this.log.warn('reassembler - dropping a segment with an inconsistent total', {
|
||||
existingTotal: existing.total,
|
||||
part: concat.part,
|
||||
total: concat.total,
|
||||
|
||||
+187
-12
@@ -27,6 +27,7 @@ import { objToPdu } from '../src/pdu.ts';
|
||||
import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||
import { concatOf } from '../src/concat.ts';
|
||||
import { consts } from '../src/defs/constants.ts';
|
||||
import { errors } from '../src/defs/errors.ts';
|
||||
import { paramNumber, paramText } from '../src/defs/types.ts';
|
||||
@@ -1470,7 +1471,6 @@ describe('sendDlr()', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reassembly bounds', () => {
|
||||
function segment(reference: number, part: number, total: number): PduObject {
|
||||
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
|
||||
const body = Buffer.concat([udh, Buffer.from('fragment')]);
|
||||
@@ -1509,13 +1509,99 @@ describe('reassembly bounds', () => {
|
||||
};
|
||||
}
|
||||
|
||||
function sarTlvs(reference: number, part: number, total: number): PduObject['tlvs'] {
|
||||
return {
|
||||
sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: reference },
|
||||
sar_segment_seqnum: { tagId: 0x020f, tagName: 'sar_segment_seqnum', tagValue: part },
|
||||
sar_total_segments: { tagId: 0x020e, tagName: 'sar_total_segments', tagValue: total },
|
||||
};
|
||||
}
|
||||
|
||||
/** The same segment numbered by the sar_* TLVs, which carry no UDH and set no esm_class bit. */
|
||||
function sarSegment(reference: number, part: number, total: number): PduObject {
|
||||
const body = Buffer.from('fragment');
|
||||
const carried = segment(reference, part, total);
|
||||
|
||||
return {
|
||||
...carried,
|
||||
params: { ...carried.params, esm_class: 0, short_message: body },
|
||||
shortMessageOctets: body,
|
||||
tlvs: sarTlvs(reference, part, total),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads the concatenation the way a session does, so no test can number a segment by hand. */
|
||||
function collectPdu(reassembler: Reassembler, pduObj: PduObject): Collected {
|
||||
const concat = concatOf(pduObj);
|
||||
|
||||
assert.ok(concat, 'the fixture must number itself as a segment');
|
||||
|
||||
return reassembler.collect(pduObj, concat);
|
||||
}
|
||||
|
||||
describe('where a segment says it is concatenated', () => {
|
||||
// The UDH reference is 8 bits and sar_msg_ref_num 16, so one number is two unrelated counters.
|
||||
test('names the spelling a segment was numbered by, alongside the reference', () => {
|
||||
assert.deepEqual(concatOf(sarSegment(5, 2, 3)), { part: 2, reference: 5, spelling: 'sar', total: 3 });
|
||||
assert.deepEqual(concatOf(segment(5, 2, 3)), { part: 2, reference: 5, spelling: 'udh', total: 3 });
|
||||
});
|
||||
|
||||
test('reads a segment carrying both spellings from its UDH', () => {
|
||||
const both = { ...segment(5, 1, 2), tlvs: sarTlvs(9, 2, 4) };
|
||||
|
||||
assert.deepEqual(concatOf(both), { part: 1, reference: 5, spelling: 'udh', total: 2 });
|
||||
});
|
||||
|
||||
// A UDH carrying only an application port numbers nothing, so the TLVs are all there is to read.
|
||||
test('reads the sar_* TLVs where the UDH names no concatenation', () => {
|
||||
const carried = sarSegment(5, 1, 2);
|
||||
const body = Buffer.concat([Buffer.from([0x04, 0x04, 0x02, 0x17, 0x00]), Buffer.from('fragment')]);
|
||||
const ported = {
|
||||
...carried,
|
||||
params: { ...carried.params, esm_class: 0x40, short_message: body },
|
||||
shortMessageOctets: body,
|
||||
};
|
||||
|
||||
assert.deepEqual(concatOf(ported), { part: 1, reference: 5, spelling: 'sar', total: 2 });
|
||||
});
|
||||
|
||||
test('reads a UDH carried in message_payload', () => {
|
||||
assert.deepEqual(concatOf(payloadSegment(6, 1, 2)), { part: 1, reference: 6, spelling: 'udh', total: 2 });
|
||||
});
|
||||
|
||||
test('reads no concatenation where a sar_* TLV is missing', () => {
|
||||
const lone = {
|
||||
...sarSegment(5, 1, 2),
|
||||
tlvs: { sar_msg_ref_num: { tagId: 0x020c, tagName: 'sar_msg_ref_num', tagValue: 5 } },
|
||||
};
|
||||
|
||||
assert.equal(concatOf(lone), undefined);
|
||||
});
|
||||
|
||||
test('reads no concatenation from a message that is not a segment', () => {
|
||||
const whole = { ...sarSegment(5, 1, 2), tlvs: {} };
|
||||
|
||||
assert.equal(concatOf(whole), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reassembly bounds', () => {
|
||||
function collect(
|
||||
reassembler: Reassembler,
|
||||
reference: number,
|
||||
part: number,
|
||||
total: number,
|
||||
): Collected {
|
||||
return reassembler.collect(segment(reference, part, total), { part, reference, total });
|
||||
return collectPdu(reassembler, segment(reference, part, total));
|
||||
}
|
||||
|
||||
function collectSar(
|
||||
reassembler: Reassembler,
|
||||
reference: number,
|
||||
part: number,
|
||||
total: number,
|
||||
): Collected {
|
||||
return collectPdu(reassembler, sarSegment(reference, part, total));
|
||||
}
|
||||
|
||||
test('hands back every segment in order once the last one arrives', () => {
|
||||
@@ -1576,7 +1662,7 @@ describe('reassembly bounds', () => {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
return reassembler.collect(payloadSegment(9, 1, 2), { part: 1, reference: 9, total: 2 });
|
||||
return collectPdu(reassembler, payloadSegment(9, 1, 2));
|
||||
}
|
||||
|
||||
assert.equal(collectPayload(30).kept, false, 'a TLV body the cap cannot hold is refused, not dropped later');
|
||||
@@ -1606,6 +1692,59 @@ describe('reassembly bounds', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// An alphanumeric sender may carry the separator the key is built with, and two of them are two peers.
|
||||
test('keeps two address pairs that differ only in where a separator sits apart', () => {
|
||||
const reassembler = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
});
|
||||
const addressed = (source: string, destination: string): PduObject => {
|
||||
const carried = segment(3, 1, 2);
|
||||
|
||||
return {
|
||||
...carried,
|
||||
params: { ...carried.params, destination_addr: destination, source_addr: source },
|
||||
};
|
||||
};
|
||||
|
||||
assert.equal(collectPdu(reassembler, addressed('A_B', 'C')).kept, true);
|
||||
assert.equal(collectPdu(reassembler, addressed('A', 'B_C')).kept, true);
|
||||
assert.equal(reassembler.size, 2, 'two senders, so two groups, and neither completes the other');
|
||||
|
||||
reassembler.clear();
|
||||
});
|
||||
|
||||
// Nothing about the bounds reads a UDH, and a group the TLVs numbered is bounded the same way.
|
||||
test('bounds a sar_* group by the same count and octet caps', () => {
|
||||
const counted = new Reassembler({
|
||||
log: silentLog,
|
||||
max: 1,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
});
|
||||
const capped = (maxOctets: number): Reassembler => new Reassembler({
|
||||
log: silentLog,
|
||||
max: 10,
|
||||
maxOctets,
|
||||
now: () => 0,
|
||||
onLost: () => undefined,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
assert.equal(collectSar(counted, 1, 1, 2).kept, true);
|
||||
assert.equal(collectSar(counted, 2, 1, 2).kept, true);
|
||||
assert.equal(counted.size, 1, 'the second group evicted the first, as a UDH group would');
|
||||
counted.clear();
|
||||
|
||||
// A sar_* segment is the two 11-octet addresses plus an 8-octet body, with no UDH to carry.
|
||||
assert.equal(collectSar(capped(20), 3, 1, 2).kept, false);
|
||||
assert.equal(collectSar(capped(30), 3, 1, 2).kept, true);
|
||||
});
|
||||
|
||||
// Nothing else says a message the peer has already been answered for was thrown away.
|
||||
test('names every group it gives up on, and why', () => {
|
||||
let now = 0;
|
||||
@@ -1626,7 +1765,8 @@ describe('reassembly bounds', () => {
|
||||
});
|
||||
|
||||
collect(reassembler, 1, 1, 2);
|
||||
collect(reassembler, 2, 1, 3);
|
||||
// A group numbered by the TLVs is given up on, and reported, exactly as a UDH group is.
|
||||
collectSar(reassembler, 2, 1, 3);
|
||||
|
||||
now = 61;
|
||||
reassembler.sweep();
|
||||
@@ -1664,7 +1804,7 @@ describe('reassembly bounds', () => {
|
||||
Array(3).fill('unplaceable'),
|
||||
);
|
||||
assert.equal(reassembler.size, 0);
|
||||
assert.deepEqual(warnings, Array(3).fill('reassembler - dropping a segment the UDH numbers impossibly'));
|
||||
assert.deepEqual(warnings, Array(3).fill('reassembler - dropping an impossibly numbered segment'));
|
||||
});
|
||||
|
||||
// Parts 1/2 then 2/3 would otherwise complete the stored two-part group, truncating the message.
|
||||
@@ -1757,7 +1897,7 @@ describe('reassembly bounds', () => {
|
||||
Buffer.concat([Buffer.from([0x05, 0x00, 0x03, 6, 2, 1]), Buffer.from('fragment')]).copy(framed);
|
||||
first.params.short_message = framed.subarray(0, 14);
|
||||
|
||||
const held = reassembler.collect(first, { part: 1, reference: 6, total: 2 });
|
||||
const held = collectPdu(reassembler, first);
|
||||
|
||||
assert.ok(held.kept);
|
||||
assert.equal(held.whole, undefined);
|
||||
@@ -1804,10 +1944,14 @@ describe('reassembly bounds', () => {
|
||||
describe('the status a refused segment is answered with', () => {
|
||||
// SMPP 3.4 lists ESME_RMSGQFUL under submit_sm_resp only; 4.6.2's retryable code is another.
|
||||
test('names one the command the segment arrived on defines', () => {
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'full'), 'ESME_RX_T_APPN');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable'), 'ESME_RINVESMCLASS');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable'), 'ESME_RINVESMCLASS');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
|
||||
// esm_class is 0x00 on a sar_* segment and entirely valid: the TLV values are what cannot be honoured.
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
|
||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable', 'sar'), 'ESME_RINVTLVVAL');
|
||||
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'sar'), 'ESME_RMSGQFUL');
|
||||
});
|
||||
|
||||
// Which command that is, for the one that travels both ways, is what the end it arrived at says.
|
||||
@@ -1817,8 +1961,8 @@ describe('the status a refused segment is answered with', () => {
|
||||
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');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2046,6 +2190,37 @@ describe('a peer that sends the next segment only once the last one is answered'
|
||||
assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVESMCLASS');
|
||||
assert.deepEqual(messages, []);
|
||||
});
|
||||
|
||||
// Its esm_class is 0x00 and correct, so the refusal names the optional parameters instead.
|
||||
test('refuses a sar_* segment the TLVs number impossibly by naming those TLVs', async t => {
|
||||
const smpp = await startServer(t);
|
||||
const messages: Sms[] = [];
|
||||
|
||||
smpp.on('session', bound => bound.on('sms', sms => { messages.push(sms); }));
|
||||
|
||||
const { session } = await connect(t, smpp, { responseTimeout: 1000 });
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const answered = await session.send({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: 'part nine of two',
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: {
|
||||
sar_msg_ref_num: { tagValue: 0x2e },
|
||||
sar_segment_seqnum: { tagValue: 9 },
|
||||
sar_total_segments: { tagValue: 2 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(answered.err, undefined);
|
||||
assert.ok(answered.pduObj);
|
||||
assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVTLVVAL');
|
||||
assert.deepEqual(messages, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AbortSignal on a send', () => {
|
||||
|
||||
+238
-2
@@ -716,8 +716,11 @@ describe('sending', () => {
|
||||
});
|
||||
|
||||
describe('receiving', () => {
|
||||
async function inbound(t: TestContext): Promise<{ peer: Session; session: Session }> {
|
||||
const smpp = await startServer(t);
|
||||
async function inbound(
|
||||
t: TestContext,
|
||||
options: ServerOptions = {},
|
||||
): Promise<{ peer: Session; session: Session }> {
|
||||
const smpp = await startServer(t, options);
|
||||
|
||||
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
|
||||
const { session } = await connect(t, smpp);
|
||||
@@ -1061,6 +1064,239 @@ describe('receiving', () => {
|
||||
assert.equal(paramText(answered.pduObj.params.message_id), '');
|
||||
}
|
||||
});
|
||||
|
||||
/** SMPP 3.4 5.3.2.31-5.3.2.33: concatenation as optional parameters, with no UDH in the body. */
|
||||
function sarTlvs(reference: number, part: number, total: number): PduObjectInput['tlvs'] {
|
||||
return {
|
||||
sar_msg_ref_num: { tagValue: reference },
|
||||
sar_segment_seqnum: { tagValue: part },
|
||||
sar_total_segments: { tagValue: total },
|
||||
};
|
||||
}
|
||||
|
||||
test('answers every sar_* segment on arrival and hands the application one message', async t => {
|
||||
const smpp = await startServer(t);
|
||||
const incoming = once<Sms>(resolve => { smpp.on('session', peer => peer.on('sms', resolve)); });
|
||||
const { session } = await connect(t, smpp, { bindType: 'transmitter', responseTimeout: 1000 });
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const parts = ['sar one, ', 'sar two, ', 'sar three'];
|
||||
const answers: PduObject[] = [];
|
||||
|
||||
// A 16-bit reference no 8-bit UDH could carry, which is the width the TLV exists for.
|
||||
for (const [index, part] of parts.entries()) {
|
||||
const sent = await session.send({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: part,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: sarTlvs(0x02b7, index + 1, parts.length),
|
||||
});
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
answers.push(sent.pduObj);
|
||||
}
|
||||
|
||||
const sms = await raceWithin(2000, incoming);
|
||||
|
||||
assert.ok(sms, 'the sar_* TLVs tie the three submissions into one message');
|
||||
assert.equal(sms.message, parts.join(''));
|
||||
assert.equal(sms.answeredOnArrival, true);
|
||||
assert.deepEqual(
|
||||
answers.map(answer => paramText(answer.params.message_id)),
|
||||
[1, 2, 3].map(part => `${sms.smsId}-${String(part)}`),
|
||||
);
|
||||
});
|
||||
|
||||
test('joins sar_* segments in the order they number themselves', async t => {
|
||||
const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||||
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||||
const parts = ['first ', 'second ', 'third'];
|
||||
|
||||
for (const index of [2, 0, 1]) {
|
||||
const sent = await peer.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: parts[index],
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: sarTlvs(0x11, index + 1, parts.length),
|
||||
});
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||
}
|
||||
|
||||
const sms = await raceWithin(2000, incoming);
|
||||
|
||||
assert.ok(sms, 'the segments number themselves, so the order they arrive in is not the message');
|
||||
assert.equal(sms.message, parts.join(''));
|
||||
});
|
||||
|
||||
test('reassembles a sar_* segment whose body is in message_payload', async t => {
|
||||
const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||||
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||||
const parts = ['in the mandatory field, ', 'and in the TLV'];
|
||||
|
||||
for (const [index, part] of parts.entries()) {
|
||||
// The combination that needs no UDH at all: the numbering and the body are both optional
|
||||
// parameters, so the second segment's short_message is empty.
|
||||
const carried = index === 0
|
||||
? { params: { short_message: part }, tlvs: {} }
|
||||
: {
|
||||
params: { short_message: Buffer.alloc(0) },
|
||||
tlvs: { message_payload: { tagValue: Buffer.from(part, 'ascii') } },
|
||||
};
|
||||
const sent = await peer.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
source_addr: '46701113311',
|
||||
...carried.params,
|
||||
},
|
||||
tlvs: { ...sarTlvs(0x12, index + 1, parts.length), ...carried.tlvs },
|
||||
});
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||
}
|
||||
|
||||
const sms = await raceWithin(2000, incoming);
|
||||
|
||||
assert.ok(sms, 'a segment carries its body where any other message may carry one');
|
||||
assert.equal(sms.message, parts.join(''));
|
||||
assert.equal(sms.answeredOnArrival, true);
|
||||
});
|
||||
|
||||
// The UDH reference is 8 bits and sar_msg_ref_num is 16, so the same number is two messages.
|
||||
test('keeps a UDH group and a sar_* group sharing a reference apart', async t => {
|
||||
const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||||
const messages: Sms[] = [];
|
||||
|
||||
session.on('sms', sms => { messages.push(sms); });
|
||||
|
||||
const udhText = 'the message numbered by its user data header. '.repeat(5);
|
||||
const udhSegments = splitMessage(udhText, { reference: 5 });
|
||||
const sarParts = ['the message numbered by ', 'its optional parameters'];
|
||||
|
||||
assert.equal(udhSegments.length, 2);
|
||||
|
||||
const sends: PduObjectInput[] = [];
|
||||
|
||||
for (const [index, segment] of udhSegments.entries()) {
|
||||
sends.push({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
|
||||
short_message: segment,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
});
|
||||
sends.push({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: sarParts[index],
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: sarTlvs(5, index + 1, sarParts.length),
|
||||
});
|
||||
}
|
||||
|
||||
for (const input of sends) {
|
||||
const sent = await peer.send(input);
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||
}
|
||||
|
||||
assert.ok(await waitFor(() => messages.length === 2));
|
||||
assert.deepEqual(messages.map(sms => sms.message).sort(), [sarParts.join(''), udhText].sort());
|
||||
assert.notEqual(messages[0]?.smsId, messages[1]?.smsId);
|
||||
});
|
||||
|
||||
// Nothing compares the two references: each spelling counts in a space of its own.
|
||||
test('groups a segment carrying both spellings by its UDH', async t => {
|
||||
const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||||
const incoming = once<Sms>(resolve => { session.on('sms', resolve); });
|
||||
const message = 'both spellings on every segment of it, and the UDH decides. '.repeat(4);
|
||||
const segments = splitMessage(message, { reference: 7 });
|
||||
|
||||
assert.equal(segments.length, 2);
|
||||
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const sent = await peer.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
|
||||
short_message: segment,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
// Numbering the same segments as a three-part message no third segment ever completes.
|
||||
tlvs: sarTlvs(0x0207, index + 1, 3),
|
||||
});
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
}
|
||||
|
||||
const sms = await raceWithin(2000, incoming);
|
||||
|
||||
assert.ok(sms, 'the UDH says the message is whole at two segments, and the UDH is what is read');
|
||||
assert.equal(sms.message, message);
|
||||
});
|
||||
|
||||
test('reads a receipt carrying sar_* fields as a dlr, never as a segment', async t => {
|
||||
const { peer, session } = await inbound(t, { responseTimeout: 1000 });
|
||||
const reports: Dlr[] = [];
|
||||
let messages = 0;
|
||||
|
||||
session.on('dlr', dlr => { reports.push(dlr); });
|
||||
session.on('sms', () => { messages++; });
|
||||
|
||||
const marked = '0199e1a4-6c3f-7d21-9a80-5b1e2f7c4d63';
|
||||
const unmarked = '0199e1a4-b70e-7c55-8f42-9d3a1c86e70b';
|
||||
const body = (smsId: string): string =>
|
||||
`id:${smsId} sub:001 dlvrd:001 submit date:2509061200 done date:2509061201 stat:DELIVRD err:000 text:`;
|
||||
const receipts: PduObjectInput[] = [
|
||||
{
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT,
|
||||
short_message: body(marked),
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: sarTlvs(0x21, 1, 2),
|
||||
},
|
||||
// esm_class marks nothing, so the receipted_message_id TLV is what says it is a report.
|
||||
{
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: body(unmarked),
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
tlvs: { ...sarTlvs(0x21, 2, 2), receipted_message_id: { tagValue: unmarked } },
|
||||
},
|
||||
];
|
||||
|
||||
for (const receipt of receipts) {
|
||||
const sent = await peer.send(receipt);
|
||||
|
||||
assert.ok(sent.pduObj);
|
||||
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||
}
|
||||
|
||||
assert.ok(await waitFor(() => reports.length === 2));
|
||||
assert.deepEqual(reports.map(report => report.smsId), [marked, unmarked]);
|
||||
assert.equal(messages, 0, 'a report is never a segment, however the peer numbered it');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery reports', () => {
|
||||
|
||||
Reference in New Issue
Block a user