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:
2026-09-06 17:20:45 +02:00
committed by GitHub
parent 0d1da1dc87
commit bcbb042c1b
8 changed files with 375 additions and 100 deletions
+44
View File
@@ -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.
+22 -16
View File
@@ -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
View File
@@ -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 });
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });