Run SMPPSim 2.6.11 as the SMSC with nine fault-injection variants: 25 scenarios pass, two defects recorded
This commit is contained in:
@@ -36,7 +36,9 @@ Both counts must be zero for a phase to pass.
|
|||||||
## Rules for an experiment
|
## Rules for an experiment
|
||||||
|
|
||||||
1. Peers run in Docker with full patch-version pins. Nothing is installed on the host. Node runs
|
1. Peers run in Docker with full patch-version pins. Nothing is installed on the host. Node runs
|
||||||
only through the `node` service.
|
only through the `node` service. Every peer service caps its logs (`logging: json-file`,
|
||||||
|
`max-size`/`max-file`) and healthchecks something the peer does not log a stack trace for —
|
||||||
|
SMPPSim once filled the whole host disk in twenty minutes from a TCP-probe healthcheck.
|
||||||
2. `src/` and `test/` are read-only during an experiment. A defect is recorded with a reproducer,
|
2. `src/` and `test/` are read-only during an experiment. A defect is recorded with a reproducer,
|
||||||
never fixed here — a fix is a separate change with a regression test in `test/`.
|
never fixed here — a fix is a separate change with a regression test in `test/`.
|
||||||
3. No git command that changes state: no add, commit, push, checkout, stash, reset. The
|
3. No git command that changes state: no add, commit, push, checkout, stash, reset. The
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ document, library changes, and the AGENTS.md decision record each one needs.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 0 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
| 0 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
||||||
| 1 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
| 1 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
||||||
| 2–3 | not started | — |
|
| 2 | done | [02-smppsim.md](findings/02-smppsim.md) |
|
||||||
|
| 3 | not started | — |
|
||||||
| 4 | done | [04-kannel.md](findings/04-kannel.md) |
|
| 4 | done | [04-kannel.md](findings/04-kannel.md) |
|
||||||
| 5–10 | not started | — |
|
| 5–10 | not started | — |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
x-smppsim-healthcheck: &smppsim-healthcheck
|
||||||
|
# Not a raw TCP probe on 2775: SMPPSim reads an empty connection as a malformed PDU and logs a
|
||||||
|
# full stack trace per attempt: with DECODE_PDUS_IN_LOG and a 1s interval that fills the disk
|
||||||
|
# within tens of minutes (24GB+ across the 9 services in-house). The HTTP admin port answers a
|
||||||
|
# bare request harmlessly.
|
||||||
|
test: ["CMD-SHELL", "curl -sf -o /dev/null http://127.0.0.1:8884/"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
# A second line of defence for the same runaway-logging risk: DECODE_PDUS_IN_LOG dumps every PDU,
|
||||||
|
# so a stuck reconnect loop or a bad probe fills the disk long before anyone notices.
|
||||||
|
x-log-limits: &log-limits
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-file: "3"
|
||||||
|
max-size: 20m
|
||||||
|
|
||||||
|
services:
|
||||||
|
smppsim:
|
||||||
|
build:
|
||||||
|
context: interop-tests/peers/smppsim
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-textdlr:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-textdlr.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-transition:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-transition.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-undeliv:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-undeliv.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-rejected:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-rejected.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-accepted:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-accepted.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-delayed:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-delayed.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
smppsim-queuefull:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-queuefull.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
# OUTBIND_ESME_IP_ADDRESS in smppsim-outbind.props names the node service by its --use-aliases
|
||||||
|
# hostname; our own server() in smppsim.test.ts listens on port 2776 for it to connect to.
|
||||||
|
smppsim-outbind:
|
||||||
|
image: larvitsmpp-interop/smppsim:bc29982
|
||||||
|
<<: *log-limits
|
||||||
|
command: ["conf/smppsim-outbind.props"]
|
||||||
|
healthcheck: *smppsim-healthcheck
|
||||||
|
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smppsim"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smppsim:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppsim.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
# A second sidecar, cheap to add, so a textdlr-specific wire question can be checked without a
|
||||||
|
# second run. Not read by run.py's own pass/fail (that only decodes captures/smppsim.pcapng).
|
||||||
|
capture-textdlr:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smppsim-textdlr"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smppsim-textdlr:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smppsim-textdlr.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
capture-textdlr:
|
||||||
|
condition: service_started
|
||||||
|
smppsim:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-textdlr:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-transition:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-undeliv:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-rejected:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-accepted:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-delayed:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-queuefull:
|
||||||
|
condition: service_healthy
|
||||||
|
smppsim-outbind:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# 02 smppsim
|
||||||
|
|
||||||
|
Date: 2026-09-05. Repo commit: `9c4939f`. Host Docker: 29.6.2. Images: `larvitsmpp-interop/smppsim:bc29982`
|
||||||
|
(built locally here from `kwahome/smpp-sim-docker` at commit `bc299828af9046ab290da3b4957dfbc472f02bfb`,
|
||||||
|
running SMPPSim 2.6.11 on `eclipse-temurin:8u452-b09-jre`; cloned during the build with
|
||||||
|
`debian:13.2-slim`, never vendored), `nicolaka/netshoot:v0.16` (capture sidecars and tshark),
|
||||||
|
`node:24.18.0-bookworm-slim` (test runner, from the root `compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
`interop-tests/peers/smppsim/Dockerfile` clones the pinned commit in a build stage and copies just
|
||||||
|
`smppsim.jar`, `lib`, `www`, `mo` and `conf/logging.properties` into the runtime stage; each
|
||||||
|
`smppsim*.props` file under the same directory is copied in and selected via the container
|
||||||
|
`command`. `interop-tests/compose.smppsim.yaml` runs nine variants of the one image (`smppsim`,
|
||||||
|
`-textdlr`, `-transition`, `-undeliv`, `-rejected`, `-accepted`, `-delayed`, `-queuefull`,
|
||||||
|
`-outbind`) plus `capture` (on `smppsim`) and `capture-textdlr`, all on one network so
|
||||||
|
`./interop-tests/run.py smppsim` covers everything in one run.
|
||||||
|
|
||||||
|
Two snags fixed while building the harness:
|
||||||
|
|
||||||
|
- **The first healthcheck (`bash -c 'echo > /dev/tcp/127.0.0.1/2775'`, 1s interval) filled the
|
||||||
|
host's disk.** SMPPSim reads an empty connection as a malformed PDU and logs a full Java stack
|
||||||
|
trace per attempt; with `DECODE_PDUS_IN_LOG=true` and a 1s probe interval, nine containers left
|
||||||
|
running for ~20 minutes wrote 24GB+ of container logs between them and took the whole shared host
|
||||||
|
to 0 bytes free (`docker run` itself started failing with "no space left on device"). Fixed by
|
||||||
|
probing the HTTP admin port instead (`curl -sf -o /dev/null http://127.0.0.1:8884/`), which
|
||||||
|
SMPPSim answers harmlessly, plus a `json-file` log cap (`max-size: 20m`, `max-file: "3"`) on every
|
||||||
|
`smppsim*` service as a second line of defence. Worth knowing for anyone else pointing a 1s
|
||||||
|
TCP-connect healthcheck at this peer.
|
||||||
|
- The `capture` sidecar's `dumpcap` needs the same root/DAC-override handling phase 1 documented
|
||||||
|
(`interop-tests/captures/` chowned to `1000:1000`/`0777` by `run.py` after every run, stale
|
||||||
|
`<peer>.pcapng` unlinked before the next). Once, a capture attempt failed outright
|
||||||
|
("Permission denied" opening the pcapng) for a reason not pinned down - not reproduced since;
|
||||||
|
treat as a rare, unexplained flake in this harness rather than a peer issue.
|
||||||
|
- Two early runs showed `malformed: 2`, `expert errors: 2`. Both traced to this test file, not
|
||||||
|
SMPPSim: C17's raw-UDH test used IEI `0x01` ("Special SMS Message Indication"), a real GSM 03.40
|
||||||
|
information element with its own defined length (2 bytes), at length 1 - Wireshark's
|
||||||
|
`gsm_sms_ud` dissector correctly flags that as malformed. Fixed by using IEI `0x70`
|
||||||
|
(reserved-for-future-use, so no dissector validates its length) for what the test only ever
|
||||||
|
needed to be an arbitrary, unparsed UDH element.
|
||||||
|
|
||||||
|
Two runs of `./interop-tests/run.py smppsim`, back to back, after that fix: both exit 0, 25/25
|
||||||
|
tests passing, `malformed: 0`, `expert errors: 0` in both.
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
| Id (from PLAN.md) | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C2 (`smppsim-textdlr`) | pass | `smppsim-textdlr - C2 text-only receipts`; no TLVs, `dlr.smsId` matches `submit_sm_resp` |
|
||||||
|
| C3 (`smppsim`, TLVs on) | pass | `smppsim - C3+C7 …`; `receipted_message_id`/`message_state` TLVs present and agree with the body on every segment |
|
||||||
|
| C4 (`smppsim-transition`) | fail (peer defect, see below) | `smppsim-transition - C4 …`; intermediate report arrives, no final ever does |
|
||||||
|
| C5 (single-state variants) | pass | `smppsim single-state variants - C5 …`; UNDELIVERABLE/REJECTED/ACCEPTED each map correctly; 2-segment message's shared status confirmed, `messageDlr` confirmed absent (see Open questions) |
|
||||||
|
| C6 (`smppsim-delayed`) | pass | `smppsim-delayed - C6 …`; disconnect+reconnect observed, delayed receipt still reaches `dlr` on the new link |
|
||||||
|
| C7 (loopback, GSM 1/2/3/10-segment) | pass | same `C3+C7` suite; one id per segment, receipt per id, loopback reassembles the exact text including € and \[ \] |
|
||||||
|
| C7 (loopback, UCS2 2-segment) | pass with a documented defect | `… 2-segment UCS2 …: TLVs stay right, the receipt body is corrupted`; TLVs and loopback reassembly both correct, receipt body unreadable - `@larvit/smpp` defect below |
|
||||||
|
| C11 (bind refusal + backoff) | pass | `smppsim - C11 …`, three sub-tests, see below for what each shows |
|
||||||
|
| C12 (`smppsim-queuefull`) | pass | `smppsim-queuefull - C12 …`; `ESME_RMSGQFUL` returned, session stays bound, later send succeeds once the one-slot queue drains |
|
||||||
|
| C13 (`maxOutstanding: 1`, 10 parallel) | pass | `smppsim - C13 …`; 10 distinct, strictly-increasing ids, none lost |
|
||||||
|
| C15 (bind version) | pass (peer never declares, see below) | `smppsim - C15 …`, both 0x34 and 0x50 |
|
||||||
|
| C17 (encodings over loopback) | pass | `smppsim - C17 …`, 5 sub-tests: Latin-1, UCS-2, flash (0x10), raw 0xF0 (not flash), raw UDH+8-bit-binary |
|
||||||
|
| C18 (`smppsim-outbind`) | pass (record, not judge) | `smppsim-outbind - C18 …`; see below for the wire facts |
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
### A delivery receipt's `data_coding` is trusted to decode its body, even though the spec makes the receipt a fixed text format
|
||||||
|
|
||||||
|
**What happened.** A message sent with `encoding: 'UCS2'` gets `data_coding: 8` on its `submit_sm`.
|
||||||
|
SMPPSim's delivery receipt for it (confirmed against source, `DeliveryReceipt` extends `DeliverSM`
|
||||||
|
by copy-constructing the original `SubmitSM`) inherits that same `data_coding: 8`, but its
|
||||||
|
`short_message` is always plain ASCII text (`id:… sub:… dlvrd:… stat:…`). `pdu.ts`'s parser decodes
|
||||||
|
any non-UDH PDU's `short_message` using its own `data_coding` unconditionally
|
||||||
|
(`params.short_message = decodeMessage(message, paramNumber(params.data_coding, 0)).message`), so
|
||||||
|
the ASCII receipt bytes are read back as UCS2 (byte pairs swapped) - `id:001 sub:…` becomes
|
||||||
|
unrecoverable CJK-range glyphs, and `dlr.receipt` (hence `.stat`, `.id`, every body field) is
|
||||||
|
garbage or `undefined` for every receipt whose triggering message used a non-ASCII encoding.
|
||||||
|
|
||||||
|
**What the spec says.** SMPP 3.4 5.2.25 defines `short_message`/`message_payload` generically, but
|
||||||
|
Appendix B's receipt format is specified as fixed ASCII fields; `data_coding` on the *receipt* PDU
|
||||||
|
describes nothing about how to read that text, since the MC is reporting on a message rather than
|
||||||
|
carrying one. Trusting `data_coding` to decode a receipt body is reading a field the spec never
|
||||||
|
attaches that meaning to. The `receipted_message_id`/`message_state` TLVs are unaffected (typed
|
||||||
|
fields, not text), which is why `dlr.statusMsg` and `dlr.smsId` stay correct here - only the
|
||||||
|
body-derived `dlr.receipt` (and anything relying on it, e.g. a peer without TLVs) is lost.
|
||||||
|
|
||||||
|
**Reproducer.** `session.sendSms({ encoding: 'UCS2', dlr: true, message: 'x', from, to })` against
|
||||||
|
`smppsim`, then inspect the `dlr` event's second argument (the raw `PduObject`):
|
||||||
|
`pduObj.params.data_coding === 8` and `dlr.receipt === undefined`, while
|
||||||
|
`pduObj.tlvs.receipted_message_id`/`message_state` are present and correct. Confirmed both against
|
||||||
|
SMPPSim and by constructing the PDU directly: a `deliver_sm` with `data_coding=8`, `esm_class=4`
|
||||||
|
and an ASCII `id:0 sub:001 dlvrd:001 …` body decodes to garbage text via `pduToObj`.
|
||||||
|
|
||||||
|
**Severity.** Medium: harmless when TLVs are present (as here), since `statusMsg`/`smsId` still
|
||||||
|
resolve correctly - but total for a peer that answers `DELIVERY_RECEIPT_OPTIONAL_PARAMS=false`
|
||||||
|
(text-only, `smppsim-textdlr`'s own style) *and* accepts non-ASCII submits, where nothing would be
|
||||||
|
left to fall back on. Not reproduced against `smppsim-textdlr` here (that variant's own C2 test
|
||||||
|
only sends ASCII), so this is inferred from the mechanism, not independently confirmed there - see
|
||||||
|
Open questions.
|
||||||
|
|
||||||
|
### `reconnect` never retries the very first connect or bind attempt
|
||||||
|
|
||||||
|
**What happened.** `client()` performs the socket connect and the initial bind directly, not
|
||||||
|
through the `ReconnectLoop`; on either failing (`ESME_RINVPASWD`, `ESME_RBINDFAIL`, connection
|
||||||
|
refused, …) it calls `session.close()` and returns `{ err }` with no session at all - the
|
||||||
|
`reconnect` option is never consulted. Confirmed with a wrong password and with a closed port
|
||||||
|
against `smppsim` (`smppsim - C11 …`, both "one attempt, no retry" sub-tests): each is exactly one
|
||||||
|
bind attempt, logged once, no matter how long the test then waits. `ReconnectLoop.schedule()` only
|
||||||
|
runs from `onClose()`/a failed `comeBackUp()` after a session has been up at least once - confirmed
|
||||||
|
separately by binding once, then mutating the same `options` object's `password` before dropping
|
||||||
|
the live link: the loop *does* retry with proper backoff then (`smppsim - C11 …, "a rebind refused
|
||||||
|
after a live link drops"`: 2-6 attempts over 12s, each gap ≥150ms).
|
||||||
|
|
||||||
|
**What the spec/README say.** Neither documents this boundary explicitly; `README.md`'s `reconnect`
|
||||||
|
entry reads as covering any drop uniformly. Given hard rule/goal 4 ("no bind flooding"), never
|
||||||
|
retrying a cold failure is arguably the safer default - a bad password retried forever would flood
|
||||||
|
exactly as much as this avoids it entirely - but it means a client started before its peer's
|
||||||
|
listener is up gets one shot and gives up for good, which a caller relying on `reconnect` for that
|
||||||
|
race would not expect.
|
||||||
|
|
||||||
|
**Reproducer.** `client({ host, password: 'wrong', reconnect: { minDelay: 1000, maxDelay: 4000 } })`
|
||||||
|
against `smppsim` (valid `system_id`, wrong `password`): resolves `{ err }` once, no `session`; a
|
||||||
|
`log.info` capture shows exactly one `'client - bind refused'` line even after a further 2s wait.
|
||||||
|
|
||||||
|
**Severity.** Low / informational - plausibly intentional, not previously written down anywhere
|
||||||
|
`git grep`-able in this repo.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- **`registered_delivery` 0x11 (final + intermediate together) never produces a final receipt.**
|
||||||
|
SMPPSim's own user guide (v2.5 release notes) says "Set to 0x11 for both intermediate
|
||||||
|
notification and final delivery receipts." `LifeCycleManager.setState()` tests
|
||||||
|
`registered_delivery_flag == 1` or `== 2` by exact integer equality rather than a bitmask
|
||||||
|
(`InboundQueue.addMessageState()`'s own intermediate-notification check correctly uses
|
||||||
|
`(rd & 0x10) == 0x10`), so 17 (0x11) matches neither branch: the intermediate report (esm_class
|
||||||
|
0x20, `ENROUTE`) always arrives, a final one never does, however long you wait. Confirmed by
|
||||||
|
reading `LifeCycleManager.java`, `MessageState.java` and `OutboundQueue.java`, and empirically
|
||||||
|
with a 16s wait against `smppsim-transition`.
|
||||||
|
- **Two `submit_sm`'s on the same connection without a response in between - exactly how this
|
||||||
|
library always sends a multi-segment message's segments (`Promise.all`, never one after the
|
||||||
|
previous one's response, a documented, deliberate design choice, not something to change here) -
|
||||||
|
sometimes make SMPPSim lose one of the resulting receipts or loopback echoes entirely.** Never
|
||||||
|
seen as a malformed/corrupted PDU on the wire (both kept runs, and every other run once the
|
||||||
|
harness's own UDH bug above was fixed, show zero); the PDU that should have arrived just never
|
||||||
|
does. Confirmed by direct A/B: a script sending two segments concurrently (this library's own
|
||||||
|
shape) got only one of two receipts about half the time across several tries; the same script
|
||||||
|
sending two independent `submit_sm`'s sequentially, awaiting each response before the next, never
|
||||||
|
lost one in the same number of tries. `interop-tests/smppsim.test.ts`'s own multi-segment tests
|
||||||
|
work around it by resending a fresh message (up to 10 times) until every segment's receipt and,
|
||||||
|
where relevant, the loopback reassembly are all present in one attempt - see
|
||||||
|
`sendUntilAllDlrsArrive`/`sendUntilComplete`/`dlrLooksIntact` there. The mechanism is unconfirmed
|
||||||
|
(see Open questions).
|
||||||
|
- **`bind_resp` never carries `sc_interface_version`, whatever the ESME declared.** No
|
||||||
|
`BindTransceiverResp`/`BindReceiverResp`/`BindTransmitterResp` class sets that TLV (confirmed
|
||||||
|
against source). `@larvit/smpp`'s client therefore always records
|
||||||
|
`peerInterfaceVersion === 0x00` (undeclared) and `acceptsOptionalParams() === false` against this
|
||||||
|
peer, whether the client declared 0x34 or 0x50 - yet `smppsim` (with
|
||||||
|
`DELIVERY_RECEIPT_OPTIONAL_PARAMS=true`) still sends `receipted_message_id`/`message_state` TLVs
|
||||||
|
on every receipt regardless, because that gate reads the *client's own declared* version from the
|
||||||
|
bind PDU it received, not anything it echoes back. Confirmed for both 0x34 and 0x50 (`smppsim -
|
||||||
|
C15 …`).
|
||||||
|
- **`DelayedDrQueue`'s own poll loop is hardcoded to 5000ms** (`private static final int period =
|
||||||
|
5000;`, not a props knob - `DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD` is a different queue, for
|
||||||
|
redelivery after `ESME_RMSGQFUL`). `DELAY_DELIVERY_RECEIPTS_BY` is a floor, not the delay: a
|
||||||
|
receipt configured for an 8000ms delay can take up to ~13000ms in practice. Confirmed by direct
|
||||||
|
measurement (an 11s wait saw nothing; 16s did).
|
||||||
|
- **Message ids are decimal, a per-process global counter** (`message_id++`, not per-connection),
|
||||||
|
starting at 0 unless `START_MESSAGE_ID_AT`/`MESSAGE_ID_PREFIX` are set (neither is here) - matches
|
||||||
|
the research notes; `submit_sm_resp` and every receipt spelling (TLV and body `id:`) agree, so no
|
||||||
|
`smsIdFormat` was needed anywhere in this suite.
|
||||||
|
- **The receipt body's echoed-message field is `Text:` (capitalised, not `text:`)** and carries up
|
||||||
|
to the first 20 bytes of the original message when it is non-empty - never the empty `text:` some
|
||||||
|
other peers (documented: LINK) send. `@larvit/smpp`'s parsing is already case-insensitive, so this
|
||||||
|
needed no special handling.
|
||||||
|
- **`outbind()` closes the socket immediately after writing, without reading anything back.**
|
||||||
|
Confirmed against source (`Smsc.outbind()`: `out.write(...); out.flush(); out.close(); s.close();`
|
||||||
|
with no read in between) and on the wire (`smppsim-outbind - C18 …`): our server's
|
||||||
|
`incomingPduObj` sees the `outbind` (`system_id: smppclient1`), `onRequest` tries to answer
|
||||||
|
`ESME_RINVBNDSTS` and fails before writing anything (`outbind_resp` is not a defined command, so
|
||||||
|
`pduReturn` errors first) - `sessionError` fires with `"outbind" has no response command`, no PDU
|
||||||
|
reaches the wire, matching target 6. Whether our socket sees SMPPSim's own close land as a clean
|
||||||
|
`close` was observed but not asserted against, per the task ("record, do not judge").
|
||||||
|
- **`outbind` fires the moment SMPPSim has an MO to deliver and no receiver is bound** - not on a
|
||||||
|
timer. `InboundQueue.processQueue()`'s wait/notify wakes on `iq.addMessage(...)`, and moves
|
||||||
|
straight to `PENDING_QUEUE` + `outbind()` when `getReceiverBoundCount() == 0`; the MO Injection
|
||||||
|
endpoint (`GET /inject_mo?source_addr=…&destination_addr=…&short_message=…`, not `/inject_mo.htm`
|
||||||
|
as the docs might suggest - that path is the *form*, `/inject_mo` is the handler) works with zero
|
||||||
|
ESMEs ever bound, which is what let this test trigger `outbind` deterministically instead of
|
||||||
|
racing `DELIVERY_MESSAGES_PER_MINUTE` against container startup.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether the `data_coding`-decodes-the-receipt-body defect also loses receipts against a peer
|
||||||
|
answering `DELIVERY_RECEIPT_OPTIONAL_PARAMS=false` (no TLV fallback) - `smppsim-textdlr`'s own
|
||||||
|
scenario here only exercises an ASCII message, so the compounding case (non-ASCII + no TLVs) is
|
||||||
|
untested.
|
||||||
|
- The exact mechanism behind the occasional lost receipt/loopback echo under concurrent
|
||||||
|
`submit_sm`'s. A plausible read of SMPPSim's threading (the connection-handler thread writing
|
||||||
|
`submit_sm_resp`s and loopback echoes, a separate `OutboundQueue`/`InboundQueue` thread writing
|
||||||
|
receipts, both against the same socket with no synchronisation found in
|
||||||
|
`StandardConnectionHandler`) would predict corruption, not a clean loss - but no malformed PDU
|
||||||
|
was ever observed for a genuine SMPPSim-authored frame in this suite, so that read is unconfirmed
|
||||||
|
and the actual mechanism (dropped at the SMPPSim side before it ever reaches the wire, versus
|
||||||
|
something on our side discarding a well-formed PDU) is still open.
|
||||||
|
- Phase 2's own instructions call for `smppsim-accepted`/`-rejected` as "similar single-state
|
||||||
|
variants... if cheap"; both were cheap and are included, alongside `-undeliv`.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SMPPSim's own site (seleniumsoftware.com) answers 522; kwahome/smpp-sim-docker vendors the last
|
||||||
|
# surviving 2.6.11 build (see ../../research/smsc-simulators.md #1). Cloned at a pinned commit here
|
||||||
|
# rather than vendored into this repo.
|
||||||
|
FROM debian:13.2-slim AS source
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG SMPPSIM_COMMIT=bc299828af9046ab290da3b4957dfbc472f02bfb
|
||||||
|
|
||||||
|
RUN git clone https://github.com/kwahome/smpp-sim-docker.git /src \
|
||||||
|
&& cd /src && git checkout "${SMPPSIM_COMMIT}"
|
||||||
|
|
||||||
|
# SMPPSim 2.6.x targets Java 6/7; runs unmodified on this 8 JRE.
|
||||||
|
FROM eclipse-temurin:8u452-b09-jre
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=source /src/SMPPSim/smppsim.jar ./smppsim.jar
|
||||||
|
COPY --from=source /src/SMPPSim/lib ./lib
|
||||||
|
COPY --from=source /src/SMPPSim/www ./www
|
||||||
|
COPY --from=source /src/SMPPSim/mo ./mo
|
||||||
|
COPY --from=source /src/SMPPSim/conf/logging.properties ./conf/logging.properties
|
||||||
|
COPY *.props ./conf/
|
||||||
|
|
||||||
|
EXPOSE 2775 8884
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-Djava.net.preferIPv4Stack=true", "-Djava.util.logging.config.file=conf/logging.properties", "-jar", "smppsim.jar"]
|
||||||
|
CMD ["conf/smppsim.props"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=0
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=100
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=8000
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=true
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=node
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=100
|
||||||
|
MAX_TIME_ENROUTE=200
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=200
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=0
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=100
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=false
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=0
|
||||||
|
PERCENTAGE_UNDELIVERABLE=100
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=false
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
SMPP_PORT=2775
|
||||||
|
SMPP_CONNECTION_HANDLERS=20
|
||||||
|
CONNECTION_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardConnectionHandler
|
||||||
|
PROTOCOL_HANDLER_CLASS=com.seleniumsoftware.SMPPSim.StandardProtocolHandler
|
||||||
|
LIFE_CYCLE_MANAGER=com.seleniumsoftware.SMPPSim.LifeCycleManager
|
||||||
|
MESSAGE_STATE_CHECK_FREQUENCY=200
|
||||||
|
MAX_TIME_ENROUTE=300
|
||||||
|
DELAY_DELIVERY_RECEIPTS_BY=0
|
||||||
|
PERCENTAGE_THAT_TRANSITION=100
|
||||||
|
PERCENTAGE_DELIVERED=100
|
||||||
|
PERCENTAGE_UNDELIVERABLE=0
|
||||||
|
PERCENTAGE_ACCEPTED=0
|
||||||
|
PERCENTAGE_REJECTED=0
|
||||||
|
DISCARD_FROM_QUEUE_AFTER=60000
|
||||||
|
HTTP_PORT=8884
|
||||||
|
HTTP_THREADS=4
|
||||||
|
DOCROOT=www
|
||||||
|
AUTHORISED_FILES=/css/style.css,/index.htm,/inject_mo.htm,/favicon.ico,/images/logo.gif,/images/dots.gif,/user-guide.htm,/images/homepage.gif,/images/inject_mo.gif
|
||||||
|
INJECT_MO_PAGE=/inject_mo.htm
|
||||||
|
SYSTEM_IDS=smppclient1,smppclient2,smppclient3
|
||||||
|
PASSWORDS=password,password,password
|
||||||
|
OUTBIND_ENABLED=false
|
||||||
|
OUTBIND_ESME_IP_ADDRESS=127.0.0.1
|
||||||
|
OUTBIND_ESME_PORT=2776
|
||||||
|
OUTBIND_ESME_SYSTEMID=smppclient1
|
||||||
|
OUTBIND_ESME_PASSWORD=password
|
||||||
|
DELIVERY_MESSAGES_PER_MINUTE=0
|
||||||
|
DELIVER_MESSAGES_FILE=mo/deliver_messages.csv
|
||||||
|
LOOPBACK=true
|
||||||
|
ESME_TO_ESME=false
|
||||||
|
OUTBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
INBOUND_QUEUE_MAX_SIZE=1000
|
||||||
|
DELAYED_INBOUND_QUEUE_PROCESSING_PERIOD=60
|
||||||
|
DELAYED_INBOUND_QUEUE_MAX_ATTEMPTS=50
|
||||||
|
DECODE_PDUS_IN_LOG=true
|
||||||
|
CAPTURE_SME_BINARY=false
|
||||||
|
CAPTURE_SME_BINARY_TO_FILE=sme_binary.capture
|
||||||
|
CAPTURE_SMPPSIM_BINARY=false
|
||||||
|
CAPTURE_SMPPSIM_BINARY_TO_FILE=smppsim_binary.capture
|
||||||
|
CAPTURE_SME_DECODED=false
|
||||||
|
CAPTURE_SME_DECODED_TO_FILE=sme_decoded.capture
|
||||||
|
CAPTURE_SMPPSIM_DECODED=false
|
||||||
|
CAPTURE_SMPPSIM_DECODED_TO_FILE=smppsim_decoded.capture
|
||||||
|
CALLBACK=false
|
||||||
|
CALLBACK_ID=SIM1
|
||||||
|
CALLBACK_TARGET_HOST=localhost
|
||||||
|
CALLBACK_PORT=3333
|
||||||
|
DELIVER_SM_INCLUDES_USSD_SERVICE_OP=false
|
||||||
|
DELIVERY_RECEIPT_OPTIONAL_PARAMS=true
|
||||||
|
SMSCID=SMPPSim
|
||||||
|
SIMULATE_VARIABLE_SUBMIT_SM_RESPONSE_TIMES=false
|
||||||
@@ -0,0 +1,774 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import type { Dlr } from '../src/dlr.ts';
|
||||||
|
import type { EncodingName } from '../src/defs/encodings.ts';
|
||||||
|
import type { MessageDlr } from '../src/dlr-merger.ts';
|
||||||
|
import type { PduObject } from '../src/pdu.ts';
|
||||||
|
import type { SendSmsResult } from '../src/send-sms.ts';
|
||||||
|
import type { Session } from '../src/session.ts';
|
||||||
|
import type { Sms } from '../src/sms.ts';
|
||||||
|
import { client } from '../src/client.ts';
|
||||||
|
import { closeAfter } from '../test/teardown.ts';
|
||||||
|
import { consts } from '../src/defs/constants.ts';
|
||||||
|
import { paramText } from '../src/defs/types.ts';
|
||||||
|
import { server } from '../src/server.ts';
|
||||||
|
|
||||||
|
const PEER_HOST = process.env.PEER_HOST ?? 'smppsim';
|
||||||
|
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||||
|
const TEXTDLR_HOST = process.env.TEXTDLR_HOST ?? 'smppsim-textdlr';
|
||||||
|
const TRANSITION_HOST = process.env.TRANSITION_HOST ?? 'smppsim-transition';
|
||||||
|
const UNDELIV_HOST = process.env.UNDELIV_HOST ?? 'smppsim-undeliv';
|
||||||
|
const REJECTED_HOST = process.env.REJECTED_HOST ?? 'smppsim-rejected';
|
||||||
|
const ACCEPTED_HOST = process.env.ACCEPTED_HOST ?? 'smppsim-accepted';
|
||||||
|
const DELAYED_HOST = process.env.DELAYED_HOST ?? 'smppsim-delayed';
|
||||||
|
const QUEUEFULL_HOST = process.env.QUEUEFULL_HOST ?? 'smppsim-queuefull';
|
||||||
|
const OUTBIND_HOST = process.env.OUTBIND_HOST ?? 'smppsim-outbind';
|
||||||
|
const OUTBIND_HTTP_PORT = Number(process.env.OUTBIND_HTTP_PORT ?? '8884');
|
||||||
|
const OUR_OUTBIND_PORT = Number(process.env.OUR_OUTBIND_PORT ?? '2776');
|
||||||
|
|
||||||
|
const USERNAME = 'smppclient1';
|
||||||
|
const PASSWORD = 'password';
|
||||||
|
const FROM = '46701113311';
|
||||||
|
const TO = '46709771337';
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Polls until `get()` stops returning undefined, or the budget runs out. */
|
||||||
|
async function waitFor<T>(get: () => T | undefined, budget = 3000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bind(host: string, options: Parameters<typeof client>[0] = {}): ReturnType<typeof client> {
|
||||||
|
return client({ host, password: PASSWORD, port: PEER_PORT, username: USERNAME, ...options });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GSM-7 message sized to a target septet count, always carrying the three extension-table
|
||||||
|
* characters (€, [, ]) that cost two septets each via an ESC prefix.
|
||||||
|
*/
|
||||||
|
function gsmFiller(targetSeptets: number): string {
|
||||||
|
const extension = '€[]';
|
||||||
|
|
||||||
|
return extension + 'a'.repeat(targetSeptets - extension.length * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delivery-receipt fields the wire actually carried, alongside the parsed Dlr. */
|
||||||
|
type Received = { dlr: Dlr; pduObj: PduObject };
|
||||||
|
|
||||||
|
function collectDlrs(session: Session): Received[] {
|
||||||
|
const received: Received[] = [];
|
||||||
|
|
||||||
|
session.on('dlr', (dlr, pduObj) => { received.push({ dlr, pduObj }); });
|
||||||
|
|
||||||
|
return received;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSms(session: Session): Sms[] {
|
||||||
|
const collected: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', sms => { collected.push(sms); });
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DLR_RETRY_BUDGET_MS = 3000;
|
||||||
|
const DLR_MAX_ATTEMPTS = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two concurrent submit_sm's on the same connection - which is how this library always sends a
|
||||||
|
* multi-segment message's segments, never one after the previous one's response - sometimes make
|
||||||
|
* SMPPSim interleave two PDUs it writes back without synchronising, corrupting one on the wire
|
||||||
|
* (tshark's own malformed/expert counts confirm it; see findings/02-smppsim.md). A corrupted
|
||||||
|
* receipt still reaches `dlr` with an empty or partial body. Sequential, awaited submit_sm's never
|
||||||
|
* see this.
|
||||||
|
*/
|
||||||
|
function dlrLooksIntact(received: Received | undefined): received is Received {
|
||||||
|
return received?.dlr.receipt?.stat !== undefined
|
||||||
|
&& received.pduObj.tlvs.receipted_message_id?.tagValue !== undefined
|
||||||
|
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resends a fresh message until one attempt's segments are every one matched by an intact `dlr`.
|
||||||
|
* Retrying works around the peer+library interaction above without hiding it: a scenario only
|
||||||
|
* fails here if it keeps missing well past what that alone explains.
|
||||||
|
*/
|
||||||
|
async function sendUntilAllDlrsArrive(
|
||||||
|
session: Session,
|
||||||
|
dlrs: Received[],
|
||||||
|
message: string,
|
||||||
|
encoding?: EncodingName,
|
||||||
|
): Promise<string[]> {
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
|
||||||
|
const sent = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: FROM,
|
||||||
|
message,
|
||||||
|
to: TO,
|
||||||
|
...(encoding ? { encoding } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const complete = await waitFor(
|
||||||
|
() => (sent.smsIds.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id))) ? true : undefined),
|
||||||
|
DLR_RETRY_BUDGET_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (complete) return sent.smsIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`no attempt got an intact DLR for every segment within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As above, but also waits for the loopback deliver_sm(s) to reassemble into the original text. */
|
||||||
|
async function sendUntilComplete(
|
||||||
|
session: Session,
|
||||||
|
dlrs: Received[],
|
||||||
|
sms: Sms[],
|
||||||
|
message: string,
|
||||||
|
encoding?: EncodingName,
|
||||||
|
): Promise<{ reassembled: Sms; smsIds: string[] }> {
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
|
||||||
|
const sent = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: FROM,
|
||||||
|
message,
|
||||||
|
to: TO,
|
||||||
|
...(encoding ? { encoding } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const complete = await waitFor(() => {
|
||||||
|
const allIntact = sent.smsIds.every(id => dlrLooksIntact(dlrs.find(r => r.dlr.smsId === id)));
|
||||||
|
const reassembled = sms.find(s => s.message === message);
|
||||||
|
|
||||||
|
return allIntact && reassembled ? { reassembled } : undefined;
|
||||||
|
}, DLR_RETRY_BUDGET_MS);
|
||||||
|
|
||||||
|
if (complete) return { reassembled: complete.reassembled, smsIds: sent.smsIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`no attempt got both an intact DLR per segment and a loopback reassembly within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('smppsim - C3+C7 long MT, receipts and loopback reassembly', () => {
|
||||||
|
const cases: { expectedSegments: number; label: string; message: string }[] = [
|
||||||
|
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: gsmFiller(100) },
|
||||||
|
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: gsmFiller(200) },
|
||||||
|
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: gsmFiller(400) },
|
||||||
|
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: gsmFiller(1450) },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of cases) {
|
||||||
|
test(testCase.label, async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
const { reassembled, smsIds } = await sendUntilComplete(session, dlrs, sms, testCase.message);
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, testCase.expectedSegments);
|
||||||
|
|
||||||
|
for (const id of smsIds) {
|
||||||
|
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
// TLVs and body both present, and agree - "TLV wins" is unobservable when they
|
||||||
|
// match, which is what a well-behaved SMSC gives you (C3).
|
||||||
|
assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id);
|
||||||
|
assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED);
|
||||||
|
assert.equal(received.dlr.receipt?.stat, 'DELIVRD');
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal((await reassembled.sendResp()).err, undefined);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('2-segment UCS2 with 一 and an emoji: TLVs stay right, the receipt body is corrupted', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const sms = collectSms(session);
|
||||||
|
const message = `一😀${'x'.repeat(70)}`;
|
||||||
|
|
||||||
|
let smsIds: string[] | undefined;
|
||||||
|
let reassembled: Sms | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS && !reassembled; attempt++) {
|
||||||
|
const sent: SendSmsResult = await session.sendSms(
|
||||||
|
{ dlr: true, encoding: 'UCS2', from: FROM, message, to: TO },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.equal(sent.smsIds.length, 2);
|
||||||
|
|
||||||
|
const complete = await waitFor(() => {
|
||||||
|
const tlvsIntact = sent.smsIds.every(id => {
|
||||||
|
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||||
|
|
||||||
|
return received?.pduObj.tlvs.receipted_message_id?.tagValue !== undefined
|
||||||
|
&& received.pduObj.tlvs.message_state?.tagValue !== undefined;
|
||||||
|
});
|
||||||
|
const found = sms.find(s => s.message === message);
|
||||||
|
|
||||||
|
return tlvsIntact && found ? { found } : undefined;
|
||||||
|
}, DLR_RETRY_BUDGET_MS);
|
||||||
|
|
||||||
|
if (complete) {
|
||||||
|
smsIds = sent.smsIds;
|
||||||
|
reassembled = complete.found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(smsIds);
|
||||||
|
assert.ok(reassembled, 'expected the loopback deliver_sm(s), unaffected by the defect, to reassemble');
|
||||||
|
|
||||||
|
for (const id of smsIds) {
|
||||||
|
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
// The TLVs are typed fields, unaffected by the defect below.
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
assert.equal(received.pduObj.tlvs.receipted_message_id?.tagValue, id);
|
||||||
|
assert.equal(received.pduObj.tlvs.message_state?.tagValue, consts.MESSAGE_STATE.DELIVERED);
|
||||||
|
|
||||||
|
// Defect (see findings/02-smppsim.md): SMPPSim's delivery receipt echoes the
|
||||||
|
// original submit_sm's data_coding (8, UCS2) but writes its short_message as plain
|
||||||
|
// ASCII text. pdu.ts decodes short_message for any non-UDH PDU using that same
|
||||||
|
// data_coding at parse time, so the ASCII receipt bytes are read back as UCS2 -
|
||||||
|
// every "stat:"/"id:" field becomes unrecoverable CJK-range garbage.
|
||||||
|
assert.equal(received.dlr.receipt?.stat, undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal((await reassembled.sendResp()).err, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-textdlr - C2 text-only receipts', () => {
|
||||||
|
test('no TLVs; dlr.smsId parsed from the body matches the submit_sm_resp id', async t => {
|
||||||
|
const { err, session } = await bind(TEXTDLR_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'text-only dlr', to: TO });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.equal(sent.smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = sent.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === smsId));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
assert.equal(received.pduObj.tlvs.receipted_message_id, undefined);
|
||||||
|
assert.equal(received.pduObj.tlvs.message_state, undefined);
|
||||||
|
assert.equal(received.dlr.receipt?.id, smsId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-transition - C4 intermediate then final', () => {
|
||||||
|
test('an intermediate report arrives, but registered_delivery 0x11 never gets a final one', async t => {
|
||||||
|
const { err, session } = await bind(TRANSITION_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const messageDlrs: unknown[] = [];
|
||||||
|
|
||||||
|
session.on('messageDlr', merged => { messageDlrs.push(merged); });
|
||||||
|
|
||||||
|
// registered_delivery 0x11: final (0x01) + intermediate (0x10). sendSms() only ever
|
||||||
|
// requests 0x01, so this scenario needs the raw passthrough - which also means
|
||||||
|
// dlrMerger.expect() is never called, so messageDlr cannot fire here (see findings).
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: consts.ENCODING.ASCII,
|
||||||
|
destination_addr: TO,
|
||||||
|
registered_delivery: 0x11,
|
||||||
|
short_message: Buffer.from('transition test', 'latin1'),
|
||||||
|
source_addr: FROM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
assert.ok(sent.pduObj);
|
||||||
|
|
||||||
|
const messageId = paramText(sent.pduObj.params.message_id);
|
||||||
|
|
||||||
|
assert.notEqual(messageId, '');
|
||||||
|
|
||||||
|
const intermediate = await waitFor(() => dlrs.find(r => r.dlr.smsId === messageId));
|
||||||
|
|
||||||
|
assert.ok(intermediate, 'expected an intermediate report');
|
||||||
|
assert.equal(intermediate.dlr.intermediate, true);
|
||||||
|
assert.equal(intermediate.dlr.statusMsg, 'ENROUTE');
|
||||||
|
|
||||||
|
// SMPPSim's own user guide (v2.5 release notes): "Set to 0x11 for both intermediate
|
||||||
|
// notification and final delivery receipts". Its LifeCycleManager.setState() tests
|
||||||
|
// registered_delivery_flag == 1 or == 2 by exact equality rather than a bitmask, so 0x11
|
||||||
|
// (17) matches neither branch and no final receipt is ever queued - confirmed against
|
||||||
|
// source (see findings/02-smppsim.md). Recorded, not asserted as something to fix here.
|
||||||
|
const final = await waitFor(() => dlrs.find(r => r.dlr.smsId === messageId && !r.dlr.intermediate), 3000);
|
||||||
|
|
||||||
|
assert.equal(final, undefined);
|
||||||
|
assert.equal(messageDlrs.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim single-state variants - C5 failure states', () => {
|
||||||
|
const variants: { expected: Dlr['statusMsg']; host: string; label: string }[] = [
|
||||||
|
{ expected: 'UNDELIVERABLE', host: UNDELIV_HOST, label: 'PERCENTAGE_UNDELIVERABLE=100' },
|
||||||
|
{ expected: 'REJECTED', host: REJECTED_HOST, label: 'PERCENTAGE_REJECTED=100' },
|
||||||
|
{ expected: 'ACCEPTED', host: ACCEPTED_HOST, label: 'PERCENTAGE_ACCEPTED=100' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const variant of variants) {
|
||||||
|
test(`${variant.label} maps to ${variant.expected}`, async t => {
|
||||||
|
const { err, session } = await bind(variant.host);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'failure state test', to: TO });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const [smsId] = sent.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === smsId));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, variant.expected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a 2-segment message: both segments report the same status; messageDlr never fires', async t => {
|
||||||
|
const { err, session } = await bind(UNDELIV_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const messageDlrs: MessageDlr[] = [];
|
||||||
|
|
||||||
|
session.on('messageDlr', merged => { messageDlrs.push(merged); });
|
||||||
|
|
||||||
|
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, gsmFiller(200));
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 2);
|
||||||
|
|
||||||
|
for (const id of smsIds) {
|
||||||
|
const received = dlrs.find(r => r.dlr.smsId === id);
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.dlr.statusMsg, 'UNDELIVERABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// SMPPSim assigns each segment its own, independent message_id rather than this
|
||||||
|
// library's own server's <base>-<n> convention, so DlrMerger can never recognise the
|
||||||
|
// pair as one message (README: "an SMSC that hands out unrelated ids per segment never
|
||||||
|
// fires it") - recorded here, not asserted as a defect.
|
||||||
|
await delay(500);
|
||||||
|
assert.equal(messageDlrs.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-delayed - C6 receipt delayed past a link drop', () => {
|
||||||
|
test('the merge survives a reconnect; the late receipt still reaches dlr', async t => {
|
||||||
|
const { err, session } = await bind(DELAYED_HOST, { reconnect: { maxDelay: 1000, minDelay: 200 } });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const dlrs = collectDlrs(session);
|
||||||
|
const disconnected: true[] = [];
|
||||||
|
const reconnected: true[] = [];
|
||||||
|
|
||||||
|
session.on('disconnected', () => { disconnected.push(true); });
|
||||||
|
session.on('reconnected', () => { reconnected.push(true); });
|
||||||
|
|
||||||
|
const sendStart = Date.now();
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'delayed dlr test', to: TO });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const [smsId] = sent.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
await delay(300);
|
||||||
|
session.sock.destroy();
|
||||||
|
|
||||||
|
assert.ok(await waitFor(() => (disconnected.length > 0 ? true : undefined), 2000), 'expected disconnected');
|
||||||
|
assert.ok(await waitFor(() => (reconnected.length > 0 ? true : undefined), 4000), 'expected reconnected');
|
||||||
|
|
||||||
|
// DELAY_DELIVERY_RECEIPTS_BY (8000ms) is a floor, not the actual delay: DelayedDrQueue's
|
||||||
|
// own poll loop only wakes every 5000ms (hardcoded, not a props knob), so delivery can
|
||||||
|
// land up to ~13s after submission - confirmed against source and empirically.
|
||||||
|
const remaining = Math.max(1000, 16_000 - (Date.now() - sendStart));
|
||||||
|
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === smsId), remaining);
|
||||||
|
|
||||||
|
assert.ok(received, 'expected the delayed receipt to still arrive on the new link');
|
||||||
|
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C11 bind refusal and reconnect backoff', () => {
|
||||||
|
test('wrong password on the very first bind: one attempt, no retry', async () => {
|
||||||
|
const refusals: { cmdStatus: unknown }[] = [];
|
||||||
|
const log = {
|
||||||
|
debug: () => undefined,
|
||||||
|
error: () => undefined,
|
||||||
|
info: (msg: string, metadata?: Record<string, boolean | number | string>) => {
|
||||||
|
if (msg === 'client - bind refused') refusals.push({ cmdStatus: metadata?.cmdStatus });
|
||||||
|
},
|
||||||
|
verbose: () => undefined,
|
||||||
|
warn: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { err, session } = await bind(PEER_HOST, { log, password: 'wrong', reconnect: { maxDelay: 4000, minDelay: 1000 } });
|
||||||
|
|
||||||
|
assert.ok(err);
|
||||||
|
assert.equal(session, undefined);
|
||||||
|
await delay(2000);
|
||||||
|
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
|
||||||
|
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a closed port on the very first connect: one attempt, no retry', async () => {
|
||||||
|
const { err, session } = await bind(PEER_HOST, { port: 46775, reconnect: { maxDelay: 4000, minDelay: 1000 } });
|
||||||
|
|
||||||
|
assert.ok(err);
|
||||||
|
assert.equal(session, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rebind refused after a live link drops: backs off, never floods', async t => {
|
||||||
|
const options: Parameters<typeof client>[0] = {
|
||||||
|
host: PEER_HOST,
|
||||||
|
password: PASSWORD,
|
||||||
|
port: PEER_PORT,
|
||||||
|
reconnect: { maxDelay: 4000, minDelay: 1000 },
|
||||||
|
username: USERNAME,
|
||||||
|
};
|
||||||
|
const { err, session } = await client(options);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const disconnectedAt: number[] = [];
|
||||||
|
|
||||||
|
session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
|
||||||
|
// Mutates the same object reference the reconnect loop's onConnected closure reads, so
|
||||||
|
// every rebind attempt from here on is refused - see interop-tests/findings/02-smppsim.md.
|
||||||
|
options.password = 'wrong-after-drop';
|
||||||
|
session.sock.destroy();
|
||||||
|
|
||||||
|
await delay(12_000);
|
||||||
|
|
||||||
|
assert.ok(disconnectedAt.length >= 2 && disconnectedAt.length <= 6, `expected a handful of attempts, got ${String(disconnectedAt.length)}`);
|
||||||
|
|
||||||
|
for (let i = 1; i < disconnectedAt.length; i++) {
|
||||||
|
const previous = disconnectedAt[i - 1];
|
||||||
|
const current = disconnectedAt[i];
|
||||||
|
|
||||||
|
assert.ok(previous !== undefined && current !== undefined);
|
||||||
|
assert.ok(current - previous >= 150, 'expected each retry to wait at least close to minDelay');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-queuefull - C12 ESME_RMSGQFUL', () => {
|
||||||
|
test('refuses when the queue is full; the session stays bound; a later send succeeds once it drains', async t => {
|
||||||
|
const { err, session } = await bind(QUEUEFULL_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const first = await session.sendSms({ from: FROM, message: 'occupies the one queue slot', to: TO });
|
||||||
|
|
||||||
|
assert.equal(first.err, undefined);
|
||||||
|
|
||||||
|
const second = await session.sendSms({ from: FROM, message: 'should be refused', to: TO });
|
||||||
|
|
||||||
|
assert.ok(second.err);
|
||||||
|
assert.match(second.err.message, /ESME_RMSGQFUL/);
|
||||||
|
|
||||||
|
const keepalive = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(keepalive.err, undefined);
|
||||||
|
assert.ok(keepalive.pduObj);
|
||||||
|
assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
let drained: Awaited<ReturnType<typeof session.sendSms>> | undefined;
|
||||||
|
|
||||||
|
while (!drained && Date.now() < deadline) {
|
||||||
|
const attempt = await session.sendSms({ from: FROM, message: 'after drain', to: TO });
|
||||||
|
|
||||||
|
if (!attempt.err) drained = attempt;
|
||||||
|
else await delay(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(drained, 'expected a later send to succeed once the queue drained');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C13 maxOutstanding 1 with 10 parallel sends', () => {
|
||||||
|
test('every send is answered, in order, none lost', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST, { maxOutstanding: 1 });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 10 }, async (_unused, index) => session.sendSms({
|
||||||
|
from: FROM,
|
||||||
|
message: `order test ${String(index)}`,
|
||||||
|
to: TO,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids: number[] = [];
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
assert.equal(result.err, undefined);
|
||||||
|
assert.equal(result.smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = result.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
ids.push(Number(smsId));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'expected 10 distinct message ids, none lost or duplicated');
|
||||||
|
|
||||||
|
for (let i = 1; i < ids.length; i++) {
|
||||||
|
const previous = ids[i - 1];
|
||||||
|
const current = ids[i];
|
||||||
|
|
||||||
|
assert.ok(previous !== undefined && current !== undefined);
|
||||||
|
assert.ok(current > previous, 'expected ids in call order, proving maxOutstanding:1 serialised the sends');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C15 bind version negotiation', () => {
|
||||||
|
for (const interfaceVersion of [0x34, 0x50]) {
|
||||||
|
test(`interfaceVersion 0x${interfaceVersion.toString(16)}: SMPPSim's bind_resp never declares a version`, async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST, { interfaceVersion });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
// Confirmed from source (no BindXResp class ever sets sc_interface_version): SMPPSim
|
||||||
|
// never declares its own version, whatever we declared - so acceptsOptionalParams()
|
||||||
|
// reads it as pre-3.4, even though it happily sends us TLVs (see C3).
|
||||||
|
assert.equal(session.peerInterfaceVersion, 0x00);
|
||||||
|
assert.equal(session.acceptsOptionalParams(), false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim - C17 encodings round trip over loopback', () => {
|
||||||
|
test('Latin-1 (å ä ö)', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
await session.sendSms({ encoding: 'LATIN1', from: FROM, message: 'å ä ö', to: TO });
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === 'å ä ö'));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.flash, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UCS-2', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
await session.sendSms({ encoding: 'UCS2', from: FROM, message: 'ucs2 round trip', to: TO });
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === 'ucs2 round trip'));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.flash, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flash (data_coding records the message-class group)', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
|
||||||
|
await session.sendSms({ flash: true, from: FROM, message: 'flash test', to: TO });
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === 'flash test'));
|
||||||
|
|
||||||
|
assert.ok(received);
|
||||||
|
assert.equal(received.flash, true);
|
||||||
|
assert.equal(received.pduObjs[0]?.params.data_coding, 0x10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a raw submit_sm with data_coding 0xF0 is not read as flash', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
const body = 'message class test';
|
||||||
|
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: 0xF0,
|
||||||
|
destination_addr: TO,
|
||||||
|
short_message: Buffer.from(body, 'latin1'),
|
||||||
|
source_addr: FROM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === body));
|
||||||
|
|
||||||
|
assert.ok(received, 'expected the 0xF0-coded loopback message to arrive');
|
||||||
|
assert.equal(received.flash, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a raw submit_sm with 8-bit binary and a UDH (esm_class 0x40)', async t => {
|
||||||
|
const { err, session } = await bind(PEER_HOST);
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const sms = collectSms(session);
|
||||||
|
// A UDH carrying no recognised concatenation IE (0x00/0x08): one element in GSM 03.40's
|
||||||
|
// reserved-for-future-use range (0x70), so Wireshark's gsm_sms_ud dissector - which
|
||||||
|
// validates the *typed* IEs' own lengths (0x01 "Special SMS Message Indication" must be
|
||||||
|
// 2 bytes, flagged malformed otherwise) - passes it through as opaque, unparsed data.
|
||||||
|
const udh = Buffer.from([0x03, 0x70, 0x01, 0xAA]);
|
||||||
|
const payload = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]);
|
||||||
|
|
||||||
|
const sent = await session.send({
|
||||||
|
cmdName: 'submit_sm',
|
||||||
|
params: {
|
||||||
|
data_coding: consts.ENCODING.BINARY,
|
||||||
|
destination_addr: TO,
|
||||||
|
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
|
||||||
|
short_message: Buffer.concat([udh, payload]),
|
||||||
|
source_addr: FROM,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const expected = payload.toString('latin1');
|
||||||
|
const received = await waitFor(() => sms.find(s => s.message === expected));
|
||||||
|
|
||||||
|
assert.ok(received, 'expected the UDH-stripped, Latin-1-decoded payload to arrive unchanged');
|
||||||
|
assert.equal(received.flash, false);
|
||||||
|
assert.deepEqual(Buffer.from(received.message, 'latin1'), payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smppsim-outbind - C18 SMSC-initiated outbind', () => {
|
||||||
|
test('arrives with no bogus response PDU; sessionError names it', async t => {
|
||||||
|
const { err, server: smpp } = await server({ port: OUR_OUTBIND_PORT });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(smpp);
|
||||||
|
closeAfter(t, smpp);
|
||||||
|
|
||||||
|
const incoming: PduObject[] = [];
|
||||||
|
const sessionErrors: Error[] = [];
|
||||||
|
const closes: true[] = [];
|
||||||
|
|
||||||
|
smpp.on('session', session => {
|
||||||
|
session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
|
||||||
|
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
|
||||||
|
session.on('close', () => { closes.push(true); });
|
||||||
|
});
|
||||||
|
|
||||||
|
const injected = await fetch(`http://${OUTBIND_HOST}:${String(OUTBIND_HTTP_PORT)}/inject_mo?${
|
||||||
|
new URLSearchParams({
|
||||||
|
destination_addr: TO,
|
||||||
|
short_message: 'trigger outbind',
|
||||||
|
source_addr: FROM,
|
||||||
|
}).toString()
|
||||||
|
}`);
|
||||||
|
|
||||||
|
assert.equal(injected.status, 200);
|
||||||
|
|
||||||
|
const outbindPdu = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'outbind'), 10_000);
|
||||||
|
|
||||||
|
assert.ok(outbindPdu, 'expected SMPPSim to connect to our server() and send outbind');
|
||||||
|
assert.equal(outbindPdu.params.system_id, 'smppclient1');
|
||||||
|
|
||||||
|
const sessionError = await waitFor(() => sessionErrors[0], 2000);
|
||||||
|
|
||||||
|
assert.ok(sessionError, 'expected sessionError: outbind has no response command to send back');
|
||||||
|
assert.match(sessionError.message, /outbind/);
|
||||||
|
|
||||||
|
// Recorded, not asserted either way (the task: "record, do not judge"): SMPPSim's own
|
||||||
|
// outbind() closes its end right after writing, before waiting for anything back.
|
||||||
|
await waitFor(() => (closes.length > 0 ? true : undefined), 1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user