Split the session, gate optional parameters on the peer version, own the SMPP version in defs
This commit is contained in:
+2
-2
@@ -61,8 +61,8 @@ describe('parseReceipt()', () => {
|
||||
describe('dlrFromPdu()', () => {
|
||||
test('prefers the TLVs when the peer sends them', () => {
|
||||
const dlr = dlrFromPdu(deliverSm(receiptText, {
|
||||
message_state: { tagId: 0x0427, tagValue: 5 },
|
||||
receipted_message_id: { tagId: 0x001E, tagValue: 'from-the-tlv' },
|
||||
message_state: { tagValue: 5 },
|
||||
receipted_message_id: { tagValue: 'from-the-tlv' },
|
||||
}));
|
||||
|
||||
assert.ok(dlr);
|
||||
|
||||
+80
-12
@@ -195,22 +195,90 @@ describe('TLVs', () => {
|
||||
},
|
||||
seqNr: 393,
|
||||
tlvs: {
|
||||
5142: { tagId: 5142, tagName: 'Nils', tagValue: Buffer.from('blajfoo', 'ascii') },
|
||||
receipted_message_id: {
|
||||
tagId: 0x001E,
|
||||
tagName: 'receipted_message_id',
|
||||
tagValue: '293f293',
|
||||
},
|
||||
5142: { tagId: 5142, tagValue: Buffer.from('blajfoo', 'ascii') },
|
||||
receipted_message_id: { tagValue: '293f293' },
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, '293f293');
|
||||
assert.equal(pduObj.tlvs['5142']?.tagName, undefined);
|
||||
|
||||
const unknown = pduObj.tlvs['5142']?.tagValue;
|
||||
const unknown = pduObj.tlvs['5142'];
|
||||
|
||||
assert.equal(typeof unknown, 'string');
|
||||
assert.equal(Buffer.from(typeof unknown === 'string' ? unknown : '', 'hex').toString('ascii'), 'blajfoo');
|
||||
assert.ok(unknown);
|
||||
assert.equal(unknown.tagName, undefined);
|
||||
assert.deepEqual(unknown.tagValue, Buffer.from('blajfoo', 'ascii'));
|
||||
});
|
||||
|
||||
test('keeps a binary TLV byte for byte through pduToObj and back', () => {
|
||||
const payload = Buffer.from('deadbeef00ff', 'hex');
|
||||
const params = {
|
||||
destination_addr: '46709771337',
|
||||
esm_class: 4,
|
||||
short_message: 'binary payload follows',
|
||||
source_addr: '46701113311',
|
||||
};
|
||||
const parsed = decode(encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params,
|
||||
seqNr: 7,
|
||||
tlvs: { message_payload: { tagValue: payload } },
|
||||
}));
|
||||
const carried = parsed.tlvs.message_payload;
|
||||
|
||||
assert.ok(carried);
|
||||
assert.deepEqual(carried.tagValue, payload);
|
||||
|
||||
const rebuilt = decode(encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params,
|
||||
seqNr: 7,
|
||||
tlvs: { message_payload: { tagValue: carried.tagValue } },
|
||||
}));
|
||||
|
||||
assert.deepEqual(rebuilt.tlvs.message_payload?.tagValue, payload);
|
||||
});
|
||||
|
||||
test('takes the tag id from the record key when the caller gives none', () => {
|
||||
const pduObj = decode(encode({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
seqNr: 11,
|
||||
tlvs: { message_state: { tagValue: 6 }, source_port: { tagValue: 1234 } },
|
||||
}));
|
||||
|
||||
assert.deepEqual(pduObj.tlvs.message_state, { tagId: 0x0427, tagName: 'message_state', tagValue: 6 });
|
||||
assert.deepEqual(pduObj.tlvs.source_port, { tagId: 0x020A, tagName: 'source_port', tagValue: 1234 });
|
||||
});
|
||||
|
||||
test('refuses an unknown tag name rather than putting a wrong tag on the wire', () => {
|
||||
const { buffer, err } = objToPdu({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
tlvs: { nils: { tagValue: 'blajfoo' } },
|
||||
});
|
||||
|
||||
assert.equal(buffer, undefined);
|
||||
assert.ok(err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a tag id that does not fit the two octet field', () => {
|
||||
const { err } = objToPdu({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
tlvs: { nils: { tagId: 0x10000, tagValue: 'blajfoo' } },
|
||||
});
|
||||
|
||||
assert.ok(err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses a TLV too long for the two octet length field', () => {
|
||||
const { err } = objToPdu({
|
||||
cmdName: 'deliver_sm',
|
||||
params: { destination_addr: '46709771337', short_message: 'hi', source_addr: '46701113311' },
|
||||
tlvs: { message_payload: { tagValue: Buffer.alloc(0x10000) } },
|
||||
});
|
||||
|
||||
assert.ok(err instanceof Error);
|
||||
});
|
||||
|
||||
test('round-trips a receipt with message_state and receipted_message_id', () => {
|
||||
@@ -225,8 +293,8 @@ describe('TLVs', () => {
|
||||
},
|
||||
seqNr: 323,
|
||||
tlvs: {
|
||||
message_state: { tagId: 1063, tagName: 'message_state', tagValue: 2 },
|
||||
receipted_message_id: { tagId: 30, tagName: 'receipted_message_id', tagValue: 450 },
|
||||
message_state: { tagId: 1063, tagValue: 2 },
|
||||
receipted_message_id: { tagId: 30, tagValue: 450 },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
+50
-66
@@ -2,11 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test, { describe } from 'node:test';
|
||||
import type { MessageDlr } from '../src/session.ts';
|
||||
import type { PduObject } from '../src/pdu.ts';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import type { SmppServer } from '../src/server.ts';
|
||||
import { Reassembler } from '../src/reassembly.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { objToPdu } from '../src/pdu.ts';
|
||||
import { server } from '../src/server.ts';
|
||||
import { silentLog } from '../src/log.ts';
|
||||
|
||||
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
|
||||
const { err, server: smpp } = await server({ ...options, port: 0 });
|
||||
@@ -171,95 +173,77 @@ describe('reconnect', () => {
|
||||
});
|
||||
|
||||
describe('reassembly bounds', () => {
|
||||
function segment(reference: number, part: number, total: number, seqNr: number): Buffer {
|
||||
const body = Buffer.concat([
|
||||
Buffer.from([0x05, 0x00, 0x03, reference, total, part]),
|
||||
Buffer.from('fragment'),
|
||||
]);
|
||||
const { buffer } = objToPdu({
|
||||
function segment(reference: number, part: number, total: number): PduObject {
|
||||
const udh = Buffer.from([0x05, 0x00, 0x03, reference, total, part]);
|
||||
|
||||
return {
|
||||
cmdId: 0x00000004,
|
||||
cmdLength: 0,
|
||||
cmdName: 'submit_sm',
|
||||
cmdStatus: 'ESME_ROK',
|
||||
cmdStatusId: 0,
|
||||
params: {
|
||||
data_coding: 0,
|
||||
destination_addr: '46709771337',
|
||||
esm_class: 0x40,
|
||||
short_message: body,
|
||||
sm_length: body.length,
|
||||
short_message: Buffer.concat([udh, Buffer.from('fragment')]),
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr,
|
||||
});
|
||||
|
||||
assert.ok(buffer);
|
||||
|
||||
return buffer;
|
||||
seqNr: part,
|
||||
tlvs: {},
|
||||
};
|
||||
}
|
||||
|
||||
// 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived.
|
||||
test('drops the oldest incomplete message once the cap is reached', async () => {
|
||||
const smpp = await startServer({ maxReassembly: 2, reassemblyTimeout: 60_000 });
|
||||
function collect(
|
||||
reassembler: Reassembler,
|
||||
reference: number,
|
||||
part: number,
|
||||
total: number,
|
||||
): PduObject[] | undefined {
|
||||
return reassembler.collect(segment(reference, part, total), { part, reference, total });
|
||||
}
|
||||
|
||||
let delivered = 0;
|
||||
test('hands back every segment in order once the last one arrives', () => {
|
||||
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 });
|
||||
|
||||
smpp.on('session', session => session.on('sms', () => { delivered++; }));
|
||||
assert.equal(collect(reassembler, 4, 2, 3), undefined);
|
||||
assert.equal(collect(reassembler, 4, 3, 3), undefined);
|
||||
|
||||
const sock = net.connect({ port: smpp.port }, () => {
|
||||
sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex'));
|
||||
});
|
||||
const whole = collect(reassembler, 4, 1, 3);
|
||||
|
||||
let bound = false;
|
||||
|
||||
sock.on('data', () => {
|
||||
if (bound) return;
|
||||
|
||||
bound = true;
|
||||
|
||||
// Three different messages, each only ever sending part 1 of 2.
|
||||
sock.write(segment(1, 1, 2, 10));
|
||||
sock.write(segment(2, 1, 2, 11));
|
||||
sock.write(segment(3, 1, 2, 12));
|
||||
// Completing the first one must not produce a message: it was evicted.
|
||||
sock.write(segment(1, 2, 2, 13));
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
assert.equal(delivered, 0, 'an evicted message must not be delivered');
|
||||
|
||||
sock.destroy();
|
||||
await smpp.close();
|
||||
assert.ok(whole);
|
||||
assert.deepEqual(whole.map(pduObj => pduObj.seqNr), [1, 2, 3]);
|
||||
assert.equal(reassembler.size, 0);
|
||||
});
|
||||
|
||||
test('expires an incomplete message on its own timer', async () => {
|
||||
const smpp = await startServer({ reassemblyTimeout: 60 });
|
||||
// 0.4.0 held incomplete groups without limit and swept them only when other traffic arrived.
|
||||
test('drops the oldest incomplete message once the cap is reached', () => {
|
||||
const reassembler = new Reassembler({ log: silentLog, max: 2, now: () => 0, timeout: 60_000 });
|
||||
|
||||
let delivered = 0;
|
||||
for (const reference of [1, 2, 3]) {
|
||||
assert.equal(collect(reassembler, reference, 1, 2), undefined);
|
||||
}
|
||||
|
||||
smpp.on('session', session => session.on('sms', () => { delivered++; }));
|
||||
// Completing the first one must not produce a message: it was evicted.
|
||||
assert.equal(collect(reassembler, 1, 2, 2), undefined);
|
||||
assert.equal(reassembler.size, 2);
|
||||
|
||||
const sock = net.connect({ port: smpp.port }, () => {
|
||||
sock.write(Buffer.from('0000002100000009000000000000002f666f6f0062617200736d70700034000000', 'hex'));
|
||||
});
|
||||
reassembler.clear();
|
||||
});
|
||||
|
||||
let bound = false;
|
||||
test('expires an incomplete message once its timeout has passed', () => {
|
||||
let now = 0;
|
||||
const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => now, timeout: 60 });
|
||||
|
||||
sock.on('data', () => {
|
||||
if (bound) return;
|
||||
assert.equal(collect(reassembler, 9, 1, 2), undefined);
|
||||
|
||||
bound = true;
|
||||
sock.write(segment(9, 1, 2, 20));
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
now = 61;
|
||||
|
||||
// The other half arrives after the group expired, so it starts a new, still-incomplete one.
|
||||
sock.write(segment(9, 2, 2, 21));
|
||||
assert.equal(collect(reassembler, 9, 2, 2), undefined);
|
||||
assert.equal(reassembler.size, 1);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
assert.equal(delivered, 0);
|
||||
|
||||
sock.destroy();
|
||||
await smpp.close();
|
||||
reassembler.clear();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+160
-19
@@ -1,12 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test, { describe } from 'node:test';
|
||||
import type { PduObject } from '../src/pdu.ts';
|
||||
import type { PduObject, PduObjectInput } from '../src/pdu.ts';
|
||||
import type { Session } from '../src/session.ts';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import type { SmppServer } from '../src/server.ts';
|
||||
import { PduFramer } from '../src/pdu-framer.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { isCommand, objToPdu, pduToObj } from '../src/pdu.ts';
|
||||
import { paramText } from '../src/defs/types.ts';
|
||||
import { server } from '../src/server.ts';
|
||||
|
||||
async function startServer(options: Parameters<typeof server>[0] = {}): Promise<SmppServer> {
|
||||
@@ -26,28 +28,73 @@ function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
|
||||
return new Promise<T>(resolve => { register(resolve); });
|
||||
}
|
||||
|
||||
/** Binds off a raw socket, which is the only way to declare a version the client cannot. */
|
||||
async function bindRaw(smpp: SmppServer, interfaceVersion: number): Promise<PduObject> {
|
||||
const { buffer } = objToPdu({
|
||||
type RawPeer = {
|
||||
close: () => void;
|
||||
/** The next PDU the server sends, queued so none is missed between reads. */
|
||||
next: () => Promise<PduObject>;
|
||||
write: (input: PduObjectInput) => void;
|
||||
};
|
||||
|
||||
/** A peer driven PDU by PDU, which is the only way to say things the client never says. */
|
||||
function rawPeer(port: number): RawPeer {
|
||||
const framer = new PduFramer();
|
||||
const queue: PduObject[] = [];
|
||||
const waiting: ((pduObj: PduObject) => void)[] = [];
|
||||
const sock = net.connect({ port });
|
||||
|
||||
sock.on('data', chunk => {
|
||||
framer.push(chunk);
|
||||
|
||||
const { pdus } = framer.next();
|
||||
|
||||
for (const pdu of pdus ?? []) {
|
||||
const { pduObj } = pduToObj(pdu);
|
||||
|
||||
if (!pduObj) continue;
|
||||
|
||||
const next = waiting.shift();
|
||||
|
||||
if (next) next(pduObj);
|
||||
else queue.push(pduObj);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
close: () => { sock.destroy(); },
|
||||
next: () => {
|
||||
const queued = queue.shift();
|
||||
|
||||
return queued
|
||||
? Promise.resolve(queued)
|
||||
: once<PduObject>(resolve => waiting.push(resolve));
|
||||
},
|
||||
write: input => {
|
||||
const { buffer } = objToPdu(input);
|
||||
|
||||
assert.ok(buffer);
|
||||
sock.write(buffer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindOf(interfaceVersion: number, seqNr = 1): PduObjectInput {
|
||||
return {
|
||||
cmdName: 'bind_transceiver',
|
||||
params: { interface_version: interfaceVersion, password: 'pass', system_id: 'user' },
|
||||
});
|
||||
seqNr,
|
||||
};
|
||||
}
|
||||
|
||||
assert.ok(buffer);
|
||||
async function bindRaw(smpp: SmppServer, interfaceVersion: number): Promise<PduObject> {
|
||||
const peer = rawPeer(smpp.port);
|
||||
|
||||
const sock = net.connect({ port: smpp.port });
|
||||
const response = await once<Buffer>(resolve => {
|
||||
sock.on('connect', () => { sock.write(buffer); });
|
||||
sock.once('data', resolve);
|
||||
});
|
||||
peer.write(bindOf(interfaceVersion));
|
||||
|
||||
sock.destroy();
|
||||
const response = await peer.next();
|
||||
|
||||
const { pduObj } = pduToObj(response);
|
||||
peer.close();
|
||||
|
||||
assert.ok(pduObj);
|
||||
|
||||
return pduObj;
|
||||
return response;
|
||||
}
|
||||
|
||||
describe('bind', () => {
|
||||
@@ -161,6 +208,41 @@ describe('bind', () => {
|
||||
await smpp.close();
|
||||
});
|
||||
|
||||
test('advertises the version the server is configured with', async () => {
|
||||
const smpp = await startServer({ interfaceVersion: 0x50 });
|
||||
const asThreeFour = await bindRaw(smpp, 0x34);
|
||||
|
||||
assert.equal(asThreeFour.tlvs.sc_interface_version?.tagValue, 0x50);
|
||||
|
||||
// The threshold for sending optional parameters is 3.4 whatever the server advertises.
|
||||
const asThreeThree = await bindRaw(smpp, 0x33);
|
||||
|
||||
assert.deepEqual(asThreeThree.tlvs, {});
|
||||
|
||||
await smpp.close();
|
||||
});
|
||||
|
||||
test('answers a bind with its own system_id, not the one the ESME sent', async () => {
|
||||
const anonymous = await startServer();
|
||||
const named = await startServer({ systemId: 'the-smsc' });
|
||||
|
||||
assert.equal((await bindRaw(anonymous, 0x34)).params.system_id, '');
|
||||
assert.equal((await bindRaw(named, 0x34)).params.system_id, 'the-smsc');
|
||||
|
||||
await anonymous.close();
|
||||
await named.close();
|
||||
});
|
||||
|
||||
test('answers a refused bind with its own system_id too', async () => {
|
||||
const smpp = await startServer({ authenticate: () => false, systemId: 'the-smsc' });
|
||||
const refused = await bindRaw(smpp, 0x34);
|
||||
|
||||
assert.equal(refused.cmdStatus, 'ESME_RBINDFAIL');
|
||||
assert.equal(refused.params.system_id, 'the-smsc');
|
||||
|
||||
await smpp.close();
|
||||
});
|
||||
|
||||
test('sends no optional parameters to a peer declaring less than 3.4', async () => {
|
||||
const smpp = await startServer();
|
||||
const bound = await bindRaw(smpp, 0x00);
|
||||
@@ -170,6 +252,23 @@ describe('bind', () => {
|
||||
|
||||
await smpp.close();
|
||||
});
|
||||
|
||||
test('answers a second bind with ESME_RALYBND and its own system_id', async () => {
|
||||
const smpp = await startServer({ systemId: 'the-smsc' });
|
||||
const peer = rawPeer(smpp.port);
|
||||
|
||||
peer.write(bindOf(0x34));
|
||||
await peer.next();
|
||||
peer.write(bindOf(0x34, 2));
|
||||
|
||||
const again = await peer.next();
|
||||
|
||||
assert.equal(again.cmdStatus, 'ESME_RALYBND');
|
||||
assert.equal(again.params.system_id, 'the-smsc');
|
||||
|
||||
peer.close();
|
||||
await smpp.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sending', () => {
|
||||
@@ -302,8 +401,8 @@ describe('delivery reports', () => {
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const dlr = once<{ smsId: string; statusMsg: string }>(resolve => {
|
||||
session.on('dlr', resolve);
|
||||
const dlr = once<[{ smsId: string; statusMsg: string }, PduObject]>(resolve => {
|
||||
session.on('dlr', (report, pduObj) => { resolve([report, pduObj]); });
|
||||
});
|
||||
|
||||
const [sms] = await Promise.all([
|
||||
@@ -319,10 +418,12 @@ describe('delivery reports', () => {
|
||||
assert.ok(sms.dlr);
|
||||
await sms.sendDlr();
|
||||
|
||||
const report = await dlr;
|
||||
const [report, receipt] = await dlr;
|
||||
|
||||
assert.equal(report.smsId, 'dlr-id');
|
||||
assert.equal(report.statusMsg, 'DELIVERED');
|
||||
assert.equal(receipt.tlvs.receipted_message_id?.tagValue, 'dlr-id');
|
||||
assert.equal(receipt.tlvs.message_state?.tagValue, 2);
|
||||
|
||||
session.close();
|
||||
await smpp.close();
|
||||
@@ -365,6 +466,46 @@ describe('delivery reports', () => {
|
||||
session.close();
|
||||
await smpp.close();
|
||||
});
|
||||
|
||||
test('sends a text-only receipt to a peer that declared less than 3.4', async () => {
|
||||
const smpp = await startServer();
|
||||
const incoming = once<Sms>(resolve => {
|
||||
smpp.on('session', session => session.on('sms', resolve));
|
||||
});
|
||||
const peer = rawPeer(smpp.port);
|
||||
|
||||
peer.write(bindOf(0x33));
|
||||
await peer.next();
|
||||
peer.write({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
data_coding: 0,
|
||||
destination_addr: '46709771337',
|
||||
registered_delivery: 1,
|
||||
short_message: 'hi',
|
||||
sm_length: 2,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: 2,
|
||||
});
|
||||
|
||||
const sms = await incoming;
|
||||
|
||||
await sms.sendResp();
|
||||
await peer.next();
|
||||
|
||||
// A raw peer answers no deliver_sm, so this only settles once the session closes.
|
||||
void sms.sendDlr();
|
||||
|
||||
const receipt = await peer.next();
|
||||
|
||||
assert.equal(receipt.cmdName, 'deliver_sm');
|
||||
assert.deepEqual(receipt.tlvs, {});
|
||||
assert.match(paramText(receipt.params.short_message), /stat:DELIVRD/);
|
||||
|
||||
peer.close();
|
||||
await smpp.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a session captured from Kannel', () => {
|
||||
|
||||
Reference in New Issue
Block a user