Add the interoperability harness and the smscsim experiment, which confirms the one-bad-PDU link drop

This commit is contained in:
2026-09-05 18:40:56 +02:00
parent 7d855cfa5d
commit 1723a0381c
7 changed files with 641 additions and 2 deletions
+1
View File
@@ -1,4 +1,5 @@
.claude .claude
dist dist
dist-test dist-test
interop-tests/captures
node_modules node_modules
+3 -1
View File
@@ -191,7 +191,9 @@ document, library changes, and the AGENTS.md decision record each one needs.
| Phase | State | Findings | | Phase | State | Findings |
| --- | --- | --- | | --- | --- | --- |
| 010 | not started | — | | 0 | done | [01-smscsim.md](findings/01-smscsim.md) |
| 1 | done | [01-smscsim.md](findings/01-smscsim.md) |
| 210 | 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
+45
View File
@@ -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
+133
View File
@@ -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).
+186
View File
@@ -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())
+272
View File
@@ -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
View File
@@ -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"]
} }