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:
+31
-61
@@ -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) => {
|
||||
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<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);
|
||||
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<true>(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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+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