Add the interoperability harness and the smscsim experiment, which confirms the one-bad-PDU link drop
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
.claude
|
.claude
|
||||||
dist
|
dist
|
||||||
dist-test
|
dist-test
|
||||||
|
interop-tests/captures
|
||||||
node_modules
|
node_modules
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ document, library changes, and the AGENTS.md decision record each one needs.
|
|||||||
|
|
||||||
| Phase | State | Findings |
|
| Phase | State | Findings |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 0–10 | not started | — |
|
| 0 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
||||||
|
| 1 | done | [01-smscsim.md](findings/01-smscsim.md) |
|
||||||
|
| 2–10 | not started | — |
|
||||||
|
|
||||||
Research notes behind this plan, 2026-09-05, are in `research/`: SMSC simulators, ESME clients
|
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
|
and validators, and operator quirks with one source URL per claim. Ask before trusting a claim here
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
services:
|
||||||
|
smscsim:
|
||||||
|
image: ukarim/smscsim:0.2.0
|
||||||
|
environment:
|
||||||
|
SMSC_PORT: "2775"
|
||||||
|
WEB_PORT: "12775"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "netstat -lnt | grep -q :2775"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
smscsim-failing:
|
||||||
|
image: ukarim/smscsim:0.2.0
|
||||||
|
environment:
|
||||||
|
FAILED_SUBMITS: "true"
|
||||||
|
SMSC_PORT: "2775"
|
||||||
|
WEB_PORT: "12775"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "netstat -lnt | grep -q :2775"]
|
||||||
|
interval: 1s
|
||||||
|
retries: 30
|
||||||
|
timeout: 2s
|
||||||
|
|
||||||
|
capture:
|
||||||
|
image: nicolaka/netshoot:v0.16
|
||||||
|
network_mode: "service:smscsim"
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- NET_RAW
|
||||||
|
depends_on:
|
||||||
|
smscsim:
|
||||||
|
condition: service_started
|
||||||
|
command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/smscsim.pcapng"]
|
||||||
|
volumes:
|
||||||
|
- ./interop-tests/captures:/captures
|
||||||
|
|
||||||
|
node:
|
||||||
|
depends_on:
|
||||||
|
capture:
|
||||||
|
condition: service_started
|
||||||
|
smscsim:
|
||||||
|
condition: service_healthy
|
||||||
|
smscsim-failing:
|
||||||
|
condition: service_healthy
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 01 smscsim
|
||||||
|
|
||||||
|
Date: 2026-09-05. Repo commit: `7d855cf` (working tree, phase 0+1 changes uncommitted on top).
|
||||||
|
Host Docker: 29.6.2. Images: `ukarim/smscsim:0.2.0` (peer, both `smscsim` and `smscsim-failing`),
|
||||||
|
`nicolaka/netshoot:v0.16` (capture sidecar and tshark), `node:24.18.0-bookworm-slim` (test runner,
|
||||||
|
from the root `compose.yaml`).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Worked as designed: `interop-tests/run.py smscsim` brings up `smscsim`, `smscsim-failing` and
|
||||||
|
`capture` via `interop-tests/compose.smscsim.yaml`, waits on their healthchecks (`netstat -lnt |
|
||||||
|
grep -q :2775`, both images have a busybox shell), runs `interop-tests/smscsim.test.ts` in the
|
||||||
|
`node` service, stops the capture, decodes it with tshark, and tears down.
|
||||||
|
|
||||||
|
Two snags fixed while building the harness, both in `run.py`/the compose overlay, not the peer:
|
||||||
|
|
||||||
|
- `dumpcap`'s binary is mode `0750` root:root inside `nicolaka/netshoot:v0.16`, so the `capture`
|
||||||
|
service has to run as root (the default) rather than `1000:1000` - matching the "otherwise fix
|
||||||
|
ownership from run.py" fallback the brief anticipated. `run.py` chowns and chmods
|
||||||
|
`interop-tests/captures/` to `1000:1000`/`0777` through a throwaway container after every run.
|
||||||
|
- This sandbox's Docker does not give a root container DAC-override: it can create a new file in a
|
||||||
|
`1000:1000`-owned `0777` directory, but not overwrite an existing `1000:1000`-owned file there
|
||||||
|
(dumpcap's own file mode, `0600`, blocks it). `run.py` now unlinks the previous
|
||||||
|
`<peer>.pcapng` itself before every run, so `dumpcap` always creates a fresh file.
|
||||||
|
- The research notes and PLAN.md's knobs column say `FAILED_SUBMITS=1`; `main.go` actually checks
|
||||||
|
`"true" == os.Getenv("FAILED_SUBMITS")`, so `1` is silently ignored (never fails anything). The
|
||||||
|
compose overlay sets `FAILED_SUBMITS: "true"`.
|
||||||
|
|
||||||
|
Two runs of `./interop-tests/run.py smscsim`, back to back, both exit 0:
|
||||||
|
|
||||||
|
```
|
||||||
|
frames: 114
|
||||||
|
commands:
|
||||||
|
bind_receiver: 1 bind_receiver_resp: 1
|
||||||
|
bind_transceiver: 12 bind_transceiver_resp: 12
|
||||||
|
bind_transmitter: 1 bind_transmitter_resp: 1
|
||||||
|
deliver_sm: 20 deliver_sm_resp: 10
|
||||||
|
enquire_link: 6 enquire_link_resp: 6
|
||||||
|
submit_sm: 19 submit_sm_resp: 19
|
||||||
|
unbind: 3 unbind_resp: 3
|
||||||
|
malformed: 0
|
||||||
|
expert errors: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
(identical both times). `bind_transceiver` is 12, not the 5 a reconnect-free run would show (C1's
|
||||||
|
one transceiver bind + single-SMS + GSM-multipart + UCS2-multipart + MO, one each) - the extra 7
|
||||||
|
are the client's own reconnects after the defect below tears the link down; `deliver_sm_resp` is
|
||||||
|
half of `deliver_sm` for the same reason (below).
|
||||||
|
|
||||||
|
## Scenarios
|
||||||
|
|
||||||
|
| Id (from PLAN.md) | Result | Evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| C1 (bind transceiver/transmitter/receiver, keepalive, clean unbind) | pass | `smscsim - C1 bind, keepalive, unbind`, all 3 bind types; no `sessionError`, one `close` each |
|
||||||
|
| smoke: single SMS + DLR | pass | `smscsim - a single SMS`; DLR `statusMsg` `DELIVERED`, `smsId` matches the `submit_sm_resp` id |
|
||||||
|
| smoke: 2-segment GSM long MT | pass | `smscsim - multipart segments › a 2-segment GSM message…`; 2 ids, 2 DLRs (via retry - see defect) |
|
||||||
|
| smoke: 2-segment UCS2 long MT (一 + emoji) | pass | `smscsim - multipart segments › a 2-segment UCS2 message…`; 2 ids, 2 DLRs (via retry) |
|
||||||
|
| smoke: MO injection via web UI | pass | `smscsim - MO injection…`; `sms.from`/`to`/`message` match the posted form, `sendResp()` clean |
|
||||||
|
| C12 (smscsim part: refusal + undeliverable DLR) | pass | `smscsim-failing - C12 refusals`; refused sends name `ESME_RSYSERR`, accepted ones' DLRs name `UNDELIVERABLE`; session stayed bound throughout (`enquire_link` answered after) |
|
||||||
|
|
||||||
|
Every scenario passed both runs, but the multipart, single-SMS and MO scenarios only pass because
|
||||||
|
they retry past the defect below (`DLR_MAX_ATTEMPTS = 20` in `smscsim.test.ts`) - see Defects.
|
||||||
|
|
||||||
|
## Defects in @larvit/smpp
|
||||||
|
|
||||||
|
### An out-of-range `deliver_sm` sequence_number drops the whole link, not just that PDU
|
||||||
|
|
||||||
|
**What happened.** `smscsim` signs every `deliver_sm` it sends unprompted - a delivery receipt or
|
||||||
|
an injected MO - with a raw `rand.Int()` truncated to `uint32` for `sequence_number`
|
||||||
|
(`smsc.go`'s `deliverSmPDU`, called from both `deliveryReceiptPDU` and `SendMoMessage`), so about
|
||||||
|
half the time the value is `>= 0x80000000`. `pdu.ts`'s `parseOnce` rejects that with `Invalid
|
||||||
|
seqNr, exceeds 2147483646: <n>`, and `pdu-transport.ts`'s `read()` routes *every* `pduToObj` error -
|
||||||
|
this one included - to `onUnreadable`, which `session.ts` wires to `sessionError` +
|
||||||
|
`teardown()`. `teardown()` destroys the socket outright; with the client's default `reconnect: true`
|
||||||
|
the session then reconnects (invisibly to the caller: `sendSms()` on a mid-reconnect session just
|
||||||
|
queues until the new link is bound), but the `deliver_sm` that triggered it - and its answer, since
|
||||||
|
none is ever sent - are gone. Confirmed live: binding, then sending a 2-segment message with `dlr:
|
||||||
|
true` against a real `smscsim`, printed `SESSION ERROR Invalid seqNr, exceeds 2147483646:
|
||||||
|
4085734660` for the second segment's receipt, no `dlr` event fired for it, and the capture showed
|
||||||
|
the peer's two `deliver_sm` PDUs answered by only one `deliver_sm_resp`.
|
||||||
|
|
||||||
|
**What the spec says.** SMPP 3.4 §4.7.1: `sequence_number` is `0x00000001` to `0x7FFFFFFF`; a
|
||||||
|
value outside it is certainly not a request this library ever intends to send and arguably not
|
||||||
|
one it must answer either. But target 1 in PLAN.md is exactly this shape: "`pdu-transport.ts`
|
||||||
|
routes every codec error... to the teardown a framing error takes, although `command_length` was
|
||||||
|
honoured and the stream is still in sync." Here `command_length` is honoured, the command is
|
||||||
|
`deliver_sm`, and only one 4-byte field is out of range - the spec gives no status for "sequence
|
||||||
|
number out of range" specifically, but continuing to read the stream and refusing just this PDU
|
||||||
|
(there is no `*_resp` to send back without a valid sequence number to answer with; a `generic_nack`
|
||||||
|
naming e.g. `ESME_RINVCMDID` would need a sequence number too, which is presumably part of why the
|
||||||
|
current code gives up on the whole link) would lose one receipt instead of the link.
|
||||||
|
|
||||||
|
**Reproducer.** A minimal `deliver_sm` with every field empty/zero except the header:
|
||||||
|
|
||||||
|
```
|
||||||
|
000000210000000500000000800000010000000000000000000000000000000000
|
||||||
|
```
|
||||||
|
|
||||||
|
(33 bytes: `command_length=0x21`, `command_id=0x00000005` deliver_sm, `command_status=0`,
|
||||||
|
`sequence_number=0x80000001`, then 17 zero bytes for `service_type`..`short_message` each
|
||||||
|
empty/0.) Feeding this to `pduToObj` (`src/pdu.ts`) returns `{ err: Error("Invalid seqNr, exceeds
|
||||||
|
2147483646: 2147483649") }`; feeding it to a live session's socket reproduces the teardown.
|
||||||
|
|
||||||
|
**Severity.** Medium-high against this peer specifically: roughly half of `smscsim`'s DLRs and MOs
|
||||||
|
are silently lost and bounce the link. Against a spec-conforming peer (small incrementing sequence
|
||||||
|
numbers) it never fires, so it is plausibly why the suite's own dummy peers never caught it - which
|
||||||
|
is the whole reason this experiment exists.
|
||||||
|
|
||||||
|
## Peer quirks
|
||||||
|
|
||||||
|
- No PDU validation (documented): a bad `interface_version` or malformed PDU is never rejected.
|
||||||
|
- `FAILED_SUBMITS` needs the literal string `true`; PLAN.md's research notes say `1`, which the
|
||||||
|
peer silently ignores (see Setup).
|
||||||
|
- DLR is always exactly `DELIVERED` (or, with `FAILED_SUBMITS=true`, `UNDELIVERABLE` on odd
|
||||||
|
sequence numbers) after a fixed ~2s; no other status is reachable.
|
||||||
|
- `FAILED_SUBMITS=true` refuses only `submit_sm`s whose *own* sequence number is even
|
||||||
|
(`ESME_RSYSERR`); it does not otherwise vary behaviour, and the DLR-triggering rule above applies
|
||||||
|
to every accepted submit regardless of parity.
|
||||||
|
- MO injection (the `12775` web page) always encodes the message as UCS2 (`data_coding=8`)
|
||||||
|
regardless of its content, and requires an already-bound session whose `system_id` matches the
|
||||||
|
form's `system_id` field exactly (`sender`, `recipient`, `message`, `system_id`, `POST /`,
|
||||||
|
`web.go`'s `webHandler`); the response is a `303` redirect to `/?message=...` (or `?error=...`).
|
||||||
|
- Message ids and `deliver_sm` sequence numbers are `rand.Int()`-derived per-process, not reset or
|
||||||
|
seeded per connection - not proven security-relevant here, but they are not unique across a
|
||||||
|
restarted container in the way a UUID would be.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- Whether the same out-of-range-sequence-number shape reaches other peers (Jasmin, SMPPSim) or is
|
||||||
|
particular to `smscsim`'s unconstrained `rand.Int()` - phase 2+ should watch for the same
|
||||||
|
`sessionError` text.
|
||||||
|
- Per PLAN.md's Order of work, this defect should get a regression test in `test/` and a fix before
|
||||||
|
phase 2 starts; both are out of scope for this experiment (`src/`/`test/` are read-only here).
|
||||||
Executable
+186
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CAPTURES_DIR = REPO_ROOT / "interop-tests" / "captures"
|
||||||
|
NETSHOOT_IMAGE = "nicolaka/netshoot:v0.16"
|
||||||
|
EXPERT_SEVERITY_ERROR = "8388608"
|
||||||
|
|
||||||
|
# The SMPP port each peer's compose overlay exposes its SMSC on.
|
||||||
|
PORT_BY_PEER = {
|
||||||
|
"kannel": 2775,
|
||||||
|
"smppsim": 2775,
|
||||||
|
"smscsim": 2775,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The 33 SMPP commands (SMPP 3.4), by numeric command_id, for the tshark histogram.
|
||||||
|
COMMAND_NAMES = {
|
||||||
|
0x00000001: "bind_receiver",
|
||||||
|
0x00000002: "bind_transmitter",
|
||||||
|
0x00000003: "query_sm",
|
||||||
|
0x00000004: "submit_sm",
|
||||||
|
0x00000005: "deliver_sm",
|
||||||
|
0x00000006: "unbind",
|
||||||
|
0x00000007: "replace_sm",
|
||||||
|
0x00000008: "cancel_sm",
|
||||||
|
0x00000009: "bind_transceiver",
|
||||||
|
0x0000000B: "outbind",
|
||||||
|
0x00000015: "enquire_link",
|
||||||
|
0x00000021: "submit_multi",
|
||||||
|
0x00000102: "alert_notification",
|
||||||
|
0x00000103: "data_sm",
|
||||||
|
0x00000111: "broadcast_sm",
|
||||||
|
0x00000112: "query_broadcast_sm",
|
||||||
|
0x00000113: "cancel_broadcast_sm",
|
||||||
|
0x80000000: "generic_nack",
|
||||||
|
0x80000001: "bind_receiver_resp",
|
||||||
|
0x80000002: "bind_transmitter_resp",
|
||||||
|
0x80000003: "query_sm_resp",
|
||||||
|
0x80000004: "submit_sm_resp",
|
||||||
|
0x80000005: "deliver_sm_resp",
|
||||||
|
0x80000006: "unbind_resp",
|
||||||
|
0x80000007: "replace_sm_resp",
|
||||||
|
0x80000008: "cancel_sm_resp",
|
||||||
|
0x80000009: "bind_transceiver_resp",
|
||||||
|
0x80000015: "enquire_link_resp",
|
||||||
|
0x80000021: "submit_multi_resp",
|
||||||
|
0x80000103: "data_sm_resp",
|
||||||
|
0x80000111: "broadcast_sm_resp",
|
||||||
|
0x80000112: "query_broadcast_sm_resp",
|
||||||
|
0x80000113: "cancel_broadcast_sm_resp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def sh(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess:
|
||||||
|
print("+", " ".join(cmd), file=sys.stderr)
|
||||||
|
return subprocess.run(cmd, cwd=REPO_ROOT, check=False, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def compose_cmd(peer: str, *args: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
"docker", "compose",
|
||||||
|
"-f", "compose.yaml",
|
||||||
|
"-f", f"interop-tests/compose.{peer}.yaml",
|
||||||
|
*args,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fix_capture_ownership() -> None:
|
||||||
|
# dumpcap's own file permissions (0750, root:root) mean the sidecar has to run as root, so
|
||||||
|
# captures/ comes out root-owned; fixed up here rather than with host sudo. The directory is
|
||||||
|
# left world-writable so the next run's root-owned dumpcap can still create files in it.
|
||||||
|
sh(["docker", "run", "--rm", "-v", f"{CAPTURES_DIR}:/captures", "--entrypoint", "chown", NETSHOOT_IMAGE, "-R", "1000:1000", "/captures"])
|
||||||
|
sh(["docker", "run", "--rm", "-v", f"{CAPTURES_DIR}:/captures", "--entrypoint", "chmod", NETSHOOT_IMAGE, "0777", "/captures"])
|
||||||
|
|
||||||
|
|
||||||
|
def walk(node: object):
|
||||||
|
if isinstance(node, dict):
|
||||||
|
yield node
|
||||||
|
for value in node.values():
|
||||||
|
yield from walk(value)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
yield from walk(item)
|
||||||
|
|
||||||
|
|
||||||
|
def collect(node: object, key: str) -> list[object]:
|
||||||
|
return [item[key] for item in walk(node) if isinstance(item, dict) and key in item]
|
||||||
|
|
||||||
|
|
||||||
|
def analyse_capture(peer: str, port: int) -> tuple[int, dict[str, object]]:
|
||||||
|
pcap = CAPTURES_DIR / f"{peer}.pcapng"
|
||||||
|
|
||||||
|
if not pcap.exists():
|
||||||
|
print(f"no capture file at {pcap}", file=sys.stderr)
|
||||||
|
|
||||||
|
return 1, {}
|
||||||
|
|
||||||
|
decoded = sh([
|
||||||
|
"docker", "run", "--rm", "-v", f"{CAPTURES_DIR}:/captures", NETSHOOT_IMAGE,
|
||||||
|
"tshark", "-r", f"/captures/{peer}.pcapng",
|
||||||
|
"-d", f"tcp.port=={port},smpp",
|
||||||
|
"-Y", "smpp",
|
||||||
|
"-T", "json",
|
||||||
|
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
|
||||||
|
if decoded.returncode != 0:
|
||||||
|
print(decoded.stderr, file=sys.stderr)
|
||||||
|
|
||||||
|
return 1, {}
|
||||||
|
|
||||||
|
frames = json.loads(decoded.stdout or "[]")
|
||||||
|
(CAPTURES_DIR / f"{peer}.json").write_text(decoded.stdout)
|
||||||
|
|
||||||
|
histogram: collections.Counter = collections.Counter()
|
||||||
|
malformed = 0
|
||||||
|
expert_errors = 0
|
||||||
|
|
||||||
|
for frame in frames:
|
||||||
|
for command_id in collect(frame, "smpp.command_id"):
|
||||||
|
name = COMMAND_NAMES.get(int(str(command_id), 16), str(command_id))
|
||||||
|
histogram[name] += 1
|
||||||
|
|
||||||
|
if collect(frame, "_ws.malformed"):
|
||||||
|
malformed += 1
|
||||||
|
|
||||||
|
expert_errors += sum(
|
||||||
|
1 for severity in collect(frame, "_ws.expert.severity") if severity == EXPERT_SEVERITY_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"frames: {len(frames)}")
|
||||||
|
print("commands:")
|
||||||
|
for name, count in sorted(histogram.items()):
|
||||||
|
print(f" {name}: {count}")
|
||||||
|
print(f"malformed: {malformed}")
|
||||||
|
print(f"expert errors: {expert_errors}")
|
||||||
|
|
||||||
|
status = 1 if malformed > 0 or expert_errors > 0 else 0
|
||||||
|
|
||||||
|
return status, {
|
||||||
|
"commands": dict(histogram),
|
||||||
|
"expertErrors": expert_errors,
|
||||||
|
"frames": len(frames),
|
||||||
|
"malformed": malformed,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("peer")
|
||||||
|
parser.add_argument("--keep", action="store_true", help="leave the peer up for a manual look")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
port = PORT_BY_PEER.get(args.peer)
|
||||||
|
|
||||||
|
if port is None:
|
||||||
|
print(f"no SMPP port known for peer {args.peer!r}; add it to PORT_BY_PEER in run.py", file=sys.stderr)
|
||||||
|
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# dumpcap can't be given 1000:1000 (see fix_capture_ownership), so it creates this file as
|
||||||
|
# root; overwriting one from a previous run then fails, since root here has no DAC override
|
||||||
|
# either - so the stale file has to go before a fresh one can be written in its place.
|
||||||
|
(CAPTURES_DIR / f"{args.peer}.pcapng").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
tests = sh(compose_cmd(
|
||||||
|
args.peer, "run", "--rm", "--use-aliases", "node",
|
||||||
|
"node", "--test", f"interop-tests/{args.peer}.test.ts",
|
||||||
|
))
|
||||||
|
sh(compose_cmd(args.peer, "stop", "capture"))
|
||||||
|
fix_capture_ownership()
|
||||||
|
|
||||||
|
analysis_status, _ = analyse_capture(args.peer, port)
|
||||||
|
|
||||||
|
if not args.keep:
|
||||||
|
sh(compose_cmd(args.peer, "down", "-v"))
|
||||||
|
|
||||||
|
return 1 if tests.returncode != 0 else analysis_status
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { describe } from 'node:test';
|
||||||
|
import type { Dlr } from '../src/dlr.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';
|
||||||
|
|
||||||
|
const PEER_HOST = process.env.PEER_HOST ?? 'smscsim';
|
||||||
|
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||||
|
const PEER_WEB_PORT = Number(process.env.PEER_WEB_PORT ?? '12775');
|
||||||
|
const FAILING_PEER_HOST = process.env.FAILING_PEER_HOST ?? 'smscsim-failing';
|
||||||
|
const FAILING_PEER_PORT = Number(process.env.FAILING_PEER_PORT ?? '2775');
|
||||||
|
|
||||||
|
// smscsim signs every deliver_sm it sends unprompted (a DLR, or an injected MO) with a
|
||||||
|
// `rand.Int()`-derived sequence_number, unconstrained to the SMPP 3.4 4.7.1 ceiling
|
||||||
|
// (0x7FFFFFFF) - about half land above it, and pdu.ts refuses the PDU outright, so the DLR or
|
||||||
|
// MO is silently lost (see findings/01-smscsim.md, "an out-of-range deliver_sm sequence
|
||||||
|
// number"). Retrying with a fresh send works around that peer+library interaction without
|
||||||
|
// hiding it: a scenario only fails here if it keeps missing well past what chance alone explains.
|
||||||
|
const DLR_RETRY_BUDGET_MS = 3000;
|
||||||
|
const DLR_MAX_ATTEMPTS = 20;
|
||||||
|
|
||||||
|
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 = 5000): Promise<T | undefined> {
|
||||||
|
const deadline = Date.now() + budget;
|
||||||
|
let value = get();
|
||||||
|
|
||||||
|
while (value === undefined && Date.now() < deadline) {
|
||||||
|
await delay(20);
|
||||||
|
value = get();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resends the message until one attempt's segments are every one matched by a `dlr` event. */
|
||||||
|
async function sendUntilAllDlrsArrive(session: Session, dlrs: Dlr[], message: string): Promise<string[]> {
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS; attempt++) {
|
||||||
|
const sent = await session.sendSms({ dlr: true, from: '46701113311', message, to: '46709771337' });
|
||||||
|
|
||||||
|
assert.equal(sent.err, undefined);
|
||||||
|
|
||||||
|
const complete = await waitFor(
|
||||||
|
() => (sent.smsIds.every(id => dlrs.some(dlr => dlr.smsId === id)) ? true : undefined),
|
||||||
|
DLR_RETRY_BUDGET_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (complete) return sent.smsIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`no attempt got a DLR for every segment within ${String(DLR_MAX_ATTEMPTS)} tries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('smscsim - C1 bind, keepalive, unbind', () => {
|
||||||
|
for (const bindType of ['transceiver', 'transmitter', 'receiver'] as const) {
|
||||||
|
test(`binds as ${bindType}, keeps the link, unbinds cleanly`, async t => {
|
||||||
|
const closes: unknown[] = [];
|
||||||
|
const sessionErrors: Error[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({
|
||||||
|
bindType,
|
||||||
|
enquireLinkInterval: 1000,
|
||||||
|
host: PEER_HOST,
|
||||||
|
port: PEER_PORT,
|
||||||
|
username: `c1-${bindType}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('close', () => { closes.push(undefined); });
|
||||||
|
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
|
||||||
|
|
||||||
|
// smscsim never sends an unsolicited enquire_link of its own - its ENQUIRE_LINK case
|
||||||
|
// only answers one (confirmed in its source, smsc.go). So the interval-driven
|
||||||
|
// keepalive is checked through its own response, not through `incomingPduObj`.
|
||||||
|
const enquired = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(enquired.err, undefined);
|
||||||
|
assert.ok(enquired.pduObj);
|
||||||
|
assert.equal(enquired.pduObj.cmdName, 'enquire_link_resp');
|
||||||
|
assert.equal(enquired.pduObj.cmdStatus, 'ESME_ROK');
|
||||||
|
|
||||||
|
await delay(1500);
|
||||||
|
|
||||||
|
const unbound = await session.unbind();
|
||||||
|
|
||||||
|
assert.equal(unbound.err, undefined);
|
||||||
|
|
||||||
|
await delay(50);
|
||||||
|
|
||||||
|
assert.deepEqual(closes, [undefined]);
|
||||||
|
assert.deepEqual(sessionErrors, []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim - a single SMS', () => {
|
||||||
|
test('one id back, a DLR within 5s naming it DELIVERED', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'single-sms' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, 'hello world');
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const matched = dlrs.find(dlr => dlr.smsId === smsId);
|
||||||
|
|
||||||
|
assert.ok(matched);
|
||||||
|
assert.equal(matched.statusMsg, 'DELIVERED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim - multipart segments', () => {
|
||||||
|
test('a 2-segment GSM message gets 2 ids and a DLR per id', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'gsm-multipart' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
// 200 plain GSM chars: over the 160-char single-segment budget, under the 306-char
|
||||||
|
// 2-segment one (153 septets each).
|
||||||
|
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, 'a'.repeat(200));
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 2-segment UCS2 message (一 and an emoji) gets 2 ids and a DLR per id', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: PEER_HOST, port: PEER_PORT, username: 'ucs2-multipart' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
// 一 (2 bytes) + an emoji (a surrogate pair, 4 bytes) + 70 padding chars (2 bytes each):
|
||||||
|
// 146 bytes, over the 140-byte single-segment budget, under the 268-byte 2-segment one.
|
||||||
|
const smsIds = await sendUntilAllDlrsArrive(session, dlrs, `一😀${'x'.repeat(70)}`);
|
||||||
|
|
||||||
|
assert.equal(smsIds.length, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim - MO injection through the web UI', () => {
|
||||||
|
test('a message posted to the web page arrives as an sms event', async t => {
|
||||||
|
const { err, session } = await client({
|
||||||
|
bindType: 'transceiver',
|
||||||
|
host: PEER_HOST,
|
||||||
|
port: PEER_PORT,
|
||||||
|
username: 'mo-inject',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
const incoming: Sms[] = [];
|
||||||
|
|
||||||
|
session.on('sms', sms => { incoming.push(sms); });
|
||||||
|
|
||||||
|
let sms: Sms | undefined;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS && !sms; attempt++) {
|
||||||
|
const before = incoming.length;
|
||||||
|
|
||||||
|
const response = await fetch(`http://${PEER_HOST}:${String(PEER_WEB_PORT)}/`, {
|
||||||
|
body: new URLSearchParams({
|
||||||
|
message: 'hello from the web UI',
|
||||||
|
recipient: '46709771337',
|
||||||
|
sender: '46701113311',
|
||||||
|
system_id: 'mo-inject',
|
||||||
|
}),
|
||||||
|
method: 'POST',
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.status, 303);
|
||||||
|
assert.match(response.headers.get('location') ?? '', /message=/);
|
||||||
|
|
||||||
|
await waitFor(() => incoming[before], DLR_RETRY_BUDGET_MS);
|
||||||
|
sms = incoming[before];
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(sms, 'no MO message arrived across the attempts');
|
||||||
|
assert.equal(sms.from, '46701113311');
|
||||||
|
assert.equal(sms.to, '46709771337');
|
||||||
|
assert.equal(sms.message, 'hello from the web UI');
|
||||||
|
|
||||||
|
assert.equal((await sms.sendResp()).err, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('smscsim-failing - C12 refusals', () => {
|
||||||
|
test('even sequence numbers are refused, odd ones get an undeliverable DLR', async t => {
|
||||||
|
const dlrs: Dlr[] = [];
|
||||||
|
|
||||||
|
const { err, session } = await client({ host: FAILING_PEER_HOST, port: FAILING_PEER_PORT, username: 'c12' });
|
||||||
|
|
||||||
|
assert.equal(err, undefined);
|
||||||
|
assert.ok(session);
|
||||||
|
closeAfter(t, session);
|
||||||
|
|
||||||
|
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||||
|
|
||||||
|
let refusedSeen = false;
|
||||||
|
let acceptedConfirmed = false;
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < DLR_MAX_ATTEMPTS && !(refusedSeen && acceptedConfirmed); attempt++) {
|
||||||
|
const before = dlrs.length;
|
||||||
|
|
||||||
|
// Sequential: smscsim keys its refusal on each submit_sm's own sequence number parity.
|
||||||
|
const result = await session.sendSms({
|
||||||
|
dlr: true,
|
||||||
|
from: '46701113311',
|
||||||
|
message: `refusal check ${String(attempt)}`,
|
||||||
|
to: '46709771337',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.err !== undefined) {
|
||||||
|
assert.match(result.err.message, /ESME_RSYSERR/);
|
||||||
|
refusedSeen = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(result.smsIds.length, 1);
|
||||||
|
|
||||||
|
const [smsId] = result.smsIds;
|
||||||
|
|
||||||
|
assert.ok(smsId);
|
||||||
|
|
||||||
|
const matched = await waitFor(() => dlrs.slice(before).find(dlr => dlr.smsId === smsId), DLR_RETRY_BUDGET_MS);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
assert.equal(matched.statusMsg, 'UNDELIVERABLE');
|
||||||
|
acceptedConfirmed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(refusedSeen, 'no send was refused across the attempts');
|
||||||
|
assert.ok(acceptedConfirmed, 'no accepted send got a confirmed undeliverable DLR');
|
||||||
|
|
||||||
|
// The session must stay bound and usable after a refusal.
|
||||||
|
const enquired = await session.send({ cmdName: 'enquire_link' });
|
||||||
|
|
||||||
|
assert.equal(enquired.err, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -24,5 +24,5 @@
|
|||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"verbatimModuleSyntax": true
|
"verbatimModuleSyntax": true
|
||||||
},
|
},
|
||||||
"include": ["eslint.config.ts", "src", "test"]
|
"include": ["eslint.config.ts", "interop-tests", "src", "test"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user