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
|
||||
dist
|
||||
dist-test
|
||||
interop-tests/captures
|
||||
node_modules
|
||||
|
||||
@@ -191,7 +191,9 @@ document, library changes, and the AGENTS.md decision record each one needs.
|
||||
|
||||
| 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
|
||||
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"],
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["eslint.config.ts", "src", "test"]
|
||||
"include": ["eslint.config.ts", "interop-tests", "src", "test"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user