Files
smpp-js/interop-tests/PLAN.md
T

19 KiB
Raw Blame History

Interoperability test plan for 1.0.0

Pre-release working file, like todo.md: it turns into interop-tests/README.md once the harness below exists, and is deleted with it otherwise.

Purpose

Find where the rewrite disagrees with SMPP as real peers run it. The suite cross-checks one independent implementation, node-smpp, in both directions and over a live session. Everything here adds peers written by other people, in other languages, with other opinions about the spec — and faults the suite's own dummy peers never inject.

Baseline

docker compose run --rm node npm test on typescript at 9ca83ac, 2026-09-05: 303 pass, 0 fail, lint and typecheck clean.

Targets read from the code

Ranked by how likely a real peer is to trip it. Each is a scenario in the matrix below.

  1. One unreadable PDU drops the link. pdu-transport.ts routes every codec error — an unknown command id, a TLV the codec refuses, a malformed body — to the teardown a framing error takes, although command_length was honoured and the stream is still in sync. One vendor-specific PDU or odd TLV from an SMSC becomes a reconnect loop. Spec: generic_nack with ESME_RINVCMDID for an unknown command, a *_resp with ESME_RINVTLVSTREAM for a bad TLV, and carry on.
  2. message_payload is never read. incoming-requests.ts takes the text from short_message only. A deliver_sm with sm_length 0 and the body in message_payload — how several SMSCs carry a long MO, Melrose Labs and Telia among the documented ones — arrives as an empty message.
  3. sar_* segmentation is never read. Reassembly keys on the UDH only; segments carried as sar_msg_ref_num/sar_total_segments/sar_segment_seqnum reach the application as fragments. Jasmin's default MT segmentation is SAR.
  4. data_sm is refused with data_sm_resp ESME_RINVCMDID. CM.com documents receipts on data_sm as a configuration; Jasmin's DLR thrower can use it too.
  5. Long messages go out one way only: UDH with an 8-bit reference and esm_class 0x40. No sar_*, no message_payload, no 16-bit reference. LINK Mobility wants exactly this and rejects the other two; Route Mobile and Kaleyra document 0x43 and validate UDH length strictly. Acceptable for 1.0 if documented — but find out which peers refuse it.
  6. Known-but-unhandled commands (query_sm, cancel_sm, replace_sm, submit_multi, alert_notification, outbind) get a *_resp carrying ESME_RINVCMDID. Check strict clients accept that, and that alert_notification and outbind, which have no response PDU, do not produce a bogus one.
  7. Receipt text parsing is a regex per field. Real formats to verify against: LINK writes sub:000 dlvrd:000 and an empty text:; Vonage writes stat:FAILED, outside the spec's table, and one receipt per segment; tyntec sends a buffered receipt and then a final one for the same id; Infobip writes stat:ENROUTE in an ordinary receipt; Telesign gives only segment 1 a message_id. Also: field order, id: hex vs decimal, dates with and without seconds, no id:.
  8. Bind version negotiation. A peer that omits sc_interface_version is treated as pre-3.4 and sent no TLVs. Right for a client; on the server side an ESME declaring 0x33 (Kannel interface-version = "33") must get text-only receipts and still correlate them.
  9. Unbind. Most SMSCs close the socket instead of answering; close-after-unbind is read as clean. Verify no sessionError noise and no reconnect attempt. Telia requires an unbind before the TCP close — check what close() puts on the wire.
  10. Reconnect against a peer that refuses the bind (ESME_RBINDFAIL, ESME_RINVPASWD, ESME_RALYBND — Vonage's answer to a third bind — or TCP refused) must back off, never flood.
  11. Window. Telenor's default window is 1; Kannel's max-pending-submits is 10. A peer enforcing 1, or closing on overrun, must not lose or duplicate.
  12. Flash is detected on coding group 0x10 only; the 0xF0 GSM message-class group is not read as flash. Minor, but it is what sms.flash promises.

Counterparts

Everything runs in Docker with full patch-version pins, resolved at setup; hosted services are manual runs, never CI. Tags below were current on 2026-09-05.

SMSC side — our client binds to them

Peer Why it earns a slot Run Knobs that matter
Jasmin 0.11.0 — Python/Twisted, a production gateway, not a toy Independent codec (smpp.pdu), SAR segmentation by default, UUID message ids, a real DLR pipeline that can use data_sm jookies/jasmin:0.11.0 + redis:8.8.2-alpine + rabbitmq:4.3.5-management-alpine (env REDIS_CLIENT_HOST, AMQP_BROKER_HOST; fall back to redis:7.4.x / rabbitmq:3.13.x if 0.11.0 balks). User, group, mtrouter and morouter over jcli on 8990 [smpp-server]: enquireLinkTimerSecs 30, inactivityTimerSecs 300, responseTimerSecs 60; submit_throughput for throttling; dlr_level
SMPPSim 2.6.x — Java, the classic Richest fault injection of anything open: per-state receipt percentages, delayed and intermediate receipts, queue-full, LOOPBACK (echoes our submit_sm back as deliver_sm), SMSC-initiated outbind, receipts with or without TLVs, its own decoded PDU log Vendor site is down (522); build from kwahome/smpp-sim-docker and tag locally. Port 2775, HTTP 8884, smppclient1/password DELIVERY_RECEIPT_OPTIONAL_PARAMS (off = Kannel-style text-only receipts), PERCENTAGE_DELIVERED/UNDELIVERABLE/ACCEPTED/REJECTED, PERCENTAGE_THAT_TRANSITION, MAX_TIME_ENROUTE, DELAY_DELIVERY_RECEIPTS_BY, OUTBOUND_QUEUE_MAX_SIZE, OUTBIND_ENABLED, CAPTURE_*_DECODED_TO_FILE
ukarim/smscsim 0.2.0 — Go Zero setup; MO injection from a web page; the smoke test ukarim/smscsim:0.2.0, ports 2775 and 12775, no auth FAILED_SUBMITS=1 fails even sequence numbers and undelivers odd ones. No PDU validation, so it proves nothing about strictness
Melrose Labs SMSC Simulator — hosted, closed A peer nobody here can special-case; TLS 1.2 on 8775 with a public CA; message_payload on deliver_sm; 64-char ids; SMPP 3.3/3.4/5; sends enquire_link itself after 45 s idle; rejects a destination under 8 digits smscsim.melroselabs.com:2775, free developer account; MO by writing the bound system_id's digits into the destination address 100 SMS/s cap; delivered-only receipts on the free tier, TLVs plus text; MO echoes the submit's data_coding
smscsim.smpp.org — hosted Second closed peer for the same assertions port 2775, 2 SMS/s cap
jsmpp SMPPServerSimulator — Java Strict PDU validation on the SMSC side, from the best-maintained SMPP codebase found (pushed 2026-06) Build from opentelecoms-org/jsmpp jsmpp-examples; no image Only if the Java clients below leave a gap

Skipped, with the reason: Kannel's opensmppbox is not in any package and last saw a commit in 2014; fakesmsc does not speak SMPP; OsmoMSC and Restcomm need a telecom stack for no extra coverage.

ESME side — they bind to our server

Peer Why it earns a slot Run Knobs that matter
Kannel 1.4.5 bearerbox + smsbox — C The most deployed real ESME there is; parses our receipts with the parser most operators' customers run; declares 3.4 or 3.3 on demand debian:bookworm-20260824-slim + apt-get install kannel (1.4.5-12). Submit over smsbox HTTP sendsms; MO out to a tiny HTTP receiver through an sms-service get-url group = smsc, smsc = smpp: interface-version = "34" or "33", msg-id-type, max-pending-submits (1 and 10), enquire-link-interval (30), wait-ack (60) with wait-ack-expire, transceiver-mode, alt-charset, dlr-mask on sendsms
jsmpp — Java, active Strict, low-level: the caller builds UDH, sar_* or message_payload bytes by hand, so it is the tool for targets 2, 3, 5 and 6. Assumes 3.4 when sc_interface_version is absent; crashes on an unnameable one Maven build in a pinned JDK image; no published image interface_version, raw optional parameters, query_sm/cancel_sm/replace_sm calls
Cloudhopper (fizzed fork) — Java/Netty The windowing client: setWindowSize, setRequestExpiryTimeout, setWindowMonitorInterval; async submits; an SSL demo Maven build; make client, make ssl-client Window 1, 10, 50 against a slow sms listener; request expiry shorter than our response
python-smpplib 2.2.4 — Python Sends GSM 7-bit unpacked with 0x1B escapes, UDH long messages, reactive enquire_link; the easiest peer to script, so also the driver for the server-side matrix python:3.12.14-slim-bookworm + pip install smpplib==2.2.4 interface_version kwarg, auto_send_enquire_link, make_parts_encoded
php-smpp (alexandr-mironov fork) — PHP Cannot bind transceiver, so it forces separate TX and RX binds — the direction-enforcement path; three long-message modes from one client: CSMS_16BIT_TAGS, CSMS_PAYLOAD, CSMS_8BIT_UDH php:8.x-cli, vendored; no licence declared, so test-only The three CSMS modes; submit_sm on the RX bind
Jasmin smppccm — Python/Twisted A production gateway as the ESME: its own reconnect, elink_interval, receipt parsing of what we send, dlr_msgid Same stack as above; smppccm -a pointing at our server, mtrouter, HTTP /send elink_interval, con_loss_retry/con_loss_delay, coding, dlr_msgid, submit_throughput
smppload 2.5.3 — Erlang The only free, scriptable load generator with UDH long messages: -r rps, -T threads, -c count, -D receipts, -C data_coding Hand-built OTP image; expect build friction (issue #8) No real window — in-flight is threads × rps — and no enquire_link at all, which is itself a real-world shape
vponomarev/libsmpp smpp-dumb-client — Go The one load tool with an enforced bounded window (generator.window) Go build stage, YAML config rate, window, count, stayConnected
fiorix/go-smpp cmd/sms — Go One-shot bind + submit + receive from a fourth language; --tls Go build stage

Validator — always on

tshark decodes every PDU independently of both ends, including TLVs and UDH/SAR reassembly, and flags malformed ones. nicolaka/netshoot:v0.16, or debian:13.6-slim + tshark (4.4.18), as a sidecar on the compose network:

dumpcap -i any -f 'tcp port 2775' -w capture.pcapng
tshark -r capture.pcapng -d tcp.port==2775,smpp -Y smpp -T json > capture.json

Assert: no _ws.malformed, no _ws.expert at error severity, every TLV decoded by name, UDH totals consistent. Run it under the existing suite too — it is the cheapest independent check of every byte we emit. gurk4n's browser decoder and SMPPSim's CAPTURE_*_DECODED_TO_FILE are the manual fallbacks.

Scenario matrix

Every scenario runs with the tshark sidecar. "Fixture" means a raw-socket peer in our own suite, because no open implementation emits that shape on demand; those scenarios are unit tests derived from real-world documentation, not interop runs, and belong in test/.

Our client against an SMSC

# Scenario Assert Peers
C1 Bind each type, enquire_link both ways, unbind Bound; SMSC-initiated enquire_link answered; close after our unbind is clean, no reconnect all SMSC peers; Melrose (45 s idle), Jasmin (30 s)
C2 Text-only receipts, no TLVs dlr.smsId equals the submit_sm_resp id; statusMsg right; messageDlr merges per segment SMPPSim with DELIVERY_RECEIPT_OPTIONAL_PARAMS off
C3 Receipts with receipted_message_id + message_state Same, and TLV wins over body when both present Jasmin, Melrose, SMPPSim TLVs on
C4 Intermediate then final receipt First arrives intermediate: true and never counts in messageDlr; final settles SMPPSim PERCENTAGE_THAT_TRANSITION 100, MAX_TIME_ENROUTE
C5 Failure states UNDELIV, REJECTD, EXPIRED, ACCEPTD mapped; messageDlr carries the worst segment SMPPSim percentages; Melrose dedicated tier
C6 Receipt delayed past a link drop Merge survives the reconnect; late receipt still reaches dlr SMPPSim DELAY_DELIVERY_RECEIPTS_BY + kill the TCP link
C7 Long MT: GSM with extension chars, UCS-2 with emoji, 2/3/10 segments One id per segment; peer reassembles (loopback or MO route shows the whole text); receipt per segment SMPPSim LOOPBACK, Jasmin MO route, Melrose
C8 Long MO as UDH 8-bit, UDH 16-bit, sar_*, message_payload One sms with the whole text in every spelling (targets 2, 3) Jasmin (SAR), Melrose (message_payload), SMPPSim loopback (UDH 8), fixture (UDH 16)
C9 MO or receipt on data_sm Not refused; reaches sms/dlr (target 4) Jasmin if configurable, else fixture
C10 Unknown command id, unknown or malformed TLV, vendor TLV, zero-length integer TLV from the SMSC Link stays up; generic_nack/*_resp with the right status; sessionError logged once (target 1) fixture
C11 Bind refused: bad password, ESME_RALYBND, TCP refused Backoff 1 s → 30 s, one attempt per interval, no flood (target 10) Jasmin bad creds, SMPPSim SYSTEM_IDS, closed port
C12 ESME_RTHROTTLED and ESME_RMSGQFUL on submit sendSms returns err naming the status; session stays bound; next send works Jasmin submit_throughput, SMPPSim queue sizes, smscsim FAILED_SUBMITS
C13 Slow SMSC and a full window maxOutstanding queues, nothing overruns; close() drains; responseTimeout fires as documented (target 11) Jasmin responseTimerSecs, SMPPSim delays
C14 TLS bind against a public CA Handshake, bind, send, receipt Melrose 8775
C15 interfaceVersion 0x50 and a peer answering 3.3 or no sc_interface_version Bound; no TLVs sent to a pre-3.4 peer; TLVs sent to 3.4+ (target 8) Melrose (v5), SMPPSim, fixture
C16 Receipt text variants from operator docs LINK sub:000 dlvrd:000 text:; Vonage stat:FAILED; tyntec two receipts one id; Infobip stat:ENROUTE under 0x04; no id:; hex id in submit_sm_resp, decimal in receipt (target 7) fixture
C17 Encodings round trip Latin-1, UCS-2 big-endian, flash 0x10 and 0xF0, 8-bit binary with UDH — what comes back matches (target 12) SMPPSim loopback, Melrose MO echo
C18 outbind from the SMSC to our server No bogus response PDU; logged (target 6) SMPPSim OUTBIND_ENABLED

An ESME against our server

# Scenario Assert Peers
S1 Kannel binds at "34" and at "33", submits, gets receipts, receives MO bearerbox log shows the receipt parsed and matched (DLR); at "33" our receipts carry no TLVs and still match; MO reaches the sms-service URL (target 8) Kannel
S2 Long messages in every spelling to our server UDH 8-bit, UDH 16-bit, sar_*, message_payload: one sms each, or a documented refusal (targets 2, 3, 5) jsmpp, php-smpp modes, python-smpplib
S3 Unhandled and unknown commands query_sm/cancel_sm/replace_sm get *_resp ESME_RINVCMDID and the client accepts it; an unknown id gets generic_nack; alert_notification gets nothing (targets 1, 6) jsmpp
S4 Separate TX and RX binds Receipts and MO go out on RX only; submit_sm on RX gets ESME_RINVBNDSTS; the peer keeps working php-smpp
S5 Window pressure Window 50 against a slow sms listener: every request answered, none twice; peer's request expiry shorter than our answer is reported by the peer, not by a crash on our side Cloudhopper
S6 Idle and keepalive A peer sending no enquire_link is dropped at idleTimeout and reconnects cleanly; one sending every 5 s is kept smppload (none), gosmpp (5 s), Jasmin elink_interval
S7 A production gateway as the ESME Jasmin parses our receipts (HTTP DLR callback fires with the right state); dlr_msgid 0 matches Jasmin smppccm
S8 Load: 100 000 submit_sm, rps 500, threads 20, receipts on, long messages on All answered, memory flat, receipts correlate, close() drains to zero smppload
S9 Bounded window under load window 2000, rate 1000: no head-of-line stall, no reorder problems smpp-dumb-client
S10 TLS from a Java client Handshake with our cert, bind, send Cloudhopper make ssl-client
S11 Encodings from other encoders GSM unpacked with 0x1B extension escapes, UCS-2, Latin-1 decode to the right string; peer decodes our deliver_sm the same way python-smpplib, jsmpp, Kannel alt-charset

Order of work

Each phase ends with findings in Status; a defect gets a regression test in test/ and a fix before the next phase starts, per AGENTS.md.

Phase What Rough effort
0 Harness: interop-tests/compose.<peer>.yaml per peer, our tests as interop-tests/<peer>.test.ts run in the node service on the same network, tshark sidecar writing to interop-tests/captures/ (gitignored), a Python runner that brings a peer up, runs, tears down ½ day
1 smscsim smoke + tshark assertions — proves the harness 1 h
2 SMPPSim: C2C7, C11C13, C15, C17, C18 1 day
3 Jasmin as SMSC (C1, C3, C7C9, C11C13) and as ESME (S7) 1 day
4 Kannel (S1) ½ day
5 jsmpp + Cloudhopper (S2, S3, S5, S10) 1 day
6 python-smpplib + php-smpp (S2, S4, S11) ½ day
7 Load: smppload + smpp-dumb-client (S6, S8, S9) ½ day
8 Hosted, manual: Melrose and smpp.org (C1, C7, C8, C14, C15, C17) 2 h
9 Fixtures from the quirk list into test/ (C8 UDH-16, C9, C10, C15, C16) 1 day
10 Decide 1.0 scope for targets 25: fix, or document as a limitation with the peers that refuse

Delegation

Cheaper model, with these instructions and the tables above, no judgement calls:

  • Compose files and Dockerfiles for every peer, pins as listed, healthcheck on the SMPP port.
  • Kannel kannel.conf (core, smsbox, smsc smpp, sendsms-user, sms-service) from the 1.4.5 user guide, one variant per knob value in S1.
  • Jasmin jcli bootstrap script: group, user, smppccm, mtrouter, morouter.
  • python-smpplib driver scripts for S2, S4 and S11, one PDU shape per script.
  • Running a phase and pasting tshark's malformed/expert counts and the peer's log excerpts into Status.

Stronger model: reading a failure against the spec and the peer's docs, deciding fix versus document, library changes, and the AGENTS.md decision record each one needs.

Status

Phase State Findings
0 done 01-smscsim.md
1 done 01-smscsim.md
210 not started

Research notes behind this plan, 2026-09-05, are in research/: SMSC simulators, ESME clients and validators, and operator quirks with one source URL per claim. Ask before trusting a claim here that a peer's own docs would settle.