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 { ErrorName } from '../src/defs/errors.ts'; import type { MessageState } from '../src/defs/constants.ts'; import type { MessageDlr } from '../src/session.ts'; import type { PduObject, PduObjectInput } from '../src/pdu.ts'; import type { Result } from '../src/result.ts'; import type { SendSmsResult } from '../src/send-sms.ts'; import type { SmppLog } from '../src/log.ts'; import type { Sms } from '../src/sms.ts'; import type { SmppServer } from '../src/server.ts'; import { Reassembler, decodeSegments } from '../src/reassembly.ts'; import { DlrMerger } from '../src/dlr-merger.ts'; import { client } from '../src/client.ts'; import { consts } from '../src/defs/constants.ts'; import { errors } from '../src/defs/errors.ts'; import { server } from '../src/server.ts'; import { silentLog } from '../src/log.ts'; import { submitSms } from '../src/send-sms.ts'; async function startServer(options: Parameters[0] = {}): Promise { const { err, server: smpp } = await server({ ...options, port: 0 }); assert.equal(err, undefined); assert.ok(smpp); return smpp; } /** An event that never fires would otherwise block until the CI job limit, asserting nothing. */ function once(register: (resolve: (value: T) => void) => void): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error('waited 5000 ms for an event that never fired')); }, 5000); register(value => { clearTimeout(timer); resolve(value); }); }); } describe('merged delivery reports', () => { // 0.4.0 allocated a longSmsDlrs store to do exactly this and then never used it. test('reports once on a whole multipart message', async () => { const smpp = await startServer(); const incoming = once(resolve => { smpp.on('session', session => session.on('sms', resolve)); }); const { session } = await client({ port: smpp.port }); assert.ok(session); const merged = once(resolve => { session.on('messageDlr', resolve); }); const perSegment: string[] = []; session.on('dlr', dlr => perSegment.push(dlr.smsId)); const [sms] = await Promise.all([ incoming.then(async received => { await received.sendResp({ smsId: 'merge-me' }); return received; }), session.sendSms({ dlr: true, from: '46701113311', message: 'x'.repeat(400), to: '46709771337', }), ]); await sms.sendDlr(); const report = await merged; assert.equal(report.smsId, 'merge-me'); assert.equal(report.segments.length, 3); assert.equal(report.statusMsg, 'DELIVERED'); assert.deepEqual(perSegment, ['merge-me-1', 'merge-me-2', 'merge-me-3']); session.close(); await smpp.close(); }); test('reports the worst status across the segments', async () => { const smpp = await startServer(); const incoming = once(resolve => { smpp.on('session', session => session.on('sms', resolve)); }); const { session } = await client({ port: smpp.port }); assert.ok(session); const merged = once(resolve => { session.on('messageDlr', resolve); }); const [sms] = await Promise.all([ incoming.then(async received => { await received.sendResp({ smsId: 'partly-failed' }); return received; }), session.sendSms({ dlr: true, from: '46701113311', message: 'x'.repeat(400), to: '46709771337', }), ]); await sms.sendDlr('UNDELIVERABLE'); const report = await merged; assert.equal(report.statusMsg, 'UNDELIVERABLE'); assert.equal(report.segments.length, 3); session.close(); await smpp.close(); }); }); describe('merging segment statuses', () => { function receipt(smsId: string, statusMsg: MessageState): Dlr { return { doneDate: undefined, errorCode: undefined, receipt: undefined, smsId, statusId: consts.MESSAGE_STATE[statusMsg], statusMsg, }; } // MESSAGE_STATE is a flat enum: ACCEPTED is 6 where UNDELIVERABLE is 5, so reducing on the // wire value called a part-failed message delivered. test('reports the worse of two states the wire numbers the other way round', () => { const merger = new DlrMerger({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 }); merger.expect(['msg-1', 'msg-2']); assert.equal(merger.collect(receipt('msg-1', 'UNDELIVERABLE')), undefined); const merged = merger.collect(receipt('msg-2', 'ACCEPTED')); assert.ok(merged); assert.equal(merged.statusMsg, 'UNDELIVERABLE'); }); }); describe('sendSms()', () => { function submitResp(seqNr: number, messageId: string, status: ErrorName = 'ESME_ROK'): PduObject { return { cmdId: 0x80000004, cmdLength: 0, cmdName: 'submit_sm_resp', cmdStatus: status, cmdStatusId: errors[status], params: { message_id: messageId }, seqNr, tlvs: {}, }; } /** Three segments, each answered by whatever the caller decides for that part. */ function sendSegments( answer: (part: number) => Result<{ pduObj: PduObject }>, ): Promise { let part = 0; return submitSms( { log: silentLog, reference: 7, send: () => { part++; return Promise.resolve(answer(part)); }, }, { from: '46701113311', message: 'x'.repeat(400), to: '46709771337' }, ); } test('reports a submit_sm the peer refused instead of an empty message id', async () => { const smpp = await startServer(); const incoming = once(resolve => { smpp.on('session', session => session.on('sms', resolve)); }); const { session } = await client({ port: smpp.port }); assert.ok(session); const [, sent] = await Promise.all([ incoming.then(received => received.sendResp({ status: 'ESME_RMSGQFUL' })), session.sendSms({ from: '46701113311', message: 'the queue is full', to: '46709771337' }), ]); assert.ok(sent.err instanceof Error); assert.match(sent.err.message, /ESME_RMSGQFUL/); session.close(); await smpp.close(); }); // A retry that repeats the segments the SMSC already took bills the recipient twice. test('hands back the ids that landed when a segment fails', async () => { const refused = await sendSegments(part => part === 2 ? { pduObj: submitResp(part, '', 'ESME_RMSGQFUL') } : { pduObj: submitResp(part, `landed-${String(part)}`) }); assert.ok(refused.err instanceof Error); assert.match(refused.err.message, /ESME_RMSGQFUL/); assert.equal(refused.pduObjs.length, 2); assert.deepEqual(refused.smsIds, ['landed-1', 'landed-3']); const unanswered = await sendSegments(part => part === 2 ? { err: new Error('No response to seqNr 2') } : { pduObj: submitResp(part, `landed-${String(part)}`) }); assert.ok(unanswered.err instanceof Error); assert.deepEqual(unanswered.smsIds, ['landed-1', 'landed-3']); }); test('refuses a message needing more segments than a UDH can number', async () => { const attempts: PduObjectInput[] = []; const sent = await submitSms( { log: silentLog, reference: 1, send: input => { attempts.push(input); return Promise.resolve({ err: new Error('nothing should reach the wire') }); }, }, { from: '46701113311', message: 'a'.repeat(153 * 256), to: '46709771337' }, ); assert.ok(sent.err instanceof Error); assert.equal(attempts.length, 0); }); // Most handsets and SMSCs stop well short of the 255 a UDH can number. test('refuses a message needing more segments than the caller allows', async () => { const attempts: PduObjectInput[] = []; const deps = { log: silentLog, reference: 3, send: (input: PduObjectInput) => { attempts.push(input); return Promise.resolve({ pduObj: submitResp(attempts.length, `landed-${String(attempts.length)}`) }); }, }; const message = 'a'.repeat(153 * 4); const refused = await submitSms(deps, { from: '46701113311', maxSegments: 3, message, to: '46709771337' }); assert.ok(refused.err instanceof Error); assert.equal(attempts.length, 0); const sent = await submitSms(deps, { from: '46701113311', maxSegments: 4, message, to: '46709771337' }); assert.equal(sent.err, undefined); assert.equal(attempts.length, 4); }); }); describe('reconnect', () => { test('re-binds after the connection drops, keeping the same session object', async () => { const smpp = await startServer(); const messages: string[] = []; // Registered up front so the session created by the reconnect is covered too. smpp.on('session', bound => { bound.on('sms', sms => { messages.push(sms.message); void sms.sendResp(); }); }); const { err, session } = await client({ port: smpp.port, reconnect: { maxDelay: 100, minDelay: 20 }, }); assert.equal(err, undefined); assert.ok(session); const reconnected = once(resolve => { session.on('reconnected', () => { resolve(true); }); }); // Drop the connection from the server's side, as a peer restart would. for (const serverSession of smpp.sessions) { serverSession.close(); } await reconnected; assert.ok(session.loggedIn); // The session object survives the drop, so listeners stay attached and it is usable again. const sent = await session.sendSms({ from: '46701113311', message: 'after reconnect', to: '46709771337', }); assert.equal(sent.err, undefined); assert.deepEqual(messages, ['after reconnect']); session.close(); await smpp.close(); }); test('does not reconnect after an explicit close', async () => { const smpp = await startServer(); const { session } = await client({ port: smpp.port, reconnect: { maxDelay: 50, minDelay: 10 }, }); assert.ok(session); let reconnects = 0; session.on('reconnected', () => { reconnects++; }); session.close(); await new Promise(resolve => setTimeout(resolve, 150)); assert.equal(reconnects, 0); await smpp.close(); }); }); describe('reassembly bounds', () => { 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: Buffer.concat([udh, Buffer.from('fragment')]), source_addr: '46701113311', }, seqNr: part, tlvs: {}, }; } function collect( reassembler: Reassembler, reference: number, part: number, total: number, ): PduObject[] | undefined { return reassembler.collect(segment(reference, part, total), { part, reference, total }); } 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 }); assert.equal(collect(reassembler, 4, 2, 3), undefined); assert.equal(collect(reassembler, 4, 3, 3), undefined); const whole = collect(reassembler, 4, 1, 3); assert.ok(whole); assert.deepEqual(whole.map(pduObj => pduObj.seqNr), [1, 2, 3]); assert.equal(reassembler.size, 0); }); // The UDH is peer-controlled, and the default authenticate() accepts every peer. test('refuses a segment whose concatenation metadata cannot be honoured', () => { const warnings: string[] = []; const noop = (): void => undefined; const log: SmppLog = { debug: noop, error: noop, info: noop, verbose: noop, warn: msg => { warnings.push(msg); }, }; const reassembler = new Reassembler({ log, max: 10, now: () => 0, timeout: 60_000 }); assert.equal(collect(reassembler, 1, 1, 0), undefined); assert.equal(collect(reassembler, 2, 0, 3), undefined); assert.equal(collect(reassembler, 3, 4, 3), undefined); assert.equal(reassembler.size, 0); assert.deepEqual(warnings, Array(3).fill('reassembler - dropping a segment the UDH numbers impossibly')); }); // Parts 1/2 then 2/3 would otherwise complete the stored two-part group, truncating the message. test('refuses a segment that renumbers how many parts the message has', () => { const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 }); assert.equal(collect(reassembler, 9, 1, 2), undefined); assert.equal(collect(reassembler, 9, 2, 3), undefined); assert.equal(reassembler.size, 1); reassembler.clear(); }); // 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 }); for (const reference of [1, 2, 3]) { assert.equal(collect(reassembler, reference, 1, 2), undefined); } // 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); reassembler.clear(); }); test('drops the oldest incomplete message once the retained octets exceed the cap', () => { const reassembler = new Reassembler({ log: silentLog, max: 10, // One segment is 36 octets: 14 of short_message plus the two 11-octet addresses. maxOctets: 80, now: () => 0, timeout: 60_000, }); for (const reference of [1, 2, 3]) { assert.equal(collect(reassembler, reference, 1, 2), undefined); } assert.equal(reassembler.size, 2); assert.equal(collect(reassembler, 1, 2, 2), undefined); reassembler.clear(); }); // A retained subarray keeps its whole framed PDU alive, up to maxPduLength per segment. test('copies a segment out of the buffer it arrived in', () => { const reassembler = new Reassembler({ log: silentLog, max: 10, now: () => 0, timeout: 60_000 }); const framed = Buffer.alloc(1024); const first = segment(6, 1, 2); Buffer.concat([Buffer.from([0x05, 0x00, 0x03, 6, 2, 1]), Buffer.from('fragment')]).copy(framed); first.params.short_message = framed.subarray(0, 14); assert.equal(reassembler.collect(first, { part: 1, reference: 6, total: 2 }), undefined); framed.fill(0x00); const whole = collect(reassembler, 6, 2, 2); assert.ok(whole); assert.equal(decodeSegments(whole), 'fragmentfragment'); }); 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 }); assert.equal(collect(reassembler, 9, 1, 2), undefined); now = 61; // The other half arrives after the group expired, so it starts a new, still-incomplete one. assert.equal(collect(reassembler, 9, 2, 2), undefined); assert.equal(reassembler.size, 1); reassembler.clear(); }); }); describe('AbortSignal on a send', () => { test('gives up on an in-flight request when the signal fires', async () => { const accepted: net.Socket[] = []; const silent = net.createServer(sock => { accepted.push(sock); sock.resume(); // Answer the bind so the client gets a session, then go quiet. sock.write(Buffer.from('0000001180000009000000000000000100', 'hex')); }); await new Promise(resolve => silent.listen(0, resolve)); const address = silent.address(); const port = typeof address === 'object' && address !== null ? address.port : 0; const { err, session } = await client({ port, responseTimeout: 10_000 }); assert.equal(err, undefined); assert.ok(session); const controller = new AbortController(); setTimeout(() => { controller.abort(); }, 50); const sent = await session.sendSms( { from: '46701113311', message: 'never answered', to: '46709771337' }, { signal: controller.signal }, ); assert.ok(sent.err instanceof Error); session.close(); for (const sock of accepted) sock.destroy(); await new Promise(resolve => silent.close(() => { resolve(); })); }); });