Give server() consumers the onRequest hook the docs promise (#86)
* Regression tests for an onRequest hook on server() * Compose the application's onRequest hook into server() past bind * Document the server's onRequest hook and record the composition decision * Pin that a re-bind and a pre-bind unbind never reach the hook, and that a non-function hook is refused at startup * Keep every bind and all pre-bind traffic out of the application's hook * Make the hook's published promises exact, and record the src layout deferral * Trim the record to what the tests do not already forbid * Pin that nothing answers a request whose hook failed * Answer nothing for a request whose hook failed, on both surfaces alike * State the hook's failure policy as it now behaves * Name the request a failed handler left unanswered, and say what a wedged hook reports
This commit is contained in:
@@ -517,6 +517,44 @@ Grouped by what each one constrains.
|
||||
in hand. The answer goes out before the `sms` event either way, so a listener's own receipt can
|
||||
never precede the acceptance of the message it reports on.
|
||||
|
||||
- **`server()` composes the application's `onRequest` after its own bind handling, and offers it
|
||||
every request that handling did not answer.** Maintainer's call, 2026-09-06, from a product review
|
||||
of the multipart change: `server()` filled the session's only `onRequest` slot, so the escape hatch
|
||||
the error above names was reachable only by hand-wiring a `Session` over a raw socket, giving up
|
||||
bind acceptance, `authenticate`, the session set and the drain `close()` runs over it — which is
|
||||
what goal 6 means by beating "the application can do this itself". What the library verifies is the
|
||||
ordering rather than the hook's honesty about answering: the hook is consulted only for a non-bind
|
||||
request on a session already bound, so no bind — a second one on a live session included — and
|
||||
nothing a peer sends before one can be intercepted however the hook is written. One
|
||||
`OnRequest` type on both option bags, because a second contract under one name is two spellings of
|
||||
one goal; widened to accept a plain boolean, as `authenticate` already is, so an observing hook need
|
||||
not be `async`. Nothing of ours is written for a request whose hook failed, the same on both
|
||||
surfaces: the library cannot tell one that failed before answering from one that failed after, so
|
||||
goal 2 reports the outcome as undetermined rather than guessing, and the peer's own
|
||||
`responseTimeout` is what settles it — the answer `authenticate` failing already takes. A hook that
|
||||
throws or rejects reaches `sessionError` on the way; one that never settles reaches nothing at all,
|
||||
and is visible only as the request that was never answered. That takes the keepalive with it, since
|
||||
a hook broken across the board leaves `enquire_link` unanswered and the peer drops the link — the
|
||||
back-pressure wanted, because an application that cannot serve a link should not hold one.
|
||||
`sessionError` rather than `serverError` because the
|
||||
failure belongs to one session's request, and that channel already carries every failure of one.
|
||||
The hook is consulted before the bind-direction gate, so it sees a `submit_sm` a receiver-bound
|
||||
peer may not send; first refusal means first, and one it declines still gets `ESME_RINVBNDSTS`.
|
||||
Nothing is held for a request the hook answered: `HeldMessages` is opened by the `sms` event the
|
||||
hook skipped, so the drain waits on none of it. `OnRequest` stays unexported where
|
||||
`AuthenticateInput` is exported, because that hook's argument is a shape this library invents and
|
||||
this one's are two types already published. Rejected: consulting the hook first, which puts
|
||||
bind and authentication inside the application's reach for nothing. Rejected: a narrower hook
|
||||
returning a status for the library to write, which makes the answer verifiable but pays a second
|
||||
contract under a second name for it, and could not express what the session-level hook already
|
||||
does — answer a bind, a vendor command, a `data_sm` — leaving that error naming something only
|
||||
half the surface can do. Rejected: falling the request through to the built-in handling on a
|
||||
failure, which reads as the answer the peer would have had with no hook — true only of a hook
|
||||
that failed before answering, where one that failed after put a second response on the peer's own
|
||||
sequence number, goal 1's wire violation. Rejected with it: recording what the hook wrote so the
|
||||
fall-through could be gated on it, which buys a fail-open path with state and an internal contract
|
||||
no other collaborator needs.
|
||||
|
||||
- **The drain waits on the messages the application holds, and `sendResp()` is what says it is done
|
||||
with one.** Maintainer's call, 2026-09-01: waiting on the send window alone tore a server session
|
||||
down while the application was still answering a `submit_sm`, so the peer timed out and re-sent —
|
||||
@@ -620,3 +658,9 @@ Grouped by what each one constrains.
|
||||
`node:24.18.0-bookworm-slim` ships no openssl binary, so a shelled-out fixture would pass in CI and
|
||||
fail on every developer machine, and a committed key leaks in a public repository. Valid while the
|
||||
dev image has no openssl.
|
||||
|
||||
- **`src/` stays flat until a module has to move for another reason.** Architecture review,
|
||||
2026-09-06: the grouping the file map above already implies — `wire/` for `pdu*` and `defs`,
|
||||
`link/` for `link-*`, `reconnect-*`, `pdu-transport` and `send-window`, `messages/` for `sms*`,
|
||||
`dlr*`, `message*`, `reassembly` and `udh` — rewrites every import for no change to
|
||||
`dist/index.js`, the one published entry. Valid while that map is what a reader navigates by.
|
||||
|
||||
@@ -240,37 +240,42 @@ since SMPP marks that field unused, so an inbound message's base is a handle of
|
||||
|
||||
A refusal that depends on the request rather than the reassembled message — a full queue, an unknown
|
||||
recipient, an unauthorised sender — needs to land before a segment is answered, which `sendResp()`
|
||||
can no longer do once it has. A hand-wired `Session` (see [Bind direction](#bind-direction)) decides
|
||||
there instead, at `onRequest`, which runs before any of the library's own handling:
|
||||
can no longer do once it has. `onRequest` decides there instead, on every request a bound peer
|
||||
sends, before reassembly and before the `sms` event:
|
||||
|
||||
```javascript
|
||||
import net from 'node:net';
|
||||
import { isCommand, Session } from '@larvit/smpp';
|
||||
import { isCommand, server } from '@larvit/smpp';
|
||||
|
||||
const knownRecipients = new Set(['46709771337']);
|
||||
|
||||
net.createServer(sock => {
|
||||
const session = new Session({
|
||||
onRequest: async (bound, pduObj) => {
|
||||
const { err } = await server({
|
||||
onRequest: async (session, pduObj) => {
|
||||
if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await bound.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||||
await session.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||||
|
||||
return true;
|
||||
},
|
||||
sock,
|
||||
});
|
||||
|
||||
session.linkEnd = 'smsc';
|
||||
}).listen(2775);
|
||||
});
|
||||
if (err) throw err;
|
||||
```
|
||||
|
||||
Returning `true` means the hook has answered the PDU and the library leaves it alone; `false` lets
|
||||
the built-in handling — reassembly, the `sms` event — run as usual. A hand-wired `Session` has no
|
||||
bind handling of its own, though: `onRequest` is where a peer's bind gets accepted too, the way
|
||||
`server()` does it.
|
||||
the built-in handling — reassembly, the `sms` event — run as usual. Every segment of a concatenated
|
||||
message is a request of its own, so the hook sees each one while refusing it still means something.
|
||||
No bind reaches it, and neither does anything a peer sends before one: `server()` answers those and
|
||||
runs `authenticate` itself, so a hook cannot intercept a bind however it is written.
|
||||
|
||||
A hook that throws or rejects reaches `sessionError`, and nothing else is written for that request:
|
||||
the library cannot tell a hook that failed before answering from one that failed after, and a second
|
||||
response on the peer's sequence number would be worse than none. The peer's own response timeout
|
||||
settles it, so a hook that must reach a decision either way makes that decision itself.
|
||||
|
||||
A `Session` you construct yourself (see [Bind direction](#bind-direction)) takes the same hook as a
|
||||
session option, and handles a failing one the same way; it is also where a peer's bind gets accepted,
|
||||
since a hand-wired session has no bind handling of its own.
|
||||
|
||||
`sendDlr` accepts `SCHEDULED`, `ENROUTE`, `DELIVERED`, `EXPIRED`, `DELETED`, `UNDELIVERABLE`,
|
||||
`ACCEPTED`, `UNKNOWN`, `REJECTED` and `SKIPPED`. `SCHEDULED` and `ENROUTE` go out as intermediate
|
||||
@@ -285,6 +290,7 @@ A message whose `data_coding` says 8-bit binary arrives as Latin-1, so `Buffer.f
|
||||
| --- | --- | --- |
|
||||
| `host` / `port` | all interfaces / `2775` | Where to listen. Pass `0` for any free port. |
|
||||
| `authenticate` | accept everything | `({ password, session, systemId, systemType }) => false \| { userData }`, sync or async. |
|
||||
| `onRequest` | none | `(session, pduObj) => true \| false`, sync or async. First refusal on every request a bound peer sends; no bind, and nothing before one, reaches it. |
|
||||
| `systemId` | `''` | The SMSC identity returned to the ESME in the bind response. |
|
||||
| `interfaceVersion` | `0x34` | The SMPP version advertised in the bind response. The floor for sending a peer optional parameters stays `0x34`, whatever this is set to. |
|
||||
| `tls` | `false` | A `tls.TlsOptions` object with your certificate and key. |
|
||||
|
||||
+38
-14
@@ -1,4 +1,4 @@
|
||||
import type { CloseOptions } from './session-options.ts';
|
||||
import type { CloseOptions, OnRequest } from './session-options.ts';
|
||||
import type { PduObject, TlvInput } from './pdu.ts';
|
||||
import type { Result, VoidResult } from './result.ts';
|
||||
import type { Server as NetServer, Socket } from 'node:net';
|
||||
@@ -32,6 +32,8 @@ export type ServerOptions = {
|
||||
maxOutstanding?: number;
|
||||
maxOctets?: number;
|
||||
maxReassembly?: number;
|
||||
/** First refusal on every request a bound peer sends. */
|
||||
onRequest?: OnRequest;
|
||||
port?: number;
|
||||
reassemblyTimeout?: number;
|
||||
responseTimeout?: number;
|
||||
@@ -185,42 +187,47 @@ async function acceptBind(
|
||||
}
|
||||
|
||||
async function onBind(session: Session, pduObj: PduObject, options: ServerOptions): Promise<void> {
|
||||
const log = guardedLog(options.log);
|
||||
const identity = { system_id: options.systemId ?? defaults.systemId };
|
||||
const systemId = paramText(pduObj.params.system_id);
|
||||
|
||||
if (!await authenticate(session, pduObj, options)) {
|
||||
log.info('server - bind refused', { systemId });
|
||||
session.log.info('server - bind refused', { systemId });
|
||||
await session.sendReturn(pduObj, 'ESME_RBINDFAIL', identity);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await acceptBind(session, pduObj, options, identity);
|
||||
log.verbose('server - bound', { systemId });
|
||||
session.log.verbose('server - bound', { systemId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles everything a peer may send before it is bound. Returns true when it has answered, so the
|
||||
* session leaves the PDU alone.
|
||||
* Bind is the server's; a bound peer's other requests are offered to the application. Returns true
|
||||
* when it has answered, so the session leaves the PDU alone.
|
||||
*/
|
||||
async function onRequest(
|
||||
async function handleRequest(
|
||||
session: Session,
|
||||
pduObj: PduObject,
|
||||
options: ServerOptions,
|
||||
): Promise<boolean> {
|
||||
if (session.loggedIn || pduObj.cmdName === 'unbind') return false;
|
||||
const isBind = bindCommands.includes(pduObj.cmdName);
|
||||
|
||||
if (!bindCommands.includes(pduObj.cmdName)) {
|
||||
const log = guardedLog(options.log);
|
||||
if (session.loggedIn) {
|
||||
if (isBind || !options.onRequest) return false;
|
||||
|
||||
log.debug('server - command before bind', { cmdName: pduObj.cmdName });
|
||||
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
||||
return options.onRequest(session, pduObj);
|
||||
}
|
||||
|
||||
if (isBind) {
|
||||
await onBind(session, pduObj, options);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
await onBind(session, pduObj, options);
|
||||
if (pduObj.cmdName === 'unbind') return false;
|
||||
|
||||
session.log.debug('server - command before bind', { cmdName: pduObj.cmdName });
|
||||
await session.sendReturn(pduObj, 'ESME_RINVBNDSTS');
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -233,7 +240,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
|
||||
maxOutstanding: options.maxOutstanding,
|
||||
maxOctets: options.maxOctets,
|
||||
maxReassembly: options.maxReassembly,
|
||||
onRequest: (bound, pduObj) => onRequest(bound, pduObj, options),
|
||||
onRequest: (bound, pduObj) => handleRequest(bound, pduObj, options),
|
||||
reassemblyTimeout: options.reassemblyTimeout,
|
||||
responseTimeout: options.responseTimeout,
|
||||
shutdownTimeout: options.shutdownTimeout,
|
||||
@@ -264,11 +271,28 @@ function createSecureListener(tlsOptions: TlsOptions, log: SmppLog): TlsServer {
|
||||
return listener;
|
||||
}
|
||||
|
||||
/** A caller without types would otherwise reach a TypeError once per PDU rather than once here. */
|
||||
function checkHooks(options: ServerOptions): VoidResult {
|
||||
for (const name of ['authenticate', 'onRequest'] as const) {
|
||||
const hook: unknown = options[name];
|
||||
|
||||
if (hook !== undefined && typeof hook !== 'function') {
|
||||
return { err: new Error(`${name} must be a function, got ${typeof hook}`) };
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function checkOptions(options: ServerOptions, log: SmppLog, port: number): VoidResult {
|
||||
const checked = checkSessionOptions(options);
|
||||
|
||||
if (checked.err) return { err: checked.err };
|
||||
|
||||
const hooks = checkHooks(options);
|
||||
|
||||
if (hooks.err) return hooks;
|
||||
|
||||
if (options.tls === true) {
|
||||
log.warn('server - tls without a certificate', { port });
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export type CloseOptions = { signal?: AbortSignal | undefined };
|
||||
* built-in handling is skipped — this is how the server owns bind without the session also
|
||||
* replying "invalid command".
|
||||
*/
|
||||
export type OnRequest = (session: Session, pduObj: PduObject) => Promise<boolean>;
|
||||
export type OnRequest = (session: Session, pduObj: PduObject) => Promise<boolean> | boolean;
|
||||
|
||||
/**
|
||||
* How to come back after an unexpected disconnect. The session owns the retry loop; the caller
|
||||
|
||||
+5
-1
@@ -397,7 +397,11 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
void this.incoming.handle(pduObj).catch((thrown: unknown) => {
|
||||
const err = errorFrom(thrown);
|
||||
|
||||
this.log.error('session - a handler threw', { message: err.message });
|
||||
this.log.error('session - a handler threw', {
|
||||
cmdName: pduObj.cmdName,
|
||||
message: err.message,
|
||||
seqNr: pduObj.seqNr,
|
||||
});
|
||||
this.emit('sessionError', err);
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ function answeredOnArrival(
|
||||
|
||||
if (options.status !== undefined && options.status !== 'ESME_ROK') {
|
||||
return Promise.resolve({
|
||||
err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from onRequest instead'),
|
||||
err: new Error('Its segments were answered as they arrived, so there is nothing left to refuse; refuse a segment from the onRequest option instead'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+28
-58
@@ -1,18 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test, { describe } from 'node:test';
|
||||
import type { Dlr } from '../src/dlr.ts';
|
||||
import type { PduObject } from '../src/pdu.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 { PduFramer } from '../src/pdu-framer.ts';
|
||||
import { PduRefusedError } from '../src/pdu-refusal.ts';
|
||||
import { Session } from '../src/session.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter, closeListenerAfter } from './teardown.ts';
|
||||
import { isCommand, objToPdu, pduToObj } from '../src/pdu.ts';
|
||||
import { closeAfter } from './teardown.ts';
|
||||
import { isCommand, objToPdu } from '../src/pdu.ts';
|
||||
import { server } from '../src/server.ts';
|
||||
|
||||
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
|
||||
@@ -264,68 +261,41 @@ describe('README: Server', () => {
|
||||
assert.equal(answeredOnArrival, false);
|
||||
});
|
||||
|
||||
test('refusing a submission at onRequest, before this library would answer it', async t => {
|
||||
test('refusing a segment at onRequest, before this library would answer it', async t => {
|
||||
const knownRecipients = new Set(['46709771337']);
|
||||
const accepted: net.Socket[] = [];
|
||||
const listener = net.createServer(sock => {
|
||||
accepted.push(sock);
|
||||
|
||||
const session = new Session({
|
||||
onRequest: async (bound, pduObj) => {
|
||||
const { err, server: smpp } = await server({
|
||||
onRequest: async (session, pduObj) => {
|
||||
if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await bound.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||||
await session.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||||
|
||||
return true;
|
||||
},
|
||||
sock,
|
||||
});
|
||||
if (err) throw err;
|
||||
|
||||
closeAfter(t, smpp);
|
||||
|
||||
let messages = 0;
|
||||
|
||||
smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||||
|
||||
const connected = await client();
|
||||
if (connected.err) throw connected.err;
|
||||
|
||||
closeAfter(t, connected.session);
|
||||
|
||||
const sent = await connected.session.sendSms({
|
||||
from: '46701113311',
|
||||
message: 'A submission long enough to need more than one segment. '.repeat(4),
|
||||
to: '46700000000',
|
||||
});
|
||||
|
||||
session.linkEnd = 'smsc';
|
||||
closeAfter(t, session);
|
||||
});
|
||||
|
||||
closeListenerAfter(t, listener, accepted);
|
||||
|
||||
await new Promise<void>(resolve => { listener.listen(0, resolve); });
|
||||
|
||||
const address = listener.address();
|
||||
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
||||
const peer = net.connect({ port });
|
||||
|
||||
t.after(() => { peer.destroy(); });
|
||||
|
||||
const framer = new PduFramer();
|
||||
const refused = once<PduObject>(resolve => {
|
||||
peer.on('data', chunk => {
|
||||
framer.push(chunk);
|
||||
|
||||
for (const pdu of framer.next().pdus ?? []) {
|
||||
const { pduObj } = pduToObj(pdu);
|
||||
|
||||
if (pduObj) resolve(pduObj);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await once<true>(resolve => { peer.once('connect', () => { resolve(true); }); });
|
||||
|
||||
const { buffer } = objToPdu({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
destination_addr: '46700000000',
|
||||
short_message: 'Hello world',
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: 1,
|
||||
});
|
||||
|
||||
assert.ok(buffer);
|
||||
peer.write(buffer);
|
||||
|
||||
assert.equal((await refused).cmdStatus, 'ESME_RINVDSTADR');
|
||||
assert.equal(sent.err?.message, 'submit_sm refused by the peer: ESME_RINVDSTADR');
|
||||
assert.deepEqual(sent.smsIds, []);
|
||||
assert.equal(messages, 0, 'a segment the hook refused never becomes a message');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+228
-1
@@ -4,8 +4,8 @@ import test, { describe } from 'node:test';
|
||||
import type { Dlr } from '../src/dlr.ts';
|
||||
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import type { ServerOptions, SmppServer } from '../src/server.ts';
|
||||
import type { SmppLog } from '../src/log.ts';
|
||||
import type { SmppServer } from '../src/server.ts';
|
||||
import type { TestContext } from 'node:test';
|
||||
import type { VoidResult } from '../src/result.ts';
|
||||
import { DlrMerger } from '../src/dlr-merger.ts';
|
||||
@@ -1924,6 +1924,216 @@ describe('application hooks that throw or reject', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('the server\'s onRequest hook', () => {
|
||||
const answeredId = '01a07501-b609-7d27-ab98-d3b29bd78e7e';
|
||||
|
||||
function submitTo(session: Session, to: string, message = 'screened by the hook') {
|
||||
return session.send({
|
||||
cmdName: 'submit_sm',
|
||||
params: { destination_addr: to, short_message: message, source_addr: '46701113311' },
|
||||
});
|
||||
}
|
||||
|
||||
test('refuses an inbound submit_sm with the status the hook chose', async t => {
|
||||
const smpp = await startServer(t, {
|
||||
onRequest: async (bound, pduObj) => {
|
||||
if (!isCommand(pduObj, 'submit_sm')) return false;
|
||||
|
||||
await bound.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
let messages = 0;
|
||||
|
||||
smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||||
|
||||
const { session } = await connect(t, smpp);
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const answered = await submitTo(session, '46700000000');
|
||||
|
||||
assert.equal(answered.err, undefined);
|
||||
assert.ok(answered.pduObj);
|
||||
assert.equal(answered.pduObj.cmdStatus, 'ESME_RINVDSTADR');
|
||||
assert.equal(messages, 0, 'a request the hook answered never reaches the sms event');
|
||||
});
|
||||
|
||||
test('answers a bind itself, so a hook that claims every request cannot intercept one', async t => {
|
||||
const seen: string[] = [];
|
||||
const smpp = await startServer(t, {
|
||||
authenticate: ({ password, systemId }) => systemId === 'user' && password === 'pass',
|
||||
onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return true; },
|
||||
});
|
||||
const peer = rawPeer(t, smpp.port);
|
||||
|
||||
peer.write(bindOf(0x34));
|
||||
|
||||
const accepted = await peer.next();
|
||||
|
||||
peer.write(bindOf(0x34, 2));
|
||||
|
||||
const rebound = await peer.next();
|
||||
const wrong = rawPeer(t, smpp.port);
|
||||
|
||||
wrong.write({
|
||||
cmdName: 'bind_transceiver',
|
||||
params: { interface_version: 0x34, password: 'wrong', system_id: 'user' },
|
||||
seqNr: 1,
|
||||
});
|
||||
|
||||
const refused = await wrong.next();
|
||||
const early = rawPeer(t, smpp.port);
|
||||
|
||||
early.write({ cmdName: 'unbind', seqNr: 1 });
|
||||
|
||||
const unbound = await early.next();
|
||||
|
||||
assert.equal(accepted.cmdName, 'bind_transceiver_resp');
|
||||
assert.equal(accepted.cmdStatus, 'ESME_ROK');
|
||||
assert.equal(rebound.cmdStatus, 'ESME_RALYBND');
|
||||
assert.equal(refused.cmdStatus, 'ESME_RBINDFAIL');
|
||||
assert.equal(unbound.cmdName, 'unbind_resp');
|
||||
assert.deepEqual(seen, [], 'a bind, and everything a peer sends before one, is never the hook\'s');
|
||||
});
|
||||
|
||||
test('passes a request the hook declines through to the sms event', async t => {
|
||||
const seen: string[] = [];
|
||||
const smpp = await startServer(t, {
|
||||
onRequest: (_bound, pduObj) => { seen.push(pduObj.cmdName); return false; },
|
||||
});
|
||||
const incoming = once<Sms>(resolve => {
|
||||
smpp.on('session', bound => bound.on('sms', async sms => {
|
||||
resolve(sms);
|
||||
await sms.sendResp({ smsId: answeredId });
|
||||
}));
|
||||
});
|
||||
const { session } = await connect(t, smpp);
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const answered = await submitTo(session, '46709771337', 'declined by the hook');
|
||||
const sms = await incoming;
|
||||
|
||||
assert.ok(answered.pduObj);
|
||||
assert.equal(answered.pduObj.cmdStatus, 'ESME_ROK');
|
||||
assert.equal(paramText(answered.pduObj.params.message_id), answeredId);
|
||||
assert.equal(sms.message, 'declined by the hook');
|
||||
assert.deepEqual(seen, ['submit_sm']);
|
||||
});
|
||||
|
||||
test('reports a hook that throws and answers nothing for it', async t => {
|
||||
const smpp = await startServer(t, {
|
||||
onRequest: () => { throw new Error('the onRequest hook exploded'); },
|
||||
});
|
||||
const failed = once<Error>(resolve => {
|
||||
smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||||
});
|
||||
let messages = 0;
|
||||
|
||||
smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||||
|
||||
const { session } = await connect(t, smpp, { responseTimeout: 300 });
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const answered = await submitTo(session, '46709771337');
|
||||
const reported = await failed;
|
||||
|
||||
assert.ok(answered.err instanceof Error);
|
||||
assert.equal(reported.message, 'the onRequest hook exploded');
|
||||
assert.equal(messages, 0, 'a hook that failed decided nothing, so nothing may decide for it');
|
||||
});
|
||||
|
||||
test('reports a hook that rejects and answers nothing for it', async t => {
|
||||
const smpp = await startServer(t, {
|
||||
onRequest: () => Promise.reject(new Error('the onRequest hook rejected')),
|
||||
});
|
||||
const failed = once<Error>(resolve => {
|
||||
smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||||
});
|
||||
let messages = 0;
|
||||
|
||||
smpp.on('session', bound => bound.on('sms', () => { messages++; }));
|
||||
|
||||
const { session } = await connect(t, smpp, { responseTimeout: 300 });
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const answered = await submitTo(session, '46709771337');
|
||||
const reported = await failed;
|
||||
|
||||
assert.ok(answered.err instanceof Error);
|
||||
assert.equal(reported.message, 'the onRequest hook rejected');
|
||||
assert.equal(messages, 0);
|
||||
});
|
||||
|
||||
test('writes nothing more for a segment the hook answered before it failed', async t => {
|
||||
const smpp = await startServer(t, {
|
||||
onRequest: async (bound, pduObj) => {
|
||||
await bound.sendReturn(pduObj, 'ESME_RINVDSTADR');
|
||||
|
||||
throw new Error('the onRequest hook exploded after answering');
|
||||
},
|
||||
});
|
||||
const failed = once<Error>(resolve => {
|
||||
smpp.on('session', bound => { bound.on('sessionError', resolve); });
|
||||
});
|
||||
const peer = rawPeer(t, smpp.port);
|
||||
const segment = splitMessage('one segment of a longer message. '.repeat(8), { reference: 0x6D })[0];
|
||||
|
||||
assert.ok(segment);
|
||||
peer.write(bindOf(0x34));
|
||||
await peer.next();
|
||||
peer.write({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: consts.ESM_CLASS.UDH_INDICATOR,
|
||||
short_message: segment,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: 2,
|
||||
});
|
||||
|
||||
const refused = await peer.next();
|
||||
const reported = await failed;
|
||||
|
||||
assert.equal(refused.cmdStatus, 'ESME_RINVDSTADR');
|
||||
assert.equal(await raceWithin(200, peer.next()), false, 'the peer gets one answer, not two');
|
||||
assert.equal(reported.message, 'the onRequest hook exploded after answering');
|
||||
});
|
||||
|
||||
test('closes without waiting out the shutdown for a message the hook answered itself', async t => {
|
||||
const smpp = await startServer(t, {
|
||||
onRequest: async (bound, pduObj) => {
|
||||
if (!isCommand(pduObj, 'submit_sm')) return false;
|
||||
|
||||
await bound.sendReturn(pduObj, 'ESME_ROK', { message_id: answeredId });
|
||||
|
||||
return true;
|
||||
},
|
||||
shutdownTimeout: 2000,
|
||||
});
|
||||
const bound = once<Session>(resolve => { smpp.on('session', resolve); });
|
||||
const { session } = await connect(t, smpp);
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const answered = await submitTo(session, '46709771337');
|
||||
const serverSide = await bound;
|
||||
const started = Date.now();
|
||||
const closed = await serverSide.close();
|
||||
const waited = Date.now() - started;
|
||||
|
||||
assert.ok(answered.pduObj);
|
||||
assert.equal(paramText(answered.pduObj.params.message_id), answeredId);
|
||||
assert.deepEqual(closed, {});
|
||||
assert.ok(waited < 1000, `close() waited ${String(waited)} ms on a message nothing was holding`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('link timers', () => {
|
||||
test('closes a client link the peer has stopped answering', async t => {
|
||||
const peer = await smscPeer(t);
|
||||
@@ -2116,6 +2326,23 @@ describe('option validation', () => {
|
||||
assert.ok(err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a hook that is not a function at startup, rather than once per PDU', async () => {
|
||||
const badAuth: ServerOptions = { port: 0 };
|
||||
const badHook: ServerOptions = { port: 0 };
|
||||
|
||||
Object.assign(badAuth, { authenticate: 'yes please' });
|
||||
Object.assign(badHook, { onRequest: 'refuse them all' });
|
||||
|
||||
const authRefused = await server(badAuth);
|
||||
const hookRefused = await server(badHook);
|
||||
|
||||
if (authRefused.server) await authRefused.server.close();
|
||||
if (hookRefused.server) await hookRefused.server.close();
|
||||
|
||||
assert.match(authRefused.err?.message ?? '', /authenticate must be a function/);
|
||||
assert.match(hookRefused.err?.message ?? '', /onRequest must be a function/);
|
||||
});
|
||||
|
||||
test('returns an error rather than rejecting on an impossible port', async () => {
|
||||
const listening = await server({ port: 70_000 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user