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

21 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
11 One product-owner pass over everything the fixes changed on the public surface — see below 2 h

The product-owner pass, phase 11

Fixing what the peers found moved the published surface seven times in two days, and three separate reviews asked for a product-owner pass on their own change. One pass over all of it beats three narrow ones: the risk days before 1.0.0 is not any single option but the seams between them, and only a review that holds them together at once can see those.

Run it once phases 6 to 10 are done, so nothing lands after it. Read-only, reporting findings; each one that earns a change ships as its own reviewed PR.

Already reviewed, and carried in only as context — the refusal error class and what sessionError means (PRs #79 and #82), and the multipart answering contract (#83). Both passes' findings were documentation, fixed in #85. Do not re-litigate them; use them to judge whether the newer changes stayed consistent with them.

What the pass covers:

Change The question a consumer asks
data_sm and message_payload accepted (#84) Traffic that used to be dropped now arrives. Does an application that never expected data_sm behave sensibly when one turns up as an sms? Is messageOctets, newly exported, something a consumer needs or something we leaked?
The server() onRequest hook (#86) A hook that throws answers nothing, so the peer waits for its own timeout. Is that discoverable before it happens in production, and is silence the right failure for the surface that owns refusing a message?
reconnect: { fromStart: true } (#80) client() resolves only once bound, and only the caller's signal ends the wait. Is that obvious enough that nobody ships a process that hangs at startup?
A receipt's body read as octets (#81) dlr.receipt now parses for peers where it used to be garbage. Anything an application could have been relying on in the broken case?
The abortable send window (in flight) What an aborted send returns, and whether it counts as unanswered — the field an application reads to decide whether resending risks a duplicate.
All of them together sessionError now carries refused PDUs, lost reassembly groups and failed hooks. Is one channel carrying too many meanings for an application to act on any of them?

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
2 done 02-smppsim.md
3 done 03-jasmin.md
4 done 04-kannel.md
5 done 05-java-clients.md
611 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.