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()
|
server.ts server() -> { err, server }, server owns the listener + close()
|
||||||
session.ts Session: the socket's life, dispatch, events, and the collaborators below
|
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)
|
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.ts Delivery receipts: text and TLV parsing, receipt status codes
|
||||||
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
|
dlr-merger.ts DlrMerger: per-segment receipts counted into one MessageDlr
|
||||||
error-from.ts errorFrom(): whatever was thrown or rejected, as an Error
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
traffic. The reassembler's octet cap already counts TLV values, so a 64 KB payload is bounded like
|
||||||
any other segment.
|
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
|
- **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:
|
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
|
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
|
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()`
|
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
|
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
|
in miniature: the field that numbered it where the segment belongs to no group, `ESME_RMSGQFUL`
|
||||||
segment's own arrival overran the octet cap, since a peer told that still holds it. Rejected:
|
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
|
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
|
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,
|
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()`
|
`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
|
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
|
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
|
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
|
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
|
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
|
`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`.
|
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()`
|
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
|
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
|
`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
|
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.
|
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
|
That answer is `ESME_ROK` unless the segment numbers itself into no message this session can join,
|
||||||
refuses it, or the reassembly buffer is full, which asks the SMSC to keep it and try again.
|
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
|
`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
|
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
|
`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
|
`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
|
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
|
`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`,
|
The spec tables are exported both individually (`cmds`, `consts`, `encodings`, `errors`, `tlvs`,
|
||||||
`types`, and the matching `*ById` maps) and grouped as `defs`.
|
`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.
|
`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
|
Both reach the application now — a receipt as `dlr`, answered for you, and a message as `sms` for
|
||||||
you to answer like any other.
|
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.
|
- 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
|
- 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,
|
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
|
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.
|
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
|
### `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
|
**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 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 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 `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 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 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..." |
|
| 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
|
as an alternative to the UDH for carrying concatenation; nothing in the spec says a receiver may
|
||||||
ignore it.
|
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`
|
`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
|
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.
|
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)
|
### 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
|
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 });
|
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 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() });
|
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 () => {
|
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);
|
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);
|
await waitForSessions(1);
|
||||||
|
|
||||||
const text = 'sar-'.padEnd(200, 'c');
|
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);
|
assert.equal(segments.length, 2);
|
||||||
|
|
||||||
// Neither segment ever arrives as the whole 200-char text: each is its own ordinary sms
|
const sms = await waitForSms(text, 15_000);
|
||||||
// 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);
|
|
||||||
|
|
||||||
assert.equal(first.answeredOnArrival, false);
|
assert.equal(sms.answeredOnArrival, true);
|
||||||
assert.equal(second.answeredOnArrival, false);
|
assert.equal(segments[0]?.messageId, `${sms.smsId}-1`);
|
||||||
assert.notEqual(first.smsId, second.smsId);
|
assert.equal(segments[1]?.messageId, `${sms.smsId}-2`);
|
||||||
assert.equal(allSms.some(entry => entry.sms.message === text), false);
|
// Neither ~130-char slice ever reached the application on its own.
|
||||||
void first;
|
assert.equal(allSms.filter(entry => text.includes(entry.sms.message)).length, 1);
|
||||||
void second;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 { DlrMerger } from './dlr-merger.ts';
|
||||||
import type { ErrorName } from './defs/errors.ts';
|
import type { ErrorName } from './defs/errors.ts';
|
||||||
import type { LostGroup, Refusal } from './reassembly.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 { HeldMessages } from './held-messages.ts';
|
||||||
import { Reassembler, decodeSegments } from './reassembly.ts';
|
import { Reassembler, decodeSegments } from './reassembly.ts';
|
||||||
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
import { bindCommands, defaults, standsInFor } from './session-options.ts';
|
||||||
import { concatInfo } from './udh.ts';
|
import { concatOf } from './concat.ts';
|
||||||
import { hasUdh } from './defs/constants.ts';
|
|
||||||
import { createSms } from './sms.ts';
|
import { createSms } from './sms.ts';
|
||||||
import { dlrFromPdu } from './dlr.ts';
|
import { dlrFromPdu } from './dlr.ts';
|
||||||
import { messageOctets } from './message-body.ts';
|
import { paramText } from './defs/types.ts';
|
||||||
import { paramNumber, paramText } from './defs/types.ts';
|
|
||||||
import { respIdParams, segmentId } from './sms-id.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. */
|
/** 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 {
|
export function refusedSegmentStatus(
|
||||||
if (refusal === 'unplaceable') return 'ESME_RINVESMCLASS';
|
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';
|
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.
|
* one request at a time never sends the second segment until the first has been answered.
|
||||||
*/
|
*/
|
||||||
private async onMessage(pduObj: PduObject): Promise<void> {
|
private async onMessage(pduObj: PduObject): Promise<void> {
|
||||||
const message = messageOctets(pduObj);
|
const concat = concatOf(pduObj);
|
||||||
const carriesUdh = hasUdh(paramNumber(pduObj.params.esm_class, 0));
|
|
||||||
const concat = carriesUdh && message ? concatInfo(message) : undefined;
|
|
||||||
|
|
||||||
if (!concat) {
|
if (!concat) {
|
||||||
this.emitSms([pduObj]);
|
this.emitSms([pduObj]);
|
||||||
@@ -212,7 +216,7 @@ export class IncomingRequests {
|
|||||||
if (!collected.kept) {
|
if (!collected.kept) {
|
||||||
await this.session.sendReturn(
|
await this.session.sendReturn(
|
||||||
pduObj,
|
pduObj,
|
||||||
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal),
|
refusedSegmentStatus(this.carriedAs(pduObj), collected.refusal, concat.spelling),
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export {
|
|||||||
|
|
||||||
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
|
export { dlrFromPdu, parseReceipt, receiptCodes } from './dlr.ts';
|
||||||
export { messageOctets } from './message-body.ts';
|
export { messageOctets } from './message-body.ts';
|
||||||
|
export { concatOf } from './concat.ts';
|
||||||
export { concatInfo } from './udh.ts';
|
export { concatInfo } from './udh.ts';
|
||||||
export { PduFramer } from './pdu-framer.ts';
|
export { PduFramer } from './pdu-framer.ts';
|
||||||
export { uuidv7 } from './uuid.ts';
|
export { uuidv7 } from './uuid.ts';
|
||||||
@@ -38,6 +39,7 @@ export { uuidv7 } from './uuid.ts';
|
|||||||
export type { BindType, ClientOptions } from './client.ts';
|
export type { BindType, ClientOptions } from './client.ts';
|
||||||
export type { Dlr, Receipt } from './dlr.ts';
|
export type { Dlr, Receipt } from './dlr.ts';
|
||||||
export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts';
|
export type { SendDlrResult, SendRespOptions, Sms, SmsInput } from './sms.ts';
|
||||||
|
export type { Concat } from './concat.ts';
|
||||||
export type { ConcatInfo } from './udh.ts';
|
export type { ConcatInfo } from './udh.ts';
|
||||||
export type { Result, VoidResult } from './result.ts';
|
export type { Result, VoidResult } from './result.ts';
|
||||||
export type { SmppLog } from './log.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 { ParamValue } from './defs/types.ts';
|
||||||
import type { PduObject } from './pdu.ts';
|
import type { PduObject } from './pdu.ts';
|
||||||
import type { SmppLog } from './log.ts';
|
import type { SmppLog } from './log.ts';
|
||||||
@@ -96,12 +96,14 @@ function octetsOf(pduObj: PduObject): number {
|
|||||||
return octets;
|
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 [
|
return [
|
||||||
paramText(pduObj.params.source_addr),
|
paramText(pduObj.params.source_addr),
|
||||||
paramText(pduObj.params.destination_addr),
|
paramText(pduObj.params.destination_addr),
|
||||||
String(reference),
|
concat.spelling,
|
||||||
].join('_');
|
String(concat.reference),
|
||||||
|
].join('\u0000');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The text of a message, joining its segments in the order they were reassembled. */
|
/** 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. */
|
/** 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();
|
this.sweep();
|
||||||
|
|
||||||
const key = groupKey(pduObj, concat.reference);
|
const key = groupKey(pduObj, concat);
|
||||||
const existing = this.groups.get(key);
|
const existing = this.groups.get(key);
|
||||||
|
|
||||||
if (!this.placeable(concat, existing)) return { kept: false, refusal: 'unplaceable' };
|
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. */
|
/** Whether a segment can join a group at all: its own numbering, and the group's total. */
|
||||||
private placeable(concat: ConcatInfo, existing: Group | undefined): boolean {
|
private placeable(concat: Concat, existing: Group | undefined): boolean {
|
||||||
if (concat.part < 1 || concat.total < 1 || concat.part > concat.total) {
|
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,
|
part: concat.part,
|
||||||
total: concat.total,
|
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.
|
// Parts 1/2 and 2/3 would otherwise complete the stored two-part group as a truncated message.
|
||||||
if (existing && existing.total !== concat.total) {
|
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,
|
existingTotal: existing.total,
|
||||||
part: concat.part,
|
part: concat.part,
|
||||||
total: concat.total,
|
total: concat.total,
|
||||||
|
|||||||
+187
-12
@@ -27,6 +27,7 @@ import { objToPdu } from '../src/pdu.ts';
|
|||||||
import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
|
import { checkSessionOptions, standsInFor } from '../src/session-options.ts';
|
||||||
import { client } from '../src/client.ts';
|
import { client } from '../src/client.ts';
|
||||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||||
|
import { concatOf } from '../src/concat.ts';
|
||||||
import { consts } from '../src/defs/constants.ts';
|
import { consts } from '../src/defs/constants.ts';
|
||||||
import { errors } from '../src/defs/errors.ts';
|
import { errors } from '../src/defs/errors.ts';
|
||||||
import { paramNumber, paramText } from '../src/defs/types.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 {
|
function segment(reference: number, part: number, total: number): PduObject {
|
||||||
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
|
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
|
||||||
const body = Buffer.concat([udh, Buffer.from('fragment')]);
|
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(
|
function collect(
|
||||||
reassembler: Reassembler,
|
reassembler: Reassembler,
|
||||||
reference: number,
|
reference: number,
|
||||||
part: number,
|
part: number,
|
||||||
total: number,
|
total: number,
|
||||||
): Collected {
|
): 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', () => {
|
test('hands back every segment in order once the last one arrives', () => {
|
||||||
@@ -1576,7 +1662,7 @@ describe('reassembly bounds', () => {
|
|||||||
timeout: 60_000,
|
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');
|
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.
|
// 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', () => {
|
test('names every group it gives up on, and why', () => {
|
||||||
let now = 0;
|
let now = 0;
|
||||||
@@ -1626,7 +1765,8 @@ describe('reassembly bounds', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
collect(reassembler, 1, 1, 2);
|
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;
|
now = 61;
|
||||||
reassembler.sweep();
|
reassembler.sweep();
|
||||||
@@ -1664,7 +1804,7 @@ describe('reassembly bounds', () => {
|
|||||||
Array(3).fill('unplaceable'),
|
Array(3).fill('unplaceable'),
|
||||||
);
|
);
|
||||||
assert.equal(reassembler.size, 0);
|
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.
|
// 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);
|
Buffer.concat([Buffer.from([0x05, 0x00, 0x03, 6, 2, 1]), Buffer.from('fragment')]).copy(framed);
|
||||||
first.params.short_message = framed.subarray(0, 14);
|
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.ok(held.kept);
|
||||||
assert.equal(held.whole, undefined);
|
assert.equal(held.whole, undefined);
|
||||||
@@ -1804,10 +1944,14 @@ describe('reassembly bounds', () => {
|
|||||||
describe('the status a refused segment is answered with', () => {
|
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.
|
// 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', () => {
|
test('names one the command the segment arrived on defines', () => {
|
||||||
assert.equal(refusedSegmentStatus('submit_sm', 'full'), 'ESME_RMSGQFUL');
|
assert.equal(refusedSegmentStatus('submit_sm', 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||||
assert.equal(refusedSegmentStatus('deliver_sm', 'full'), 'ESME_RX_T_APPN');
|
assert.equal(refusedSegmentStatus('deliver_sm', 'full', 'udh'), 'ESME_RX_T_APPN');
|
||||||
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable'), 'ESME_RINVESMCLASS');
|
assert.equal(refusedSegmentStatus('submit_sm', 'unplaceable', 'udh'), 'ESME_RINVESMCLASS');
|
||||||
assert.equal(refusedSegmentStatus('deliver_sm', 'unplaceable'), '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.
|
// 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('deliver_sm', 'esme'), 'deliver_sm');
|
||||||
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
|
assert.equal(standsInFor('submit_sm', 'smsc'), 'submit_sm');
|
||||||
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
|
assert.equal(standsInFor('enquire_link', 'smsc'), 'enquire_link');
|
||||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full'), 'ESME_RMSGQFUL');
|
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'smsc'), 'full', 'udh'), 'ESME_RMSGQFUL');
|
||||||
assert.equal(refusedSegmentStatus(standsInFor('data_sm', 'esme'), 'full'), 'ESME_RX_T_APPN');
|
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.equal(answered.pduObj.cmdStatus, 'ESME_RINVESMCLASS');
|
||||||
assert.deepEqual(messages, []);
|
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', () => {
|
describe('AbortSignal on a send', () => {
|
||||||
|
|||||||
+238
-2
@@ -716,8 +716,11 @@ describe('sending', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('receiving', () => {
|
describe('receiving', () => {
|
||||||
async function inbound(t: TestContext): Promise<{ peer: Session; session: Session }> {
|
async function inbound(
|
||||||
const smpp = await startServer(t);
|
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 bound = once<Session>(resolve => { smpp.on('session', resolve); });
|
||||||
const { session } = await connect(t, smpp);
|
const { session } = await connect(t, smpp);
|
||||||
@@ -1061,6 +1064,239 @@ describe('receiving', () => {
|
|||||||
assert.equal(paramText(answered.pduObj.params.message_id), '');
|
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', () => {
|
describe('delivery reports', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user