diff --git a/interop-tests/compose.php.yaml b/interop-tests/compose.php.yaml new file mode 100644 index 0000000..60ef84f --- /dev/null +++ b/interop-tests/compose.php.yaml @@ -0,0 +1,40 @@ +x-log-limits: &log-limits + logging: + driver: json-file + options: + max-file: "3" + max-size: 20m + +services: + php: + build: ./interop-tests/peers/php + image: interop-php:8.4.25-1d3b53c + command: ["node", "2775"] + <<: *log-limits + healthcheck: + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"] + interval: 1s + retries: 30 + timeout: 2s + + # php only ever dials node:2775 on this container's own network namespace, so this sees exactly + # its side of every scenario below (same pattern as compose.jsmpp.yaml). + capture: + image: nicolaka/netshoot:v0.16 + network_mode: "service:php" + cap_add: + - NET_ADMIN + - NET_RAW + depends_on: + php: + condition: service_started + command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/php.pcapng"] + volumes: + - ./interop-tests/captures:/captures + + node: + depends_on: + capture: + condition: service_started + php: + condition: service_healthy diff --git a/interop-tests/compose.python.yaml b/interop-tests/compose.python.yaml new file mode 100644 index 0000000..6ae18db --- /dev/null +++ b/interop-tests/compose.python.yaml @@ -0,0 +1,40 @@ +x-log-limits: &log-limits + logging: + driver: json-file + options: + max-file: "3" + max-size: 20m + +services: + python: + build: ./interop-tests/peers/python + image: interop-python:2.2.4-3.12.14 + command: ["node", "2775"] + <<: *log-limits + healthcheck: + test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080'"] + interval: 1s + retries: 30 + timeout: 2s + + # python only ever dials node:2775 on this container's own network namespace, so this sees exactly + # its side of every scenario below (same pattern as compose.jsmpp.yaml). + capture: + image: nicolaka/netshoot:v0.16 + network_mode: "service:python" + cap_add: + - NET_ADMIN + - NET_RAW + depends_on: + python: + condition: service_started + command: ["dumpcap", "-i", "any", "-f", "tcp port 2775", "-w", "/captures/python.pcapng"] + volumes: + - ./interop-tests/captures:/captures + + node: + depends_on: + capture: + condition: service_started + python: + condition: service_healthy diff --git a/interop-tests/findings/06-python-php.md b/interop-tests/findings/06-python-php.md new file mode 100644 index 0000000..c5706e7 --- /dev/null +++ b/interop-tests/findings/06-python-php.md @@ -0,0 +1,119 @@ +# 06 python-php + +Date: 2026-09-06. Repo commit: `ab93833` (working tree, phase 6 changes uncommitted on top). Host +Docker: 29.6.2. Images: `interop-python:2.2.4-3.12.14` (`python:3.12.14-slim-bookworm` + +`pip install smpplib==2.2.4`), `interop-php:8.4.25-1d3b53c` (php-smpp, +`alexandr-mironov/php-smpp`, cloned at commit `1d3b53c2d2b63d51ab70009d956914b7f8903118`, built on +`php:8.4.25-cli`), `nicolaka/netshoot:v0.16` (capture sidecar), `node:24.18.0-bookworm-slim` (test +runner, from the root `compose.yaml`). + +## Setup + +Each peer is a small driver (`interop-tests/peers//driver.{py,php}`) exposing an HTTP command +channel the node test file drives, the same shape as the Java drivers in phase 5 - one long-lived +process per peer holding named client sessions open across requests. + +- **python**: `driver.py` runs a `ThreadingHTTPServer`; each named session is a `smpplib.client.Client` + plus a background thread calling `read_once()` in a loop once `/startReader` is called. A plain + `submit_sm` is answered by `@larvit/smpp` only once the application calls `sms.sendResp()` (README, + Server), so `/submit` sends and returns the sequence number immediately rather than blocking for + the ack - a synchronous wait here deadlocks against the node test's own "wait for the sms, then + answer it" flow. `/ack?name=&sequence=` polls the result afterwards. A multipart submit is + answered automatically by the library on arrival, so `/submitLong` keeps its per-part + `wait_ack()`, unaffected by the same deadlock. +- **php**: `driver.php` is a hand-rolled single-connection-at-a-time HTTP server (no framework), + since php-smpp itself is fully synchronous - every send blocks reading its own response on the + same call. This means a plain submit deadlocks against `sendResp()` exactly as above, but there is + no background thread to poll afterwards, so the fix is the opposite one: the node test's global + `session.on('sms', ...)` handler calls `sendResp()` immediately for every arrived sms (the same + pattern `kannel.test.ts` uses for its "maxp1" burst, generalised here to the whole file). A + `deliver_sm` built by hand from the node side and read via `/receive` (php's blocking `readSMS()`) + has the same shape in reverse: the `/receive` call has to be in flight before the `deliver_sm` + goes out, or `session.send()`'s own wait for `deliver_sm_resp` has nothing to unblock it yet. + +Build snags, fixed in the Dockerfile, not in `src/`: + +- **PHP 8's `sockets` extension returns a `Socket` object from `socket_create()`, not a resource.** + This fork's `Socket::isOpen()` (written pre-PHP8) checks `is_resource($this->socket)` alone, so it + is always `false` on PHP 8.x, and every guarded call - `bindReceiver()`, `bindTransmitter()`, + `bindTransceiver()`, `close()`, `sendCommand()` - throws `SocketTransportException('Socket is not + open')` immediately, regardless of the actual connection. The whole library is unusable on PHP 8+ + without this. Patched with a build-time `sed` on `src/transport/Socket.php` (also accept + `$this->socket instanceof \Socket`), not in the vendored source itself. +- `mbstring` needs `libonig-dev` on this base image; the official image warns "mbstring is already + loaded" (it is bundled but not built as a shared extension), harmless. + +Two runs each of `./interop-tests/run.py python` and `./interop-tests/run.py php`, all four stable: +python 13/13 both times (frames 449/450, `enquire_link` ~185, `submit_sm`/`submit_sm_resp` 24/24, +`deliver_sm`/`_resp` 3/3, malformed 0, expert errors 0); php 9/9 both times (frames 42, +`bind_transmitter`/`bind_receiver`/`bind_transceiver` and their resps all matched, `submit_sm`/`_resp` +11/11, `deliver_sm`/`_resp` 1/1, malformed 0, expert errors 0). + +## Scenarios (PLAN.md) + +| Id | Result | Evidence | +| --- | --- | --- | +| S11 GSM 03.38 basic table + extension table (python) | pass, one peer-side quirk | `python.test.ts` "GSM 03.38 basic table + extension table round trip": whole 127-char table (minus the escape character itself) plus `€[]{}\|~^` round-trips exactly | +| S11 form feed (python) | pass, one peer-side quirk | `python.test.ts` "form feed (0x1B 0x0A)...": raw `1b 0a` appended after `gsm_encode()`'s own bytes decodes to `\f` | +| S11 Latin-1 (python) | pass | `python.test.ts` "Latin-1 round trip" | +| S11 UCS-2 with 一 and an emoji (python) | pass | `python.test.ts` "UCS-2 with 一 and an emoji round trip" | +| S11 reverse direction, GSM and UCS-2 (python) | pass | `python.test.ts` "reverse direction: server sends ... text back, python decodes it the same way" (both) | +| S11 the 0x5F quirk, both ways | pass (documents the peer's own table, not asserted as our defect) | `python.test.ts` "peer quirk, documented both ways: byte 0x5F..." | +| S2 UDH 2/3/10 segments (python) | pass | `python.test.ts` "S2 - long messages", one `sms` per size, `answeredOnArrival` true, ids `-1..N` | +| S2 `CSMS_16BIT_TAGS`, `CSMS_PAYLOAD`, `CSMS_8BIT_UDH` (php) | pass, all three | `php.test.ts` "S2 - long messages (php-smpp, three CSMS spellings)" | +| S4 separate TX/RX binds (php) | pass | `php.test.ts` "S4 - bind direction": `boundAs`/`bindAllows()` both ways, `submit_sm` on RX → `ESME_RINVBNDSTS` (status 4) and the peer keeps working, `submit_sm` on TX unaffected, `deliver_sm` reaches RX only (TX's own `/receive` times out) | +| Keepalive, reactive `enquire_link` (python) | pass | `python.test.ts` "Keepalive...": silent past 40s idleTimeout → session closes; `auto_send_enquire_link` with a 10s client timeout → survives the same 45s | +| Refusal via `onRequest` (python, php) | pass, both peers | `python.test.ts`/`php.test.ts` "Refusals via onRequest": `ESME_RTHROTTLED` surfaced as the ack status (python) or a caught `SmppException` code (php); `enquire_link` and a follow-up submit both still work | + +## Defects in @larvit/smpp + +None found. Every encoding, both directions, every long-message spelling from both peers, the +bind-direction enforcement, the idle/keepalive behaviour, and the `onRequest` refusal path all +matched the README and the spec. + +## Peer quirks + +- **python-smpplib's `gsm.GSM_CHARACTER_TABLE` disagrees with GSM 03.38 (and this library) at one + code point: 0x5F.** The real table's value there is SECTION SIGN (§); smpplib's own table (its + `gsm.py` docstring already calls it "vendor-specific and not recommended for use") has a backtick + instead. Encoding a literal backtick through `gsm.gsm_encode()` sends byte `0x5F`, which this + library correctly decodes to §; sending §'s own byte back and decoding it through smpplib's table + (as the driver's `gsm_decode()` deliberately does, to compare like for like) reads a backtick, not + a §. Both directions reproduced in `python.test.ts`'s "peer quirk" test. Confirmed by diffing + `smpplib.gsm.GSM_CHARACTER_TABLE[:128]` against this library's own `gsmChars` table (identical at + every other of the 128 positions). +- **`gsm.gsm_encode()` cannot produce a form feed at all.** The character table represents that + extension-table slot with a placeholder backtick, not the literal `\x0c` character, so + `GSM_CHARACTER_TABLE.index('\x0c')` raises `ValueError` (wrapped as `UnicodeError`) for any attempt + to encode one. The driver builds the `1b 0a` byte pair by hand for that one character (see + `python.test.ts`'s "form feed" test) rather than through the library's own encoder. +- **`auto_send_enquire_link` defaults to `True`**, on `read_once()`/`poll()`/`listen()` - not opt-in, + correcting `research/esme-clients-and-validators.md`'s A4 note. The driver passes it explicitly + either way so both keepalive scenarios are deliberate. +- **php-smpp's `GsmEncoderHelper::utf8_to_gsm0338()` has no dictionary entry for ¤ (CURRENCY SIGN, + U+00A4, GSM 03.38 code `0x24`).** `strtr()` only rewrites characters present in its dict; ¤ passes + through as its raw two-byte UTF-8 sequence (`c2 a4`), which this library's GSM decoder (correctly, + since neither byte is in the 128-entry table) renders as two spaces. Reproduced manually (not + asserted in `php.test.ts`, to keep that suite's own assertions unambiguous): submitting + `"price:¤100"` at `data_coding` 0 arrives as `"price: 100"`. +- **This php-smpp fork's `bindTransceiver()` actually works**, against both the plan's premise and + the fork's own inherited README ("You can't connect as a transceiver, otherwise supported by SMPP + v.3.4" - upstream OnlineCity text, unchanged by this fork despite `Client.php` plainly implementing + `bindTransceiver()`). Confirmed directly: `php.test.ts`'s "Peer quirk: bindTransceiver() actually + works" binds one against `server()` and gets `boundAs === 'transceiver'`. `php.test.ts`'s S4 + scenarios still use separate TX/RX binds deliberately, since that is the shape target 8 needs + regardless of whether TRX also happens to work. +- **This fork's `composer.json` declares `"license": "LGPL-2.0-or-later"`**, though no top-level + `LICENSE` file exists in the repo - correcting the plan's "no licence declared" for this specific + fork (true of the field, not true of the declaration). Still test-only per the phase brief; not + vendored into this repo either way. +- **`submit_sm()`'s returned message id carries a trailing NUL byte** (`unpack("a*msgid", ...)` + keeps it, unlike PHP's `A`-format unpack which would trim it) - cosmetic, not asserted against in + `php.test.ts`. + +## Open questions + +- Whether php-smpp's other `is_resource()`-adjacent assumptions (none found beyond `isOpen()` in this + version) would surface on a longer-running session than these scenarios exercise. +- Whether smpplib's `0x5F` table quirk affects any other vendor beyond this one - not checked against + a second Python client, since none of comparable maturity was in scope for this phase. diff --git a/interop-tests/peers/php/Dockerfile b/interop-tests/peers/php/Dockerfile new file mode 100644 index 0000000..967718d --- /dev/null +++ b/interop-tests/peers/php/Dockerfile @@ -0,0 +1,35 @@ +# No licence declared for this fork (composer.json says LGPL-2.0-or-later, but no top-level +# LICENSE file - see findings/06-python-php.md); test-only, cloned at a pinned commit, never +# vendored into this repo. +FROM php:8.4.25-cli AS source + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +ARG PHPSMPP_COMMIT=1d3b53c2d2b63d51ab70009d956914b7f8903118 + +RUN git clone https://github.com/alexandr-mironov/php-smpp.git /src \ + && cd /src \ + && git checkout "${PHPSMPP_COMMIT}" + +# PHP 8's sockets extension returns a Socket object from socket_create(), not a resource; this +# fork's own isOpen() checks is_resource() alone (written pre-PHP8), so it is always false and every +# guarded call ("Socket is not open") fails immediately, on any PHP 8.x. Patched here, not in +# src/Socket.php - see findings/06-python-php.md. +RUN sed -i 's/if (!is_resource(\$this->socket)) {/if (!is_resource(\$this->socket) \&\& !(\$this->socket instanceof \\Socket)) {/' /src/src/transport/Socket.php + +FROM php:8.4.25-cli + +RUN apt-get update \ + && apt-get install -y --no-install-recommends libonig-dev \ + && rm -rf /var/lib/apt/lists/* \ + && docker-php-ext-install sockets mbstring + +WORKDIR /app +COPY --from=source /src/src /app/smpp-src +COPY driver.php . + +EXPOSE 8080 +ENTRYPOINT ["php", "/app/driver.php"] +CMD ["node", "2775"] diff --git a/interop-tests/peers/php/driver.php b/interop-tests/peers/php/driver.php new file mode 100644 index 0000000..be3474c --- /dev/null +++ b/interop-tests/peers/php/driver.php @@ -0,0 +1,340 @@ + */ +$clients = []; + +function jsonBody(): array +{ + $raw = file_get_contents('php://input'); + + return $raw === '' || $raw === false ? [] : (json_decode($raw, true) ?? []); +} + +function respond(int $status, array $payload): void +{ + http_response_code($status); + header('Content-Type: application/json'); + echo json_encode($payload); +} + +function encodeBody(string $text, int $dataCoding): string +{ + if ($dataCoding === SMPP::DATA_CODING_DEFAULT) { + return GsmEncoderHelper::utf8_to_gsm0338($text); + } + + if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) { + return mb_convert_encoding($text, 'ISO-8859-1', 'UTF-8'); + } + + // UCS2: sendSMS() converts internally, so the raw UTF-8 text passes straight through here. + return $text; +} + +// Inverse of GsmEncoderHelper's dict, for decoding what this driver receives - reusing the peer's +// own table so a mismatch against what was sent is the peer's own encode/decode disagreeing with +// itself, not this driver guessing at the real GSM 03.38 one. +function gsmDecode(string $data): string +{ + static $reverse = null; + + if ($reverse === null) { + $reverse = []; + + foreach (gsmEncodeDict() as $char => $bytes) { + $reverse[$bytes] = $char; + } + } + + $result = ''; + $length = strlen($data); + $i = 0; + + while ($i < $length) { + if ($data[$i] === "\x1B" && $i + 1 < $length) { + $pair = substr($data, $i, 2); + $result .= $reverse[$pair] ?? '?'; + $i += 2; + } else { + $result .= $reverse[$data[$i]] ?? $data[$i]; + $i += 1; + } + } + + return $result; +} + +// The dict inside utf8_to_gsm0338() is a local literal, not a class constant - re-derived once by +// encoding every basic-table/extension character singly and reading back what came out, rather +// than duplicating the private table here. +function gsmEncodeDict(): array +{ + static $dict = null; + + if ($dict !== null) { + return $dict; + } + + $dict = []; + $candidates = ['@', '£', '$', '¥', 'è', 'é', 'ù', 'ì', 'ò', 'Ç', 'Ø', 'ø', 'Å', 'å', 'Δ', '_', 'Φ', 'Γ', 'Λ', 'Ω', 'Π', 'Ψ', 'Σ', 'Θ', 'Ξ', 'Æ', 'æ', 'ß', 'É', '¡', 'Ä', 'Ö', 'Ñ', 'Ü', '§', '¿', 'ä', 'ö', 'ñ', 'ü', 'à', '^', '{', '}', '\\', '[', '~', ']', '|', '€']; + + foreach ($candidates as $char) { + $encoded = GsmEncoderHelper::utf8_to_gsm0338($char); + + if ($encoded !== $char) { + $dict[$char] = $encoded; + } + } + + return $dict; +} + +function decodeBody(string $data, int $dataCoding): string +{ + if ($dataCoding === SMPP::DATA_CODING_UCS2) { + return mb_convert_encoding($data, 'UTF-8', 'UCS-2BE'); + } + + if ($dataCoding === SMPP::DATA_CODING_ISO8859_1) { + return mb_convert_encoding($data, 'UTF-8', 'ISO-8859-1'); + } + + return gsmDecode($data); +} + +function doBind(array $body): array +{ + global $clients, $targetHost, $targetPort; + + $name = $body['name']; + $recvTimeoutMs = (int) ($body['recvTimeoutMs'] ?? 5000); + + $transport = new Socket([$targetHost], $targetPort); + $transport->setRecvTimeout($recvTimeoutMs); + $transport->open(); + + $client = new Client($transport); + Client::$smsNullTerminateOctetStrings = false; + + if (isset($body['interfaceVersion'])) { + Client::$interfaceVersion = (int) $body['interfaceVersion']; + } + + $systemId = $body['systemId']; + $password = $body['password']; + + switch ($body['mode']) { + case 'transmitter': + $client->bindTransmitter($systemId, $password); + break; + case 'receiver': + $client->bindReceiver($systemId, $password); + break; + default: + $client->bindTransceiver($systemId, $password); + } + + $clients[$name] = $client; + + return ['ok' => true]; +} + +function doUnbind(array $body): array +{ + global $clients; + + $clients[$body['name']]->close(); + unset($clients[$body['name']]); + + return ['ok' => true]; +} + +function doEnquireLink(array $body): array +{ + global $clients; + + $clients[$body['name']]->enquireLink(); + + return ['ok' => true]; +} + +/** Single, non-CSMS submit - used by the refusal and bind-direction scenarios. */ +function doSubmit(array $body): array +{ + global $clients; + + $client = $clients[$body['name']]; + $dataCoding = (int) ($body['dataCoding'] ?? SMPP::DATA_CODING_DEFAULT); + $message = encodeBody($body['text'], $dataCoding); + $from = new Address($body['from']); + $to = new Address($body['to']); + + try { + $messageId = $client->sendSMS($from, $to, $message, null, $dataCoding); + + return ['messageId' => $messageId, 'ok' => true]; + } catch (SmppException $e) { + return ['ok' => false, 'status' => $e->getCode()]; + } +} + +/** One message in each of the three CSMS spellings; Client::$csmsMethod is process-global, so this + * driver serves one request at a time by construction (see the top-of-file note). */ +function doSendLong(array $body): array +{ + global $clients; + + $client = $clients[$body['name']]; + Client::$csmsMethod = (int) $body['csmsMethod']; + $message = encodeBody($body['text'], SMPP::DATA_CODING_DEFAULT); + $from = new Address($body['from']); + $to = new Address($body['to']); + + try { + $messageId = $client->sendSMS($from, $to, $message, null, SMPP::DATA_CODING_DEFAULT); + + return ['lastMessageId' => $messageId, 'ok' => true]; + } catch (SmppException $e) { + return ['ok' => false, 'status' => $e->getCode()]; + } +} + +function doReceive(array $body): array +{ + global $clients; + + $client = $clients[$body['name']]; + $sms = $client->readSMS(); + + if ($sms === false) { + return ['ok' => true, 'received' => false]; + } + + return [ + 'dataCoding' => $sms->dataCoding, + 'esmClass' => $sms->esmClass, + 'from' => $sms->source->value, + 'hex' => bin2hex($sms->message), + 'isReceipt' => $sms instanceof DeliveryReceipt, + 'ok' => true, + 'received' => true, + 'text' => decodeBody($sms->message, $sms->dataCoding), + 'to' => $sms->destination->value, + ]; +} + +const ROUTES = [ + '/bind' => 'doBind', + '/enquireLink' => 'doEnquireLink', + '/receive' => 'doReceive', + '/sendLong' => 'doSendLong', + '/submit' => 'doSubmit', + '/unbind' => 'doUnbind', +]; + +// Minimal single-connection HTTP server: no framework, one request handled fully before the next +// is accepted - which is exactly what a synchronous, blocking SMPP client needs (see top note). +$listen = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr); + +if ($listen === false) { + fwrite(STDERR, "listen failed: $errstr\n"); + exit(1); +} + +fwrite(STDERR, "php-smpp driver listening on 8080, target $targetHost:$targetPort\n"); + +while (true) { + $conn = @stream_socket_accept($listen, -1); + + if ($conn === false) { + continue; + } + + $requestLine = fgets($conn); + $method = 'GET'; + $path = '/'; + + if ($requestLine !== false && preg_match('#^(\\S+)\\s+(\\S+)#', $requestLine, $m)) { + $method = $m[1]; + $path = parse_url($m[2], PHP_URL_PATH) ?? '/'; + } + + $contentLength = 0; + + while (($line = fgets($conn)) !== false && trim($line) !== '') { + if (preg_match('/^Content-Length:\\s*(\\d+)/i', $line, $m)) { + $contentLength = (int) $m[1]; + } + } + + $rawBody = ''; + + while (strlen($rawBody) < $contentLength) { + $chunk = fread($conn, $contentLength - strlen($rawBody)); + + if ($chunk === false || $chunk === '') { + break; + } + + $rawBody .= $chunk; + } + $body = $rawBody === '' || $rawBody === false ? [] : (json_decode($rawBody, true) ?? []); + + if ($path === '/health') { + $payload = ['ok' => true]; + $status = 200; + } elseif (isset(ROUTES[$path])) { + try { + $payload = ROUTES[$path]($body); + $status = 200; + } catch (Throwable $e) { + $payload = ['error' => get_class($e) . ': ' . $e->getMessage()]; + $status = 500; + } + } else { + $payload = ['error' => 'no such route']; + $status = 404; + } + + $json = json_encode($payload); + $statusText = $status === 200 ? 'OK' : ($status === 404 ? 'Not Found' : 'Internal Server Error'); + fwrite( + $conn, + "HTTP/1.1 $status $statusText\r\nContent-Type: application/json\r\nContent-Length: " + . strlen($json) . "\r\nConnection: close\r\n\r\n" . $json + ); + fclose($conn); +} diff --git a/interop-tests/peers/python/Dockerfile b/interop-tests/peers/python/Dockerfile new file mode 100644 index 0000000..22b5dbe --- /dev/null +++ b/interop-tests/peers/python/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12.14-slim-bookworm + +RUN pip install --no-cache-dir smpplib==2.2.4 + +WORKDIR /app +COPY driver.py . + +EXPOSE 8080 +ENTRYPOINT ["python3", "/app/driver.py"] +CMD ["node", "2775"] diff --git a/interop-tests/peers/python/driver.py b/interop-tests/peers/python/driver.py new file mode 100644 index 0000000..699e0a1 --- /dev/null +++ b/interop-tests/peers/python/driver.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""HTTP-driven python-smpplib ESME: binds named sessions against the target SMPP server and +performs one action per request, answering with the result as JSON. Kept alive as one process so a +session survives across requests, the way jsmpp's Java driver does (see AGENTS.md).""" +import json +import socket +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +import smpplib.client +import smpplib.consts +import smpplib.exceptions +import smpplib.gsm +import smpplib.smpp + +TARGET_HOST = sys.argv[1] if len(sys.argv) > 1 else "node" +TARGET_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 2775 + +GSM_TABLE = smpplib.gsm.GSM_CHARACTER_TABLE + +MODES = { + "receiver": "bind_receiver", + "transceiver": "bind_transceiver", + "transmitter": "bind_transmitter", +} + + +def as_text(value): + return value.decode() if isinstance(value, bytes) else value + + +def gsm_decode(data: bytes) -> str: + """Inverse of smpplib's own gsm_encode(), through its own (vendor-specific) table - used to + decode what this driver receives, so a mismatch against what was sent is smpplib's own table, + not a guess at the real GSM 03.38 one.""" + chars = [] + i = 0 + while i < len(data): + byte = data[i] + if byte == 0x1B and i + 1 < len(data): + chars.append(GSM_TABLE[0x80 + data[i + 1]]) + i += 2 + else: + chars.append(GSM_TABLE[byte]) + i += 1 + return "".join(chars) + + +def decode_body(data: bytes, data_coding: int) -> str: + if data_coding in (0, 1): + return gsm_decode(data) + if data_coding == 3: + return data.decode("latin-1") + if data_coding == 8: + whole = len(data) - (len(data) % 2) + return data[:whole].decode("utf-16-be") + return data.hex() + + +class Session: + def __init__(self, client): + self.client = client + self.send_lock = threading.Lock() + self.received = [] + self.acks = {} + self.ack_events = {} + self.reader_thread = None + self.reader_running = False + self.reader_error = None + + def wait_ack(self, sequence, budget=8.0): + event = self.ack_events.setdefault(sequence, threading.Event()) + event.wait(budget) + return self.acks.get(sequence) + + +SESSIONS = {} +SESSIONS_LOCK = threading.Lock() + + +def session_for(name): + with SESSIONS_LOCK: + return SESSIONS[name] + + +def do_bind(body): + name = body["name"] + mode = body["mode"] + timeout_secs = float(body.get("timeoutSecs", 5)) + + client = smpplib.client.Client(TARGET_HOST, TARGET_PORT, timeout=timeout_secs, allow_unknown_opt_params=True) + client.connect() + + kwargs = {"system_id": body["systemId"], "password": body["password"]} + if body.get("interfaceVersion") is not None: + kwargs["interface_version"] = int(body["interfaceVersion"]) + + getattr(client, MODES[mode])(**kwargs) + + session = Session(client) + + def on_received(pdu, **_kwargs): + data = pdu.short_message or b"" + session.received.append({ + "dataCoding": pdu.data_coding, + "esmClass": pdu.esm_class, + "from": as_text(pdu.source_addr), + "hex": data.hex(), + "text": decode_body(data, pdu.data_coding), + "to": as_text(pdu.destination_addr), + }) + return smpplib.consts.SMPP_ESME_ROK + + def on_sent(pdu, **_kwargs): + session.acks[pdu.sequence] = { + "messageId": as_text(getattr(pdu, "message_id", None)), + "status": int(pdu.status), + } + session.ack_events.setdefault(pdu.sequence, threading.Event()).set() + + def on_error_pdu(pdu): + # Overrides the default handler, which raises: a refusing status must reach + # message_sent_handler like any other response, not tear down the read loop. + if pdu.command == "submit_sm_resp": + on_sent(pdu) + + client.set_message_received_handler(on_received) + client.set_message_sent_handler(on_sent) + client.set_error_pdu_handler(on_error_pdu) + + with SESSIONS_LOCK: + SESSIONS[name] = session + + return {"ok": True} + + +def do_start_reader(body): + session = session_for(body["name"]) + auto_send_enquire_link = bool(body.get("autoSendEnquireLink", True)) + + if session.reader_running: + return {"ok": True} + + def run(): + session.reader_running = True + try: + while True: + session.client.read_once(auto_send_enquire_link=auto_send_enquire_link) + except Exception as exc: # noqa: BLE001 - recorded, not raised: this is a driver thread + session.reader_error = f"{type(exc).__name__}: {exc}" + finally: + session.reader_running = False + + session.reader_thread = threading.Thread(target=run, daemon=True) + session.reader_thread.start() + + return {"ok": True} + + +def encode_body(text, data_coding): + if data_coding == 0: + return smpplib.gsm.gsm_encode(text) + if data_coding == 3: + return text.encode("latin-1") + if data_coding == 8: + return text.encode("utf-16-be") + raise ValueError(f"unsupported dataCoding {data_coding}") + + +def do_submit(body): + # Does not wait for the submit_sm_resp: a single-segment message is only answered once the + # caller's own "sms" handler calls sendResp(), which the caller can only do after seeing this + # call return - waiting here would deadlock exactly that handshake. Poll /ack for the result. + session = session_for(body["name"]) + data_coding = int(body["dataCoding"]) + payload = encode_body(body.get("text", ""), data_coding) if "text" in body else b"" + + if body.get("extraHex"): + payload += bytes.fromhex(body["extraHex"]) + + with session.send_lock: + pdu = session.client.send_message( + source_addr=body["from"], + destination_addr=body["to"], + short_message=payload, + data_coding=data_coding, + esm_class=int(body.get("esmClass", 0)), + ) + sequence = pdu.sequence + + return {"ok": True, "sequence": sequence} + + +def do_ack(query): + session = session_for(query["name"][0]) + sequence = int(query["sequence"][0]) + ack = session.acks.get(sequence) + + if ack is None: + return {"found": False, "ok": True} + + return {"found": True, "messageId": ack["messageId"], "ok": True, "status": ack["status"]} + + +def do_submit_long(body): + session = session_for(body["name"]) + data_coding = int(body["dataCoding"]) + parts, encoding, esm_class = smpplib.gsm.make_parts(body["text"], encoding=data_coding, use_udhi=True) + + results = [] + + for part in parts: + with session.send_lock: + pdu = session.client.send_message( + source_addr=body["from"], + destination_addr=body["to"], + short_message=part, + data_coding=encoding, + esm_class=esm_class, + ) + sequence = pdu.sequence + + ack = session.wait_ack(sequence, budget=8) + results.append(ack) + + return {"ok": all(results), "parts": len(parts), "results": results} + + +def do_enquire_link(body): + session = session_for(body["name"]) + + with session.send_lock: + pdu = smpplib.smpp.make_pdu("enquire_link", client=session.client) + session.client.send_pdu(pdu) + + return {"ok": True} + + +def do_received(name): + session = session_for(name) + + return {"ok": True, "received": session.received} + + +def do_status(name): + session = session_for(name) + + return { + "ok": True, + "readerError": session.reader_error, + "readerRunning": session.reader_running, + "receivedCount": len(session.received), + } + + +def do_idle_silent(body): + """Sleeps `seconds` sending nothing at all - no reader thread, no enquire_link - then does one + read attempt to say whether the peer (our server) closed the link while it was silent.""" + session = session_for(body["name"]) + time.sleep(float(body["seconds"])) + + session.client._socket.settimeout(2) + + try: + session.client.read_pdu() + + return {"closed": False, "ok": True} + except socket.timeout: + return {"closed": False, "ok": True} + except smpplib.exceptions.ConnectionError: + return {"closed": True, "ok": True} + + +def do_unbind(body): + session = session_for(body["name"]) + + try: + session.client.unbind() + except Exception: # noqa: BLE001 - best-effort teardown + pass + + session.client.disconnect() + + with SESSIONS_LOCK: + del SESSIONS[body["name"]] + + return {"ok": True} + + +ROUTES = { + "/bind": lambda body, _query: do_bind(body), + "/enquireLink": lambda body, _query: do_enquire_link(body), + "/idleSilent": lambda body, _query: do_idle_silent(body), + "/startReader": lambda body, _query: do_start_reader(body), + "/submit": lambda body, _query: do_submit(body), + "/submitLong": lambda body, _query: do_submit_long(body), + "/unbind": lambda body, _query: do_unbind(body), +} + +GET_ROUTES = { + "/ack": do_ack, + "/received": lambda query: do_received(query["name"][0]), + "/status": lambda query: do_status(query["name"][0]), +} + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + def _respond(self, status, payload): + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/health": + self._respond(200, {"ok": True}) + return + + parsed = urlparse(self.path) + handler = GET_ROUTES.get(parsed.path) + + if handler is None: + self._respond(404, {"error": "no such route"}) + return + + try: + self._respond(200, handler(parse_qs(parsed.query))) + except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process + self._respond(500, {"error": f"{type(exc).__name__}: {exc}"}) + + def do_POST(self): + handler = ROUTES.get(self.path) + + if handler is None: + self._respond(404, {"error": "no such route"}) + return + + length = int(self.headers.get("Content-Length", 0)) + raw = self.rfile.read(length) if length else b"{}" + body = json.loads(raw or b"{}") + + try: + self._respond(200, handler(body, None)) + except Exception as exc: # noqa: BLE001 - surfaced to the caller, not the process + self._respond(500, {"error": f"{type(exc).__name__}: {exc}"}) + + +def main(): + server = ThreadingHTTPServer(("0.0.0.0", 8080), Handler) + print(f"python-smpplib driver listening on 8080, target {TARGET_HOST}:{TARGET_PORT}", file=sys.stderr) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/interop-tests/php.test.ts b/interop-tests/php.test.ts new file mode 100644 index 0000000..0415ce9 --- /dev/null +++ b/interop-tests/php.test.ts @@ -0,0 +1,231 @@ +import assert from 'node:assert/strict'; +import test, { after, describe } from 'node:test'; +import type { Session } from '../src/session.ts'; +import type { Sms } from '../src/sms.ts'; +import { paramText } from '../src/defs/types.ts'; +import { isCommand, server } from '../src/index.ts'; + +const DRIVER = process.env.PHP_DRIVER ?? 'php:8080'; +const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); +const REFUSED_DEST = 'REFUSEME'; + +function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); +} + +async function waitFor(get: () => T | undefined, budget = 8000): Promise { + const deadline = Date.now() + budget; + let value = get(); + + while (value === undefined && Date.now() < deadline) { + await delay(50); + value = get(); + } + + return value; +} + +type JsonBody = Record; + +async function post(path: string, body: JsonBody): Promise { + const res = await fetch(`http://${DRIVER}${path}`, { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + return res.json() as Promise; +} + +const allSms: { sms: Sms; systemId: string }[] = []; +const systemIdBySession = new Map(); + +const { err: serverErr, server: smpp } = await server({ + onRequest: async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false; + + await session.sendReturn(pduObj, 'ESME_RTHROTTLED'); + + return true; + }, + port: SMPP_PORT, +}); + +assert.equal(serverErr, undefined); +assert.ok(smpp); + +const smppServer = smpp; + +smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { + if (!pduObj.cmdName.startsWith('bind_')) return; + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); + + session.on('sms', sms => { + const systemId = systemIdBySession.get(session); + + if (systemId) allSms.push({ sms, systemId }); + + // php-smpp's submit_sm() blocks synchronously reading the response on the same connection + // that sent it, so answering here (rather than after this event's own test observes the + // sms) is the only way that read ever completes - unlike python-smpplib's driver, this one + // has no separate reader thread to poll afterwards. + void sms.sendResp(); + }); +}); + +after(async () => { + await smppServer.close(); +}); + +function sessionFor(systemId: string): Session | undefined { + return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId); +} + +async function waitForSession(systemId: string, budget = 8000): Promise { + const found = await waitFor(() => sessionFor(systemId), budget); + + assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`); + + return found; +} + +async function waitForSms(systemId: string, message: string, budget = 8000): Promise { + const found = await waitFor( + () => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms, + budget, + ); + + assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`); + + return found; +} + +async function bindClient(name: string, mode: 'receiver' | 'transceiver' | 'transmitter', opts: Partial = {}): Promise { + const bound = await post('/bind', { mode, name, password: 'pw', systemId: name, ...opts }); + + assert.equal(bound.ok, true, JSON.stringify(bound)); +} + +describe('S2 - long messages (php-smpp, three CSMS spellings)', () => { + const modes: { csmsMethod: number; name: string }[] = [ + { csmsMethod: 0, name: 's2-16bit-tags' }, + { csmsMethod: 1, name: 's2-payload' }, + { csmsMethod: 2, name: 's2-8bit-udh' }, + ]; + + for (const { csmsMethod, name } of modes) { + test(`csmsMethod=${String(csmsMethod)} (${name}) reassembles into one sms with the full text`, async () => { + await bindClient(name, 'transceiver'); + + const text = 'L'.repeat(350); + const sent = await post('/sendLong', { csmsMethod, from: '46700000001', name, text, to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + await waitForSms(name, text, 15_000); + }); + } +}); + +describe('S4 - bind direction (php-smpp forces separate TX and RX binds)', () => { + test('php-smpp opens a transmitter bind and a receiver bind, both accepted', async () => { + await bindClient('s4-tx', 'transmitter'); + await bindClient('s4-rx', 'receiver', { recvTimeoutMs: 8000 }); + + const tx = await waitForSession('s4-tx'); + const rx = await waitForSession('s4-rx'); + + assert.equal(tx.boundAs, 'transmitter'); + assert.equal(rx.boundAs, 'receiver'); + assert.equal(tx.bindAllows('deliver_sm'), false); + assert.equal(rx.bindAllows('submit_sm'), false); + }); + + test('a submit_sm on the receiver bind is answered ESME_RINVBNDSTS, and the peer keeps working', async () => { + const rx = await waitForSession('s4-rx'); + + assert.ok(rx); + + const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's4-rx', text: 'nope', to: '46700000002' }); + + assert.equal(refused.ok, false); + assert.equal(refused.status, 4); // ESME_RINVBNDSTS + + const link = await post('/enquireLink', { name: 's4-rx' }); + + assert.equal(link.ok, true); + }); + + test('submit_sm on the transmitter bind works normally', async () => { + const text = 's4 tx works'; + const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's4-tx', text, to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s4-tx', text); + + assert.equal(sms.session, await waitForSession('s4-tx')); + }); + + test('a deliver_sm built on the receiver bind reaches php-smpp; the transmitter bind never gets one', async () => { + const rx = await waitForSession('s4-rx'); + + const text = 's4 mo on rx'; + + // php-smpp's readSMS() blocks synchronously reading and answering, so it has to be in flight + // before the deliver_sm goes out - awaiting rx.send() first would wait on an answer nothing + // is there yet to send. + const receivePromise = post('/receive', { name: 's4-rx' }); + const sent = await rx.send({ + cmdName: 'deliver_sm', + params: { destination_addr: '46700000001', short_message: Buffer.from(text), source_addr: '46700000002' }, + }); + + assert.equal(sent.err, undefined); + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); + + const received = await receivePromise; + + assert.equal(received.ok, true); + assert.equal(received.received, true); + assert.equal(received.text, text); + + const nothingOnTx = await post('/receive', { name: 's4-tx' }); + + assert.equal(nothingOnTx.ok, true); + assert.equal(nothingOnTx.received, false); + }); +}); + +describe('Refusals via onRequest', () => { + test('a refusing status through onRequest is surfaced to php-smpp as a catchable status, not a hang or close', async () => { + await bindClient('refusal', 'transceiver'); + + const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name: 'refusal', text: 'nope', to: REFUSED_DEST }); + + assert.equal(refused.ok, false); + assert.equal(refused.status, 0x58); // ESME_RTHROTTLED + + const link = await post('/enquireLink', { name: 'refusal' }); + + assert.equal(link.ok, true); + + const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name: 'refusal', text: 'still works', to: '46700000002' }); + + assert.equal(after1.ok, true, JSON.stringify(after1)); + }); +}); + +describe('Peer quirk: bindTransceiver() actually works, despite this fork\'s inherited README', () => { + test('a transceiver bind against our server is accepted', async () => { + await bindClient('trx-quirk', 'transceiver'); + + const session = await waitForSession('trx-quirk'); + + assert.equal(session.boundAs, 'transceiver'); + }); +}); diff --git a/interop-tests/python.test.ts b/interop-tests/python.test.ts new file mode 100644 index 0000000..84f1c66 --- /dev/null +++ b/interop-tests/python.test.ts @@ -0,0 +1,405 @@ +import assert from 'node:assert/strict'; +import test, { after, describe } from 'node:test'; +import type { Session } from '../src/session.ts'; +import type { Sms } from '../src/sms.ts'; +import { encodings } from '../src/defs/encodings.ts'; +import { paramText } from '../src/defs/types.ts'; +import { isCommand, server } from '../src/index.ts'; + +const DRIVER = process.env.PYTHON_DRIVER ?? 'python:8080'; +const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); +const REFUSED_DEST = 'REFUSEME'; + +function delay(ms: number): Promise { + return new Promise(resolve => { setTimeout(resolve, ms); }); +} + +async function waitFor(get: () => T | undefined, budget = 8000): Promise { + const deadline = Date.now() + budget; + let value = get(); + + while (value === undefined && Date.now() < deadline) { + await delay(50); + value = get(); + } + + return value; +} + +type JsonBody = Record; + +async function post(path: string, body: JsonBody): Promise { + const res = await fetch(`http://${DRIVER}${path}`, { + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + + return res.json() as Promise; +} + +async function get(path: string): Promise { + const res = await fetch(`http://${DRIVER}${path}`); + + return res.json() as Promise; +} + +async function bindReader(name: string, opts: Partial = {}): Promise { + const bound = await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, ...opts }); + + assert.equal(bound.ok, true, JSON.stringify(bound)); + + const started = await post('/startReader', { autoSendEnquireLink: true, name }); + + assert.equal(started.ok, true, JSON.stringify(started)); +} + +// --- Shared infra: one server() for the whole file, one session per named python-smpplib bind, +// tracked by the system_id the driver binds with - the same pattern as kannel.test.ts's variant. --- + +const allSms: { sms: Sms; systemId: string }[] = []; +const systemIdBySession = new Map(); + +const { err: serverErr, server: smpp } = await server({ + onRequest: async (session, pduObj) => { + if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false; + + await session.sendReturn(pduObj, 'ESME_RTHROTTLED'); + + return true; + }, + port: SMPP_PORT, +}); + +assert.equal(serverErr, undefined); +assert.ok(smpp); + +const smppServer = smpp; + +smppServer.on('session', session => { + session.on('incomingPduObj', pduObj => { + if (!pduObj.cmdName.startsWith('bind_')) return; + + systemIdBySession.set(session, paramText(pduObj.params.system_id)); + }); + + session.on('sms', sms => { + const systemId = systemIdBySession.get(session); + + if (systemId) allSms.push({ sms, systemId }); + }); +}); + +after(async () => { + await smppServer.close(); +}); + +function sessionFor(systemId: string): Session | undefined { + return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId); +} + +async function waitForSession(systemId: string, budget = 8000): Promise { + const found = await waitFor(() => sessionFor(systemId), budget); + + assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`); + + return found; +} + +async function waitForSms(systemId: string, message: string, budget = 8000): Promise { + const found = await waitFor( + () => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms, + budget, + ); + + assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`); + + return found; +} + +type ReceivedEntry = { dataCoding: number; esmClass: number; from: string; hex: string; text: string; to: string }; + +async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => boolean, budget = 8000): Promise { + const found = await waitFor(async () => { + const status = await get(`/received?name=${name}`); + const list = status.received as ReceivedEntry[]; + + return list.find(predicate); + }, budget); + + assert.ok(found, `no matching received entry for ${name} within ${String(budget)}ms`); + + return found; +} + +type AckResult = { messageId?: string; status?: number }; + +// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so +// /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards. +async function waitForAck(name: string, sequence: number, budget = 8000): Promise { + const found = await waitFor(async () => { + const status = await get(`/ack?name=${name}&sequence=${String(sequence)}`); + + return status.found ? status : undefined; + }, budget); + + assert.ok(found, `no ack for sequence ${String(sequence)} (${name}) within ${String(budget)}ms`); + + return found; +} + +async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise { + const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII'; + const buf = encodings[encName].encode(text); + const sent = await session.send({ + cmdName: 'deliver_sm', + params: { data_coding: dataCoding, destination_addr: sms.from, short_message: buf, source_addr: sms.to }, + }); + + assert.equal(sent.err, undefined); + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); +} + +// The real GSM 03.38 basic table (128 unique chars), as this library decodes it. +const ourTable = + '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'; +// python-smpplib's own gsm.GSM_CHARACTER_TABLE[:128] - identical except at 0x5F, a backtick where +// the real table (and this library) has a section sign. +const theirTable = + '@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà'; +const ESC_INDEX = 27; +const sendBasic = theirTable.slice(0, ESC_INDEX) + theirTable.slice(ESC_INDEX + 1); +const expectBasic = ourTable.slice(0, ESC_INDEX) + ourTable.slice(ESC_INDEX + 1); +const extensionChars = '€[]{}\\|~^'; + +describe('S11 - encodings (python-smpplib)', () => { + test('GSM 03.38 basic table + extension table round trip (python -> server)', async () => { + await bindReader('s11-basic'); + + const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-basic', text: sendBasic + extensionChars, to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s11-basic', expectBasic + extensionChars); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0); + }); + + test('form feed (0x1B 0x0A), which smpplib\'s own gsm_encode() cannot build, round trips raw', async () => { + await bindReader('s11-ff'); + + const sent = await post('/submit', { dataCoding: 0, extraHex: '1b0a', from: '46700000001', name: 's11-ff', text: 'before-', to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s11-ff', 'before-\f'); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0); + }); + + test('Latin-1 round trip', async () => { + await bindReader('s11-latin1'); + + const text = 'café £ ñ ¿¡ © ®'; + const sent = await post('/submit', { dataCoding: 3, from: '46700000001', name: 's11-latin1', text, to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s11-latin1', text); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0); + }); + + test('UCS-2 with 一 and an emoji round trip', async () => { + await bindReader('s11-ucs2'); + + const text = '一😀hello'; + const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-ucs2', text, to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s11-ucs2', text); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0); + }); + + test('reverse direction: server sends GSM text back, python decodes it the same way', async () => { + await bindReader('s11-echo-gsm'); + + const text = 'hello from the server'; + const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-echo-gsm', text: 'seed', to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s11-echo-gsm', 'seed'); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0); + + const session = sessionFor('s11-echo-gsm'); + + assert.ok(session); + await echoBack(session, sms, 0, text); + + const received = await waitForReceived('s11-echo-gsm', e => e.text === text); + + assert.equal(received.dataCoding, 0); + }); + + test('reverse direction: server sends UCS-2 text back, python decodes it the same way', async () => { + await bindReader('s11-echo-ucs2'); + + const seed = 'seed-ucs2'; + const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-echo-ucs2', text: seed, to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s11-echo-ucs2', seed); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0); + + const session = sessionFor('s11-echo-ucs2'); + + assert.ok(session); + + const text = '一😀back'; + + await echoBack(session, sms, 8, text); + + const received = await waitForReceived('s11-echo-ucs2', e => e.text === text); + + assert.equal(received.dataCoding, 8); + }); + + test('peer quirk, documented both ways: byte 0x5F is section sign here, backtick in smpplib\'s own table', async () => { + await bindReader('s11-quirk'); + + // python encodes a literal backtick through its own gsm_encode(), landing on byte 0x5F - + // this library decodes that byte to SECTION SIGN, the real GSM 03.38 value. + const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-quirk', text: '`', to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + + const sms = await waitForSms('s11-quirk', '§'); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0); + + // Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads + // the same byte back as a backtick, not a section sign - the mismatch shows up both ways. + const session = sessionFor('s11-quirk'); + + assert.ok(session); + await echoBack(session, sms, 0, '§'); + + const received = await waitForReceived('s11-quirk', e => e.dataCoding === 0); + + assert.equal(received.text, '`'); + }); +}); + +describe('S2 - long messages (python-smpplib, UDH)', () => { + for (const segments of [2, 3, 10]) { + test(`${String(segments)} segments reassemble whole, each answered on arrival`, async () => { + const name = `s2-udh-${String(segments)}`; + + await bindReader(name); + + const text = Array.from({ length: 153 * (segments - 1) + 10 }, (_, i) => String(i % 10)).join(''); + + const sent = await post('/submitLong', { dataCoding: 0, from: '46700000001', name, text, to: '46700000002' }); + + assert.equal(sent.ok, true, JSON.stringify(sent)); + assert.equal(sent.parts, segments); + + const sms = await waitForSms(name, text, 15_000); + + assert.equal(sms.answeredOnArrival, true); + + const results = sent.results as { messageId: string; status: number }[]; + + assert.equal(results.length, segments); + + for (const [i, result] of results.entries()) { + assert.equal(result.status, 0); + assert.equal(result.messageId, `${sms.smsId}-${String(i + 1)}`); + } + }); + } +}); + +describe('Keepalive - python-smpplib\'s reactive enquire_link', () => { + test('idle past idleTimeout (40s) with no keepalive: our server drops the session', async () => { + const name = 'keepalive-none'; + + await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 50 }); + + const session = await waitForSession(name); + const closed: unknown[] = []; + + session.on('close', () => { closed.push(undefined); }); + + const idle = await post('/idleSilent', { name, seconds: 45 }); + + assert.equal(idle.ok, true, JSON.stringify(idle)); + assert.equal(idle.closed, true); + assert.deepEqual(closed, [undefined]); + }); + + test('idle past idleTimeout (40s) with auto_send_enquire_link: the link survives', async () => { + const name = 'keepalive-auto'; + + await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 10 }); + await waitForSession(name); + await post('/startReader', { autoSendEnquireLink: true, name }); + + const closed: unknown[] = []; + const session = sessionFor(name); + + assert.ok(session); + session.on('close', () => { closed.push(undefined); }); + + await delay(45_000); + + const status = await get(`/status?name=${name}`); + + assert.equal(status.readerRunning, true); + assert.equal(status.readerError, null); + assert.deepEqual(closed, []); + assert.ok(sessionFor(name), 'session should still be bound'); + }); +}); + +describe('Refusals via onRequest', () => { + test('a refusing status through onRequest is surfaced to python-smpplib, not a hang or close', async () => { + const name = 'refusal'; + + await bindReader(name); + + // Refused through onRequest, before reassembly and before the sms event, so this is answered + // without any sendResp() call - unlike every other submit in this file. + const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST }); + + assert.equal(refused.ok, true, JSON.stringify(refused)); + assert.equal((await waitForAck(name, refused.sequence as number)).status, 0x58); // ESME_RTHROTTLED + + const link = await post('/enquireLink', { name }); + + assert.equal(link.ok, true); + + const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'still works', to: '46700000002' }); + + assert.equal(after1.ok, true, JSON.stringify(after1)); + + const sms = await waitForSms(name, 'still works'); + + assert.equal((await sms.sendResp()).err, undefined); + assert.equal((await waitForAck(name, after1.sequence as number)).status, 0); + }); +}); diff --git a/interop-tests/run.py b/interop-tests/run.py index c8eba29..55170e8 100755 --- a/interop-tests/run.py +++ b/interop-tests/run.py @@ -14,9 +14,13 @@ EXPERT_SEVERITY_ERROR = "8388608" # The SMPP port each peer's compose overlay exposes its SMSC on. PORT_BY_PEER = { "cloudhopper": 2775, + "dumbclient": 2775, "jasmin": 2775, "jsmpp": 2775, "kannel": 2775, + "php": 2775, + "python": 2775, + "smppload": 2775, "smppsim": 2775, "smscsim": 2775, }