From 366fa26b1c422db72dca5f58bdd1a9280d115f52 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Thu, 27 Aug 2026 14:07:42 +0200 Subject: [PATCH] Enforce bind direction, cap segments per message and pin the README examples --- AGENTS.md | 9 +- README.md | 29 ++++- eslint.config.js | 5 + src/client.ts | 6 +- src/incoming-requests.ts | 10 ++ src/send-sms.ts | 61 ++++++---- src/server.ts | 3 +- src/session-options.ts | 22 ++++ src/session.ts | 16 ++- src/sms.ts | 4 + test/readme.test.ts | 235 ++++++++++++++++++++++++++++++++++++ test/session-extras.test.ts | 25 ++++ test/session.test.ts | 121 +++++++++++++++++++ todo.md | 53 +++++++- 14 files changed, 568 insertions(+), 31 deletions(-) create mode 100644 test/readme.test.ts diff --git a/AGENTS.md b/AGENTS.md index 932a1f2..52c2cde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ src/ result.ts Result — the shape every fallible call returns send-sms.ts submitSms composition and the submitSmParams builder send-window.ts SendWindow: the maxOutstanding semaphore - session-options.ts SessionOptions, ReconnectOptions and the session defaults + session-options.ts SessionOptions, ReconnectOptions, bind direction and the session defaults udh.ts User data header: the concatenation fields of a long SMS uuid.ts uuidv7() — the ids the library generates for messages defs/ @@ -185,6 +185,13 @@ exactly 140. `server()`, so an implementation that needs 5.0 throughout can have it. The threshold at or above which a peer may be sent optional parameters is fixed at 0x34 by the spec and is not the same constant as the declared version. +- **Bind direction is enforced on the library's own senders and on everything incoming, not on + `send()`.** A receiver-bound ESME carries no `submit_sm` and a transmitter-bound one no + `deliver_sm`; `sendSms()` and `sendDlr()` refuse locally, and an arriving PDU is answered + `ESME_RINVBNDSTS`. `bindAllows()` is a predicate on the same footing as `acceptsOptionalParams()`, + so the deliberately public low-level `send()` stays a passthrough. Only those two commands are + policed, because they are the only ones the library sends and dispatches by direction. + - **The logger is a five-method contract this library declares, not a dependency.** `SmppLog` in `log.ts` is what the code actually calls (`debug`, `error`, `info`, `verbose`, `warn`), so an application can satisfy it with an object literal and `@larvit/smpp` ships with no runtime diff --git a/README.md b/README.md index 6089f6d..4c3c348 100644 --- a/README.md +++ b/README.md @@ -92,16 +92,24 @@ Every one is optional. ```javascript await session.sendSms({ dlr: true, // ask for a delivery report + destinationAddrNpi: 0, // override the numbering plan of the recipient + destinationAddrTon: 1, encoding: 'UCS2', // override the automatic choice flash: false, from: 'MyBrand', // alphanumeric -> TON 5, digits -> TON 1 + maxSegments: 10, // refuse a longer message instead of sending it message: 'Hello world', scheduleDeliveryTime: new Date(Date.now() + 3600_000), + sourceAddrNpi: 0, // override the numbering plan of the sender + sourceAddrTon: 5, to: '46709771337', validityPeriod: 3600, // seconds, or a Date }, { signal }); // optional per-call AbortSignal ``` +`sourceAddrTon` and `destinationAddrTon` default to 5 for an alphanumeric address and 1 for a +numeric one; the NPI fields default to 0. Set them for an operator that requires something else. + Messages too long for one SMS are split automatically and sent as a concatenated message. You get one id per segment: @@ -113,7 +121,8 @@ const { err, pduObjs, smsIds } = await session.sendSms({ from, message, to }); segment goes on the wire together, `pduObjs` and `smsIds` then hold what the SMSC did accept — enough to reconcile against a later receipt, not enough to resend the rest, so treat a partial failure as a failed message. A message needing more than 255 segments is refused before anything is sent, since -the concatenation header numbers segments in a single octet. +the concatenation header numbers segments in a single octet. `maxSegments` lowers that ceiling: +most handsets and SMSCs stop well short of 255, and refusing beats a message only half delivered. ### Receiving @@ -143,6 +152,7 @@ if (err) throw err; smpp.on('session', session => { session.on('sms', async sms => { // sms.from, sms.to, sms.message, sms.dlr + await sms.sendResp(); }); }); ``` @@ -200,6 +210,20 @@ A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.f | `reassemblyTimeout` | `300000` | How long a late segment can still join an incomplete message. | | `responseTimeout`, `maxOutstanding`, `log`, `signal` | as for the client | | +### Bind direction + +The three bind types are honoured in both directions, not just accepted. A receiver-bound ESME +carries no `submit_sm` and a transmitter-bound one is sent no `deliver_sm`, whichever end of the +link the session is: + +- `session.sendSms()` on a receiver-bound session, and `sms.sendDlr()` to a transmitter-bound peer, + fail with an `err` before anything reaches the wire. +- A `submit_sm` arriving on a receiver-bound session, or a `deliver_sm` on a transmitter-bound one, + is answered `ESME_RINVBNDSTS`. + +A `transceiver` bind, the default, carries both. `session.send()` stays a low-level passthrough and +is not checked, so the raw surface can still put whatever a test or a proxy needs on the wire. + ## Errors Nothing in this library throws. Every fallible call returns a result carrying an optional `err`, so @@ -278,6 +302,9 @@ at and above which the spec allows optional parameters to be sent to it; `peerIn the version it declared, `0x00` if it declared none. The library's own senders consult the first before attaching a TLV — a `send()` you build yourself is passed through as written, so consult it too when you attach TLVs. +`bindAllows(cmdName)` answers the same question for the bind direction, and `boundAs` is the role +the ESME bound with — see [Bind direction](#bind-direction). + ## Working with PDUs directly The codec is exported, synchronous, and never throws — handy for inspecting captured traffic: diff --git a/eslint.config.js b/eslint.config.js index 755c9c0..307e09d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -48,6 +48,11 @@ export default tseslint.config( files: ['src/defs/encodings.ts'], rules: { 'no-control-regex': 'off' }, }, + { + // Mirrors the README's examples as written; an async event listener is part of what they show. + files: ['test/readme.test.ts'], + rules: { '@typescript-eslint/no-misused-promises': 'off' }, + }, { files: ['eslint.config.js'], extends: [tseslint.configs.disableTypeChecked], diff --git a/src/client.ts b/src/client.ts index 15bd359..6c5afb0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,10 @@ import type { ConnectionOptions } from 'node:tls'; import type { Result, VoidResult } from './result.ts'; +import type { BindType } from './session-options.ts'; import type { SmppLog } from './log.ts'; import type { Socket } from 'node:net'; +export type { BindType }; + import { Session } from './session.ts'; import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; import { connect as netConnect } from 'node:net'; @@ -9,8 +12,6 @@ import { connect as tlsConnect } from 'node:tls'; import { defaultInterfaceVersion } from './defs/constants.ts'; import { silentLog } from './log.ts'; -export type BindType = 'receiver' | 'transceiver' | 'transmitter'; - export type ClientOptions = { addressRange?: string; addrNpi?: number; @@ -125,6 +126,7 @@ async function bind(session: Session, options: ClientOptions): Promise { if (this.onRequest && await this.onRequest(this.session, pduObj)) return; + if (!this.session.bindAllows(pduObj.cmdName)) { + this.log.info('session - command the peer\'s bind direction does not carry', { + bindType: this.session.boundAs ?? '', + cmdName: pduObj.cmdName, + }); + await this.session.sendReturn(pduObj, 'ESME_RINVBNDSTS'); + + return; + } + switch (pduObj.cmdName) { case 'deliver_sm': await this.onDeliverSm(pduObj); diff --git a/src/send-sms.ts b/src/send-sms.ts index 8e3a04d..b424f20 100644 --- a/src/send-sms.ts +++ b/src/send-sms.ts @@ -15,6 +15,8 @@ export type SendSmsOptions = { encoding?: EncodingName; flash?: boolean; from: string; + /** Refuse before sending anything if the message needs more than this many segments. */ + maxSegments?: number; message: string; scheduleDeliveryTime?: Date | number | string; sourceAddrNpi?: number; @@ -79,31 +81,26 @@ export function submitSmParams( } /** Puts a message on the wire as one submit_sm per segment. */ -export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise { - const encoding = sms.encoding ?? detect(sms.message); - const segments = splitMessage(sms.message, { encoding, reference: deps.reference }); - - if (segments.length === 0) { - return { - err: new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`), - pduObjs: [], - smsIds: [], - }; +/** Nothing goes on the wire until the whole message fits: a half-sent message bills twice. */ +function checkSegments(allowed: number, segments: number): Error | undefined { + if (!Number.isInteger(allowed) || allowed < 1 || allowed > maxSegments) { + return new Error(`maxSegments must be between 1 and ${String(maxSegments)}, got ${String(allowed)}`); } - const multipart = segments.length > 1; + if (segments === 0) { + return new Error(`Message needs more than ${String(maxSegments)} segments, the concatenation limit`); + } + + if (segments > allowed) { + return new Error(`Message needs ${String(segments)} segments, more than the ${String(allowed)} allowed`); + } + + return undefined; +} + +function collectSent(sent: Result<{ pduObj: PduObject }>[]): SendSmsResult { const pduObjs: PduObject[] = []; const smsIds: string[] = []; - - deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to }); - - // Segments go out together rather than one-after-a-response: a receiver that waits for every - // segment before answering — this library's own server does — would otherwise deadlock. - const sent = await Promise.all(segments.map(segment => deps.send({ - cmdName: 'submit_sm', - params: submitSmParams(sms, segment, { encoding, multipart }), - }))); - let failure: Error | undefined; for (const one of sent) { @@ -121,3 +118,25 @@ export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise return failure ? { err: failure, pduObjs, smsIds } : { pduObjs, smsIds }; } + +export async function submitSms(deps: SendSmsDeps, sms: SendSmsOptions): Promise { + const allowed = sms.maxSegments ?? maxSegments; + const encoding = sms.encoding ?? detect(sms.message); + const segments = splitMessage(sms.message, { encoding, reference: deps.reference }); + const refused = checkSegments(allowed, segments.length); + + if (refused) return { err: refused, pduObjs: [], smsIds: [] }; + + const multipart = segments.length > 1; + + deps.log.debug('sendSms() - sending', { encoding, segments: segments.length, to: sms.to }); + + // Segments go out together rather than one-after-a-response: a receiver that waits for every + // segment before answering — this library's own server does — would otherwise deadlock. + const sent = await Promise.all(segments.map(segment => deps.send({ + cmdName: 'submit_sm', + params: submitSmParams(sms, segment, { encoding, multipart }), + }))); + + return collectSent(sent); +} diff --git a/src/server.ts b/src/server.ts index 50f739b..8352442 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,7 +5,7 @@ import type { Server as TlsServer, TlsOptions } from 'node:tls'; import type { SmppLog } from './log.ts'; import { EventEmitter } from 'node:events'; import { Session, bindCommands, defaultSystemId } from './session.ts'; -import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; +import { bindTypeFromCommand, checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; import { createServer as createNetServer } from 'node:net'; import { createServer as createTlsServer } from 'node:tls'; import { defaultInterfaceVersion } from './defs/constants.ts'; @@ -147,6 +147,7 @@ async function acceptBind( ): Promise { const declared = pduObj.params.interface_version; + session.boundAs = bindTypeFromCommand(pduObj.cmdName); session.loggedIn = true; session.peerInterfaceVersion = typeof declared === 'number' ? declared diff --git a/src/session-options.ts b/src/session-options.ts index 5ab18a2..4da295c 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -25,6 +25,28 @@ export const bindCommands: readonly string[] = [ 'bind_transmitter', ]; +export type BindType = 'receiver' | 'transceiver' | 'transmitter'; + +export function bindTypeFromCommand(cmdName: string): BindType | undefined { + if (cmdName === 'bind_receiver') return 'receiver'; + if (cmdName === 'bind_transceiver') return 'transceiver'; + if (cmdName === 'bind_transmitter') return 'transmitter'; + + return undefined; +} + +/** + * Whether a bind direction carries a command at all. A receiver-bound ESME submits nothing and a + * transmitter-bound one is delivered nothing, whichever end of the link is looking. A session that + * has not bound carries everything, since nothing has declared a direction yet. + */ +export function bindCarries(bindType: BindType | undefined, cmdName: string): boolean { + if (bindType === 'receiver') return cmdName !== 'submit_sm'; + if (bindType === 'transmitter') return cmdName !== 'deliver_sm'; + + return true; +} + export type SendOptions = { signal?: AbortSignal | undefined }; /** diff --git a/src/session.ts b/src/session.ts index bf9457e..84ab472 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2,7 +2,7 @@ import type { ErrorName } from './defs/errors.ts'; import type { MessageDlr } from './dlr-merger.ts'; import type { ParamValue } from './defs/types.ts'; import type { PduObject, PduObjectInput, TlvInput } from './pdu.ts'; -import type { ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; +import type { BindType, ReconnectOptions, SendOptions, SessionEvents, SessionOptions } from './session-options.ts'; import type { Result, VoidResult } from './result.ts'; import type { SendSmsOptions, SendSmsResult } from './send-sms.ts'; import type { SmppLog } from './log.ts'; @@ -16,7 +16,7 @@ import { PendingRequests } from './pending-requests.ts'; import { ReconnectLoop } from './reconnect-loop.ts'; import { SendWindow } from './send-window.ts'; import { optionalParamsMinVersion } from './defs/constants.ts'; -import { bindCommands, defaultSystemId, defaults } from './session-options.ts'; +import { bindCarries, bindCommands, defaultSystemId, defaults } from './session-options.ts'; import { isResp, objToPdu, pduReturn, pduToObj } from './pdu.ts'; import { silentLog } from './log.ts'; import { submitSms } from './send-sms.ts'; @@ -30,6 +30,7 @@ export type { SessionEvents, SessionOptions, }; +export type { BindType }; export { bindCommands, defaultSystemId }; export class Session extends EventEmitter { @@ -37,6 +38,8 @@ export class Session extends EventEmitter { sock: Socket; readonly log: SmppLog; + /** The role the ESME bound with, whichever end of the link this is. Undefined before any bind. */ + boundAs: BindType | undefined = undefined; loggedIn = false; /** What the peer declared when binding: 0x00 if it declared none, undefined before any bind. */ peerInterfaceVersion: number | undefined = undefined; @@ -110,6 +113,11 @@ export class Session extends EventEmitter { this.resetTimers(); } + /** Whether this session's bind direction carries a command. Consulted by the library's senders. */ + bindAllows(cmdName: string): boolean { + return bindCarries(this.boundAs, cmdName); + } + /** SMPP 3.4 forbids sending optional parameters to a peer that declared an older version. */ acceptsOptionalParams(): boolean { return this.peerInterfaceVersion === undefined @@ -165,6 +173,10 @@ export class Session extends EventEmitter { } async sendSms(sms: SendSmsOptions, options: SendOptions = {}): Promise { + if (!this.bindAllows('submit_sm')) { + return { err: new Error('A receiver-bound session does not carry submit_sm'), pduObjs: [], smsIds: [] }; + } + const sent = await submitSms({ log: this.log, reference: this.nextConcatReference(), diff --git a/src/sms.ts b/src/sms.ts index ee4c09e..cefdcf9 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -126,6 +126,10 @@ async function sendDlr( sms: Sms, status: MessageState = 'DELIVERED', ): Promise> { + if (!sms.session.bindAllows('deliver_sm')) { + return { err: new Error('A transmitter-bound session does not carry deliver_sm') }; + } + const total = sms.pduObjs.length; const pduObjs: PduObject[] = []; diff --git a/test/readme.test.ts b/test/readme.test.ts new file mode 100644 index 0000000..8c8c0f9 --- /dev/null +++ b/test/readme.test.ts @@ -0,0 +1,235 @@ +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 type { SmppLog } from '../src/log.ts'; +import type { SmppServer } from '../src/server.ts'; +import type { TestContext } from 'node:test'; +import { client } from '../src/client.ts'; +import { server } from '../src/server.ts'; + +function once(register: (resolve: (value: T) => void) => void): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('waited 5000 ms for an event that never fired')); + }, 5000); + + register(value => { + clearTimeout(timer); + resolve(value); + }); + }); +} + +/** The README's examples listen on the documented default port, so one server runs at a time. */ +async function answeringServer(t: TestContext): Promise { + const { err, server: smpp } = await server(); + + assert.equal(err, undefined); + assert.ok(smpp); + t.after(() => smpp.close()); + + smpp.on('session', session => { + session.on('sms', async sms => { + await sms.sendResp(); + + if (sms.dlr) await sms.sendDlr(); + }); + }); + + return smpp; +} + +describe('README: Client', () => { + test('the simplest possible client', async t => { + await answeringServer(t); + + const { err, session } = await client(); + if (err) throw err; + + await session.sendSms({ + from: '46701113311', + message: 'Hello world', + to: '46709771337', + }); + + await session.unbind(); + }); + + test('with connection parameters, a delivery report and logging', async t => { + await answeringServer(t); + + const log: SmppLog = { + debug: () => undefined, + error: () => undefined, + info: () => undefined, + verbose: () => undefined, + warn: () => undefined, + }; + + const { err, session } = await client({ + host: 'localhost', + log, + password: 'bar', + port: 2775, + username: 'foo', + }); + if (err) throw err; + + t.after(() => { session.close(); }); + + const reported = once(resolve => { session.on('dlr', resolve); }); + const { err: sendErr, smsIds } = await session.sendSms({ + dlr: true, + from: '46701113311', + message: '«baff»', + to: '46709771337', + }); + + assert.equal(sendErr, undefined); + assert.equal(smsIds.length, 1); + assert.equal((await reported).smsId, smsIds[0]); + }); + + test('the documented sending options', async t => { + const smpp = await answeringServer(t); + const incoming = once(resolve => { + smpp.on('session', session => session.on('sms', resolve)); + }); + const { err, session } = await client(); + if (err) throw err; + + t.after(() => { session.close(); }); + + const { signal } = new AbortController(); + const [sms, sent] = await Promise.all([ + incoming, + session.sendSms({ + dlr: true, + encoding: 'UCS2', + flash: false, + from: 'MyBrand', + message: 'Hello world', + scheduleDeliveryTime: new Date(Date.now() + 3600_000), + to: '46709771337', + validityPeriod: 3600, + }, { signal }), + ]); + + assert.equal(sent.err, undefined); + assert.equal(sms.from, 'MyBrand'); + assert.equal(sms.message, 'Hello world'); + }); + + test('receiving an inbound message on a client session', async t => { + const smpp = await answeringServer(t); + const bound = once(resolve => { smpp.on('session', resolve); }); + const { err, session } = await client(); + if (err) throw err; + + t.after(() => { session.close(); }); + + const incoming = once(resolve => { session.on('sms', resolve); }); + const peer = await bound; + + void peer.send({ + cmdName: 'deliver_sm', + params: { + destination_addr: '46709771337', + short_message: 'inbound hello', + source_addr: '46701113311', + }, + }); + + const sms = await incoming; + + await sms.sendResp(); + + assert.equal(sms.message, 'inbound hello'); + }); +}); + +describe('README: Server', () => { + test('the simplest possible server', async t => { + const { err, server: smpp } = await server(); + if (err) throw err; + + t.after(() => smpp.close()); + + const received: string[] = []; + + smpp.on('session', session => { + session.on('sms', async sms => { + received.push(sms.message); + await sms.sendResp(); + }); + }); + + const { err: clientErr, session } = await client(); + + assert.equal(clientErr, undefined); + assert.ok(session); + + await session.sendSms({ from: '46701113311', message: 'Hello world', to: '46709771337' }); + await session.unbind(); + + assert.deepEqual(received, ['Hello world']); + }); + + test('with authentication and delivery reports', async t => { + const { err, server: smpp } = await server({ + authenticate: ({ password, systemId }) => { + if (systemId !== 'foo' || password !== 'bar') return false; + + return { userData: { userId: 123 } }; + }, + }); + if (err) throw err; + + t.after(() => smpp.close()); + + smpp.on('session', session => { + session.on('sms', async sms => { + await sms.sendResp(); + + if (sms.dlr) { + await sms.sendDlr(); + } + }); + }); + + assert.equal(smpp.port, 2775); + + const refused = await client({ password: 'wrong', username: 'foo' }); + + assert.ok(refused.err instanceof Error); + + const { err: clientErr, session } = await client({ password: 'bar', username: 'foo' }); + + assert.equal(clientErr, undefined); + assert.ok(session); + + const reported = once(resolve => { session.on('dlr', resolve); }); + const sent = await session.sendSms({ + dlr: true, + from: '46701113311', + message: 'with a receipt', + to: '46709771337', + }); + + assert.equal(sent.err, undefined); + assert.equal((await reported).statusMsg, 'DELIVERED'); + + await session.unbind(); + }); +}); + +describe('README: Errors', () => { + test('a refused connection reports err instead of throwing', async () => { + const { err, session } = await client({ port: 1 }); + + assert.ok(err instanceof Error); + assert.equal(session, undefined); + }); +}); diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index d80af1c..c0d1e78 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -243,6 +243,31 @@ describe('sendSms()', () => { assert.ok(sent.err instanceof Error); assert.equal(attempts.length, 0); }); + + // Most handsets and SMSCs stop well short of the 255 a UDH can number. + test('refuses a message needing more segments than the caller allows', async () => { + const attempts: PduObjectInput[] = []; + const deps = { + log: silentLog, + reference: 3, + send: (input: PduObjectInput) => { + attempts.push(input); + + return Promise.resolve({ pduObj: submitResp(attempts.length, `landed-${String(attempts.length)}`) }); + }, + }; + const message = 'a'.repeat(153 * 4); + + const refused = await submitSms(deps, { from: '46701113311', maxSegments: 3, message, to: '46709771337' }); + + assert.ok(refused.err instanceof Error); + assert.equal(attempts.length, 0); + + const sent = await submitSms(deps, { from: '46701113311', maxSegments: 4, message, to: '46709771337' }); + + assert.equal(sent.err, undefined); + assert.equal(attempts.length, 4); + }); }); describe('reconnect', () => { diff --git a/test/session.test.ts b/test/session.test.ts index c5b68a7..f8607c8 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -424,6 +424,93 @@ describe('bind', () => { }); }); +describe('bind direction', () => { + // A receiver-bound ESME sends no submit_sm and a transmitter-bound one is sent no deliver_sm. + test('refuses a submit_sm from a peer that bound as a receiver', async () => { + const smpp = await startServer(); + const { session } = await connect(smpp, { bindType: 'receiver' }); + + assert.ok(session); + + const sent = await session.send({ + cmdName: 'submit_sm', + params: { destination_addr: '46709771337', short_message: 'nope', source_addr: '46701113311' }, + }); + + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS'); + + session.close(); + await smpp.close(); + }); + + test('refuses sendSms() on a receiver-bound session before it reaches the wire', async () => { + const smpp = await startServer(); + const arrived: Sms[] = []; + + smpp.on('session', peer => peer.on('sms', sms => arrived.push(sms))); + + const { session } = await connect(smpp, { bindType: 'receiver' }); + + assert.ok(session); + + const sent = await session.sendSms({ from: '46701113311', message: 'nope', to: '46709771337' }); + + assert.ok(sent.err instanceof Error); + assert.match(sent.err.message, /receiver-bound/); + assert.deepEqual(sent.smsIds, []); + assert.equal(arrived.length, 0); + + session.close(); + await smpp.close(); + }); + + test('refuses a deliver_sm sent to a peer that bound as a transmitter', async () => { + const smpp = await startServer(); + const bound = once(resolve => { smpp.on('session', resolve); }); + const { session } = await connect(smpp, { bindType: 'transmitter' }); + + assert.ok(session); + + const peer = await bound; + const sent = await peer.send({ + cmdName: 'deliver_sm', + params: { destination_addr: '46709771337', short_message: 'nope', source_addr: '46701113311' }, + }); + + assert.ok(sent.pduObj); + assert.equal(sent.pduObj.cmdStatus, 'ESME_RINVBNDSTS'); + + session.close(); + await smpp.close(); + }); + + test('refuses sendDlr() to a transmitter-bound peer before it reaches the wire', async () => { + const smpp = await startServer(); + const incoming = once(resolve => { + smpp.on('session', peer => peer.on('sms', resolve)); + }); + const { session } = await connect(smpp, { bindType: 'transmitter' }); + + assert.ok(session); + + const [sms] = await Promise.all([ + incoming, + session.sendSms({ dlr: true, from: '46701113311', message: 'one way', to: '46709771337' }), + ]); + + await sms.sendResp(); + + const report = await sms.sendDlr(); + + assert.ok(report.err instanceof Error); + assert.match(report.err.message, /transmitter-bound/); + + session.close(); + await smpp.close(); + }); +}); + describe('sending', () => { test('delivers a simple SMS with the sender TON derived from the address', async () => { const smpp = await startServer(); @@ -546,6 +633,40 @@ describe('sending', () => { session.close(); await smpp.close(); }); + + test('puts the address TON and NPI the caller chose on the wire', async () => { + const smpp = await startServer(); + const incoming = once(resolve => { + smpp.on('session', peer => peer.on('sms', resolve)); + }); + const { session } = await connect(smpp); + + assert.ok(session); + + const [sms] = await Promise.all([ + incoming, + session.sendSms({ + destinationAddrNpi: consts.NPI.ISDN, + destinationAddrTon: consts.TON.NATIONAL, + from: '46701113311', + message: 'addressed by hand', + sourceAddrNpi: consts.NPI.PRIVATE, + sourceAddrTon: consts.TON.ABBREVIATED, + to: '46709771337', + }), + ]); + + const params = sms.pduObjs[0]?.params; + + assert.ok(params); + assert.equal(params.dest_addr_npi, consts.NPI.ISDN); + assert.equal(params.dest_addr_ton, consts.TON.NATIONAL); + assert.equal(params.source_addr_npi, consts.NPI.PRIVATE); + assert.equal(params.source_addr_ton, consts.TON.ABBREVIATED); + + session.close(); + await smpp.close(); + }); }); describe('receiving', () => { diff --git a/todo.md b/todo.md index 6a30e66..c869133 100644 --- a/todo.md +++ b/todo.md @@ -5,7 +5,7 @@ rules there constrain every item below. ## Status -The rewrite is **feature complete and green**: 210 tests, lint and typecheck clean, verified on Node +The rewrite is **feature complete and green**: 223 tests, lint and typecheck clean, verified on Node 18, 20, 22 and 24. What is left is release work and a few things worth adding before or after 1.0.0. ```bash @@ -54,12 +54,50 @@ Rules the API follows: | Stream framing | `test/pdu-framer.test.ts` | | Delivery receipt parsing, TLV and text | `test/dlr.test.ts` | | Session, client, server: bind, auth, send, reassembly, DLRs, timeouts, abort, send window | `test/session.test.ts` | -| Merged multipart DLRs, reconnect, reassembly bounds, per-send abort | `test/session-extras.test.ts` | +| Merged multipart DLRs, reconnect, reassembly bounds, per-send abort, the segment cap | `test/session-extras.test.ts` | +| Every runnable README example | `test/readme.test.ts` | | Cross-checked against node-smpp both ways and over a live session | `test/interop.test.ts` | | CI on Node 18/20/22/24, Renovate, tag-triggered publish | `.github/workflows/` | Every defect listed in the AGENTS.md table has a regression test naming the behaviour. +## The GitHub backlog, once this branch is `master` + +Nothing below is closed while `master` is still 0.4.0 — declining a security bump on a live default +branch is worse than leaving it open. Work through this immediately after the merge. + +**Close as fixed by 1.0.0**, naming the replacement in the comment: + +| | Fixed by | +| --- | --- | +| [#4](https://github.com/larvit/larvitsmpp/issues/4) DLR errors with `message_state` missing | `dlrFromPdu()` parses the `stat:` receipt text when the TLVs are absent | +| [#33](https://github.com/larvit/larvitsmpp/issues/33) Large inbound text arrives as raw `Buffer` segments | `IncomingRequests` reassembles a UDH-carrying `deliver_sm` into one `sms` event | +| [#3](https://github.com/larvit/larvitsmpp/issues/3) Tests for flash messages | `test/session.test.ts` | +| [#20](https://github.com/larvit/larvitsmpp/issues/20) Tests fail on current dependency versions | The mocha suite is gone; `node:test` on Node 18/20/22/24 | +| [#2](https://github.com/larvit/larvitsmpp/issues/2) Tests for the README examples | `test/readme.test.ts` | +| [#17](https://github.com/larvit/larvitsmpp/issues/17) `addr_ton`/`addr_npi` should be settable | `sendSms()` takes all four, documented and tested | +| [#16](https://github.com/larvit/larvitsmpp/issues/16) Support all three bind types | Bound and enforced in both directions | +| [#13](https://github.com/larvit/larvitsmpp/issues/13) Limit a long SMS to fewer segments | The `maxSegments` send option | +| [#68](https://github.com/larvit/larvitsmpp/pull/68) `message_id` in `submit_sm_resp`, spec DLR codes | All four hold: `sendResp()` always answers a `message_id`, per segment; `stat:UNDELIV` is the 7-character code. Credit the reporter — the fork found real defects. | + +**Close as superseded**, all against 0.4.0 dependencies the rewrite does not have — `async`, +`coveralls`, `eslint`, `iconv-lite`, `larvitutils`, `mocha`, `mocha-eslint`, `portfinder`, `uuid`: +[#40](https://github.com/larvit/larvitsmpp/pull/40), [#41](https://github.com/larvit/larvitsmpp/pull/41), +[#42](https://github.com/larvit/larvitsmpp/pull/42), [#45](https://github.com/larvit/larvitsmpp/pull/45), +[#46](https://github.com/larvit/larvitsmpp/pull/46), [#47](https://github.com/larvit/larvitsmpp/pull/47), +[#59](https://github.com/larvit/larvitsmpp/pull/59), [#63](https://github.com/larvit/larvitsmpp/pull/63), +[#64](https://github.com/larvit/larvitsmpp/pull/64), [#65](https://github.com/larvit/larvitsmpp/pull/65), +[#67](https://github.com/larvit/larvitsmpp/pull/67), [#70](https://github.com/larvit/larvitsmpp/pull/70). +[#70](https://github.com/larvit/larvitsmpp/pull/70) is the open `uuid` advisory GitHub reports on the +default branch; it disappears with the runtime dependencies rather than being fixed. + +[#60](https://github.com/larvit/larvitsmpp/issues/60) is Renovate's dashboard — leave it, it +re-baselines itself against the new `package.json`. + +**Leave open:** [#8](https://github.com/larvit/larvitsmpp/issues/8), the socket's remote host and +port on log messages. Only `server - incoming connection` carries them today; putting them on every +session message is a change to every call site. + ## Before publishing 1.0.0 - [ ] Create the `@larvit/smpp` package on npm and add `NPM_TOKEN` to the repository secrets, which @@ -71,10 +109,19 @@ Every defect listed in the AGENTS.md table has a regression test naming the beha ## Worth doing, not blocking +- [ ] **An `async` event listener that rejects escapes the guard.** `Session.emit()` wraps + `super.emit()` in try/catch, which catches a listener that throws synchronously but not one + that returns a rejected promise — that surfaces as an unhandled rejection and takes the + process down, which hard rule 1 says must not happen. Every README example uses + `session.on('sms', async sms => …)`, so the shape is the one applications will write. The + library's own calls inside such a listener never reject, so the examples themselves are safe. + Fixing it means dispatching `rawListeners()` by hand in `emit()` and routing a rejection to + `sessionError` — a change to the hottest path, so it needs a decision before 1.0.0. + - [ ] **In-flight sends across a reconnect.** They currently fail with "Session closed before a response arrived" and the caller retries. Re-queueing them automatically would be friendlier but risks duplicate delivery, so it needs a decision before it is built. -- [ ] **`session.ts` is 459 lines.** The one seam left in it is a socket-to-PDU transport, which +- [ ] **`session.ts` is 386 lines.** The one seam left in it is a socket-to-PDU transport, which would move the deliberately public `sock` field out of `Session` or turn it into a getter — a public-surface change, so it waits for a decision. - [ ] **Group the session's collaborators under `src/session/`.** Only `session.ts` imports