diff --git a/.gitignore b/.gitignore index 836f621..df594cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .claude dist dist-test +interop-tests/captures node_modules diff --git a/interop-tests/PLAN.md b/interop-tests/PLAN.md index 4f0579a..d6b6004 100644 --- a/interop-tests/PLAN.md +++ b/interop-tests/PLAN.md @@ -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 diff --git a/interop-tests/compose.smscsim.yaml b/interop-tests/compose.smscsim.yaml new file mode 100644 index 0000000..100ac00 --- /dev/null +++ b/interop-tests/compose.smscsim.yaml @@ -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 diff --git a/interop-tests/findings/01-smscsim.md b/interop-tests/findings/01-smscsim.md new file mode 100644 index 0000000..37d043f --- /dev/null +++ b/interop-tests/findings/01-smscsim.md @@ -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 + `.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: `, 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). diff --git a/interop-tests/run.py b/interop-tests/run.py new file mode 100755 index 0000000..f8be986 --- /dev/null +++ b/interop-tests/run.py @@ -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()) diff --git a/interop-tests/smscsim.test.ts b/interop-tests/smscsim.test.ts new file mode 100644 index 0000000..783f4c3 --- /dev/null +++ b/interop-tests/smscsim.test.ts @@ -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 { + return new Promise(resolve => { setTimeout(resolve, ms); }); +} + +/** Polls until `get()` stops returning undefined, or the budget runs out. */ +async function waitFor(get: () => T | undefined, budget = 5000): Promise { + 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 { + 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); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 969cf2b..8075b0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,5 +24,5 @@ "types": ["node"], "verbatimModuleSyntax": true }, - "include": ["eslint.config.ts", "src", "test"] + "include": ["eslint.config.ts", "interop-tests", "src", "test"] }