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
+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 });