diff --git a/AGENTS.md b/AGENTS.md index 948b95c..0b7ae06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index b117504..66e8556 100644 --- a/README.md +++ b/README.md @@ -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) => { - if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) { - return false; - } +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); + return true; + }, +}); +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. | diff --git a/src/server.ts b/src/server.ts index c5473f6..51303bf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { - 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 { - 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 }); diff --git a/src/session-options.ts b/src/session-options.ts index 2060447..8c077df 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -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; +export type OnRequest = (session: Session, pduObj: PduObject) => Promise | boolean; /** * How to come back after an unexpected disconnect. The session owns the retry loop; the caller diff --git a/src/session.ts b/src/session.ts index 0f01ded..16b9775 100644 --- a/src/session.ts +++ b/src/session.ts @@ -397,7 +397,11 @@ export class Session extends EventEmitter { 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); }); } diff --git a/src/sms.ts b/src/sms.ts index a82aa88..de92d43 100644 --- a/src/sms.ts +++ b/src/sms.ts @@ -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'), }); } diff --git a/test/readme.test.ts b/test/readme.test.ts index e9362f1..ef1593c 100644 --- a/test/readme.test.ts +++ b/test/readme.test.ts @@ -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(register: (resolve: (value: T) => void) => void): Promise { @@ -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) => { - if (!isCommand(pduObj, 'submit_sm') || knownRecipients.has(pduObj.params.destination_addr)) { - return false; - } - - await bound.sendReturn(pduObj, 'ESME_RINVDSTADR'); - - return true; - }, - sock, - }); - - session.linkEnd = 'smsc'; - closeAfter(t, session); - }); - - closeListenerAfter(t, listener, accepted); - - await new Promise(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(resolve => { - peer.on('data', chunk => { - framer.push(chunk); - - for (const pdu of framer.next().pdus ?? []) { - const { pduObj } = pduToObj(pdu); - - if (pduObj) resolve(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 once(resolve => { peer.once('connect', () => { resolve(true); }); }); + await session.sendReturn(pduObj, 'ESME_RINVDSTADR'); - const { buffer } = objToPdu({ - cmdName: 'submit_sm', - params: { - destination_addr: '46700000000', - short_message: 'Hello world', - source_addr: '46701113311', + return true; }, - seqNr: 1, + }); + 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', }); - 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'); }); }); diff --git a/test/session.test.ts b/test/session.test.ts index 4e957d5..d50d6a9 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -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(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(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(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(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(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 });