import assert from 'node:assert/strict'; import http from 'node:http'; import test, { after, describe } from 'node:test'; import type { MessageState } from '../src/defs/constants.ts'; import type { Dlr } from '../src/dlr.ts'; import type { Session } from '../src/session.ts'; import type { Sms } from '../src/sms.ts'; import { ConcatReference } from '../src/udh.ts'; import { consts } from '../src/defs/constants.ts'; import { detect, encodings } from '../src/defs/encodings.ts'; import { paramText } from '../src/defs/types.ts'; import { server } from '../src/server.ts'; import { splitMessage } from '../src/message.ts'; import { submitSmParams } from '../src/send-sms.ts'; // smsbox HTTP hosts, one per variant - all point at the same node:2775 SMPP server. const MAIN_SMSBOX = process.env.MAIN_SMSBOX ?? 'kannel-smsbox:13013'; const IV33_SMSBOX = process.env.IV33_SMSBOX ?? 'kannel-iv33-smsbox:13013'; const MAXP1_SMSBOX = process.env.MAXP1_SMSBOX ?? 'kannel-maxp1-smsbox:13013'; const NOTRX_SMSBOX = process.env.NOTRX_SMSBOX ?? 'kannel-notrx-smsbox:13013'; const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775'); const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080'); const SENDSMS_USER = 'tester'; const SENDSMS_PASS = 'testerpw'; type Variant = 'iv33' | 'main' | 'maxp1' | 'notrx'; function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } /** Polls until `get()` stops returning undefined, or the budget runs out. */ async function waitFor(get: () => T | undefined, budget = 5000): Promise { const deadline = Date.now() + budget; let value = get(); while (value === undefined && Date.now() < deadline) { await delay(20); value = get(); } return value; } // --- Shared infra: one long-lived server() and one HTTP callback listener for the whole file, // since every Kannel variant dials in and keeps retrying from container start, independent of // when this file's tests run. --- type MoCallback = { coding: string; from: string; text: string; to: string; udh: string }; type DlrCallback = { answer: string; id: string; type: string }; const moCallbacks = new Map(); const dlrCallbacks: DlrCallback[] = []; function variantFromPath(pathname: string): Variant | undefined { if (pathname === '/mo') return 'main'; if (pathname === '/mo/iv33') return 'iv33'; if (pathname === '/mo/maxp1') return 'maxp1'; if (pathname === '/mo/notrx') return 'notrx'; return undefined; } function rawQueryValue(rawUrl: string, key: string): string { const match = new RegExp(`[?&]${key}=([^&]*)`).exec(rawUrl); return match?.[1] ?? ''; } function percentDecodeBytes(raw: string): Buffer { const bytes: number[] = []; for (let i = 0; i < raw.length; i++) { if (raw[i] === '%' && i + 2 < raw.length) { bytes.push(Number.parseInt(raw.slice(i + 1, i + 3), 16)); i += 2; } else { bytes.push(raw.charCodeAt(i)); } } return Buffer.from(bytes); } // Kannel's %a decodes GSM text to a normal string before percent-escaping it, but for UCS-2 // (coding=2) it escapes the raw big-endian bytes instead - URLSearchParams decodes percent-escapes // as UTF-8, which turns those raw bytes into mojibake, so coding=2 needs a byte-level percent-decode // through our own UCS2 decoder instead. function moText(rawUrl: string, url: URL): string { if (url.searchParams.get('coding') !== '2') return url.searchParams.get('text') ?? ''; return encodings.UCS2.decode(percentDecodeBytes(rawQueryValue(rawUrl, 'text'))); } const httpServer = http.createServer((req, res) => { const url = new URL(req.url ?? '/', 'http://node'); if (url.pathname === '/dlr') { dlrCallbacks.push({ answer: url.searchParams.get('answer') ?? '', id: url.searchParams.get('id') ?? '', type: url.searchParams.get('type') ?? '', }); res.writeHead(200); res.end(); return; } const variant = variantFromPath(url.pathname); if (variant) { const list = moCallbacks.get(variant) ?? []; list.push({ coding: url.searchParams.get('coding') ?? '', from: url.searchParams.get('from') ?? '', text: moText(req.url ?? '', url), to: url.searchParams.get('to') ?? '', udh: url.searchParams.get('udh') ?? '', }); moCallbacks.set(variant, list); res.writeHead(200); res.end(); return; } res.writeHead(404); res.end(); }); await new Promise(resolve => { httpServer.listen(CALLBACK_PORT, resolve); }); function variantFromSystemId(systemId: string): Variant | undefined { if (systemId === 'kannel') return 'main'; if (systemId === 'kannel-iv33') return 'iv33'; if (systemId === 'kannel-maxp1') return 'maxp1'; if (systemId === 'kannel-notrx') return 'notrx'; return undefined; } const allSms: { sms: Sms; variant: Variant }[] = []; const allDlrs: { dlr: Dlr; variant: Variant }[] = []; const bindPdus: { params: Record; variant: Variant }[] = []; const { err: serverErr, server: smpp } = await server({ authenticate: ({ password, systemId }) => { if (password !== 'kannelpw') return false; const variant = variantFromSystemId(systemId); return variant ? { userData: { variant } } : false; }, idleTimeout: 40_000, port: SMPP_PORT, }); assert.equal(serverErr, undefined); assert.ok(smpp); const smppServer = smpp; smppServer.on('session', session => { session.on('incomingPduObj', pduObj => { if (!pduObj.cmdName.startsWith('bind_')) return; const variant = variantFromSystemId(paramText(pduObj.params.system_id)); if (variant) bindPdus.push({ params: pduObj.params, variant }); }); session.on('sms', sms => { const variant = (session.userData as { variant?: Variant } | undefined)?.variant; if (variant) allSms.push({ sms, variant }); }); session.on('dlr', dlr => { const variant = (session.userData as { variant?: Variant } | undefined)?.variant; if (variant) allDlrs.push({ dlr, variant }); }); }); after(async () => { await smppServer.close(); await new Promise(resolve => { httpServer.close(() => { resolve(); }); }); }); function sessionsFor(variant: Variant): Session[] { return [...smppServer.sessions].filter(s => (s.userData as { variant?: Variant } | undefined)?.variant === variant); } async function waitForSessions(variant: Variant, count: number, budget = 15_000): Promise { const found = await waitFor(() => (sessionsFor(variant).length >= count ? sessionsFor(variant) : undefined), budget); assert.ok(found, `no ${String(count)} session(s) bound for variant ${variant} within ${String(budget)}ms`); return found; } // Kannel's sendsms answers 202 with a body of "0: Accepted for delivery" or "3: Queued for later // delivery" - the HTTP status is never 200 (Table 7-16 of the user guide). async function sendsms(host: string, params: Record): Promise<{ body: string; status: number }> { const url = new URL(`http://${host}/cgi-bin/sendsms`); url.search = new URLSearchParams({ password: SENDSMS_PASS, username: SENDSMS_USER, ...params }).toString(); const response = await fetch(url); const body = await response.text(); assert.equal(response.status, 202); assert.match(body, /^[03]: /); return { body, status: response.status }; } /** The next incoming `sms` for a variant carrying `message`, polling past ones that don't match. */ async function waitForSms(variant: Variant, message: string, budget = 8000): Promise { const found = await waitFor( () => allSms.find(entry => entry.variant === variant && entry.sms.message === message)?.sms, budget, ); assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for variant ${variant}`); return found; } async function waitForMoCallback(variant: Variant, text: string, budget = 8000): Promise { const found = await waitFor( () => moCallbacks.get(variant)?.find(callback => callback.text === text), budget, ); assert.ok(found, `no MO callback carrying ${JSON.stringify(text)} arrived for variant ${variant}`); return found; } async function waitForDlrCallback(id: string, type: string, budget = 8000): Promise { const found = await waitFor( () => dlrCallbacks.find(callback => callback.id === id && callback.type === type), budget, ); assert.ok(found, `no dlr callback id=${id} type=${type} arrived (seen: ${JSON.stringify(dlrCallbacks)})`); return found; } /** Builds a `deliver_sm` per segment the way `session.sendSms()` builds `submit_sm` - see the MO * describe block for why this bypasses `sendSms()` itself. */ async function sendMo(session: Session, opts: { from: string; message: string; to: string }): Promise { const encoding = detect(opts.message); const reference = moReference.next(); const segments = splitMessage(opts.message, { encoding, reference }); const multipart = segments.length > 1; for (const segment of segments) { const params = submitSmParams({ from: opts.from, message: opts.message, to: opts.to }, segment, { encoding, multipart }); const sent = await session.send({ cmdName: 'deliver_sm', params }); assert.equal(sent.err, undefined); assert.ok(sent.pduObj); assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK'); } } const moReference = new ConcatReference(); describe('kannel main variant - bind', () => { test('binds transceiver 34, defaults addr_ton/npi to 0, carries our system_type', async () => { await waitForSessions('main', 1); const bind = await waitFor(() => bindPdus.find(entry => entry.variant === 'main')); assert.ok(bind); assert.equal(bind.params.system_type, 'kannel-esme'); assert.equal(bind.params.interface_version, 0x34); assert.equal(bind.params.addr_ton, 0); assert.equal(bind.params.addr_npi, 0); assert.equal(bind.params.address_range, ''); }); }); describe('S1 - MT from Kannel with delivery reports', () => { for (const status of ['DELIVERED', 'UNDELIVERABLE', 'EXPIRED', 'ENROUTE'] as MessageState[]) { test(`dlr-mask=31 round trip settles as ${status}`, async () => { await waitForSessions('main', 1); const text = `s1-${status.toLowerCase()}`; const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`; await sendsms(MAIN_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337', }); const sms = await waitForSms('main', text); assert.equal(sms.from, '46701113311'); assert.equal(sms.to, '46709771337'); assert.equal(sms.dlr, true); assert.equal((await sms.sendResp()).err, undefined); // dlr-mask bit 8: Kannel fires this off the submit_sm_resp alone, before any receipt. const submitAck = await waitForDlrCallback(sms.smsId, '8'); assert.equal(submitAck.id, sms.smsId); const report = await sms.sendDlr(status); assert.equal(report.err, undefined); // Kannel's %d for a settled message: DELIVERED 1, ENROUTE 4, UNDELIVERABLE 2 - but EXPIRED // is its own bit (34 = 32|2), not folded into the generic failure code. const finalType = status === 'DELIVERED' ? '1' : status === 'ENROUTE' ? '4' : status === 'EXPIRED' ? '34' : '2'; const final = await waitForDlrCallback(sms.smsId, finalType); assert.equal(final.id, sms.smsId); }); } }); describe('long MT from Kannel', () => { test('300-char GSM text reassembles whole', async () => { await waitForSessions('main', 1); const text = 'g'.repeat(300); await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' }); const sms = await waitForSms('main', text, 15_000); assert.equal(sms.message, text); assert.equal((await sms.sendResp()).err, undefined); }); test('UCS-2 text with δΈ€ and an emoji reassembles whole', async () => { await waitForSessions('main', 1); const text = `δΈ€πŸ˜€${'x'.repeat(140)}`; await sendsms(MAIN_SMSBOX, { charset: 'UTF-8', coding: '2', from: '46701113311', text, to: '46709771337' }); const sms = await waitForSms('main', text, 15_000); assert.equal(sms.message, text); assert.equal((await sms.sendResp()).err, undefined); }); }); describe('S11 - GSM extension characters', () => { test('€ [ ] ~ round trip through Kannel unpacked GSM7', async () => { await waitForSessions('main', 1); const text = '€[]~ok'; await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' }); const sms = await waitForSms('main', text); assert.equal(sms.message, text); assert.equal((await sms.sendResp()).err, undefined); }); }); describe('MO to Kannel', () => { test('session.sendSms() is refused by Kannel: submit_sm only flows ESME to SMSC', async () => { const [session] = await waitForSessions('main', 1); assert.ok(session); const result = await session.sendSms({ from: '46701113311', message: 'mo via sendSms', to: '46709771337' }); assert.ok(result.err); assert.match(result.err.message, /ESME_RINVCMDID/); }); test('a deliver_sm carrying a single-segment GSM message reaches the sms-service once', async () => { const [session] = await waitForSessions('main', 1); assert.ok(session); const text = 'mo single segment'; await sendMo(session, { from: '46709771337', message: text, to: '46701113311' }); const callback = await waitForMoCallback('main', text); // Two Kannel quirks, not this library's: %p prepends '+' to an international-TON address // even though the wire address carried none, and %P reports smsbox's own `global-sender` // rather than the deliver_sm's destination_addr (unset `my-number` on the smsc group). assert.equal(callback.from, '+46709771337'); assert.equal(callback.to, '46700000000'); const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? []; assert.equal(matching.length, 1); }); test('a deliver_sm split over 3 GSM segments reaches the sms-service once, whole', async () => { const [session] = await waitForSessions('main', 1); assert.ok(session); const text = 'm'.repeat(400); await sendMo(session, { from: '46709771337', message: text, to: '46701113311' }); const callback = await waitForMoCallback('main', text, 15_000); assert.equal(callback.text, text); const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? []; assert.equal(matching.length, 1); }); test('a deliver_sm split over UCS-2 segments reaches the sms-service once, whole', async () => { const [session] = await waitForSessions('main', 1); assert.ok(session); const text = `δΈ€πŸ˜€${'y'.repeat(200)}`; await sendMo(session, { from: '46709771337', message: text, to: '46701113311' }); const callback = await waitForMoCallback('main', text, 15_000); assert.equal(callback.text, text); const matching = moCallbacks.get('main')?.filter(c => c.text === text) ?? []; assert.equal(matching.length, 1); }); }); describe('S6 - wait-ack expiry and keepalive', () => { // Runs before the wait-ack test below, which deliberately provokes a disconnect/reconnect on // this same variant's session - a stable link is needed to observe the keepalive cleanly. test('enquire_link every 5s keeps a 60s-idle session from hitting idleTimeout (40s)', async () => { const [session] = await waitForSessions('main', 1); assert.ok(session); const closes: unknown[] = []; session.on('close', () => { closes.push(undefined); }); await delay(60_000); assert.deepEqual(closes, []); }); test('a submit_sm answered past wait-ack (5s) - Kannel\'s reaction is recorded, not judged', async () => { await waitForSessions('main', 1); const text = 's6-slow-resp'; const before = allSms.filter(e => e.variant === 'main').length; await sendsms(MAIN_SMSBOX, { from: '46701113311', text, to: '46709771337' }); const sms = await waitForSms('main', text); await delay(7000); await sms.sendResp().catch(() => undefined); // wait-ack-expire defaults to 0x00 (disconnect/reconnect); reconnect-delay is 1s, so give it // room to rebind and possibly resend the same submit_sm on the new session. await delay(4000); const after = allSms.filter(e => e.variant === 'main' && e.sms.message === text); assert.ok(after.length >= 1, 'the original sms is still on record'); // Recorded for findings, not asserted: whether a resend duplicated the sms is peer behaviour. void before; }); }); describe('iv33 variant - interface_version 0x33', () => { test('binds at 0x33 and negotiates no optional params', async () => { const [session] = await waitForSessions('iv33', 1); assert.ok(session); assert.equal(session.peerInterfaceVersion, 0x33); assert.equal(session.acceptsOptionalParams(), false); }); test('MT + DLR round trip still correlates with no TLVs on the receipt', async () => { const [session] = await waitForSessions('iv33', 1); assert.ok(session); assert.equal(session.acceptsOptionalParams(), false); const text = 'iv33 round trip'; const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`; await sendsms(IV33_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' }); const sms = await waitForSms('iv33', text); assert.equal((await sms.sendResp()).err, undefined); await waitForDlrCallback(sms.smsId, '8'); await sms.sendDlr('DELIVERED'); await waitForDlrCallback(sms.smsId, '1'); }); }); describe('maxp1 variant - max-pending-submits 1', () => { test('a burst of 20 sendsms calls all arrive, in order, all answered', async () => { const [session] = await waitForSessions('maxp1', 1); assert.ok(session); // max-pending-submits=1 means bearerbox holds the link to one outstanding submit_sm at a // time - answer each as it lands, or the whole burst stalls behind the first message. session.on('sms', sms => { void sms.sendResp(); }); const texts = Array.from({ length: 20 }, (_, i) => `burst-${String(i).padStart(2, '0')}`); // Sequential, not Promise.all: concurrent fetch()es reach smsbox's HTTP listener in whatever // order the OS schedules them, so only a request-then-response chain keeps send order // meaningful - max-pending-submits=1 is exercised regardless, since 20 calls in a tight loop // still outrun one-at-a-time SMPP submission. for (const text of texts) { await sendsms(MAXP1_SMSBOX, { from: '46701113311', text, to: '46709771337' }); } const arrived = await waitFor(() => { const got = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message); return texts.every(text => got.includes(text)) ? got : undefined; }, 20_000); assert.ok(arrived, 'not all 20 burst messages arrived'); const ordered = allSms.filter(e => e.variant === 'maxp1').map(e => e.sms.message).filter(m => texts.includes(m)); assert.deepEqual(ordered, texts); }); }); describe('notrx variant - separate TX and RX binds', () => { test('Kannel opens a transmitter bind and a receiver bind, both accepted', async () => { const sessions = await waitForSessions('notrx', 2); assert.deepEqual(sessions.map(s => s.boundAs).sort(), ['receiver', 'transmitter']); }); test('submit_sm from Kannel arrives on the transmitter bind, is answered, nothing refused', async () => { const sessions = await waitForSessions('notrx', 2); const tx = sessions.find(s => s.boundAs === 'transmitter'); assert.ok(tx); const text = 'notrx mt'; await sendsms(NOTRX_SMSBOX, { from: '46701113311', text, to: '46709771337' }); const sms = await waitForSms('notrx', text); assert.equal(sms.session, tx); assert.equal((await sms.sendResp()).err, undefined); }); test('a receipt built on the receiver bind reaches Kannel; the transmitter bind cannot carry one', async () => { const sessions = await waitForSessions('notrx', 2); const tx = sessions.find(s => s.boundAs === 'transmitter'); const rx = sessions.find(s => s.boundAs === 'receiver'); assert.ok(tx); assert.ok(rx); // sms.sendDlr() ties the receipt to the session the submit_sm arrived on (the TX bind), which // cannot carry deliver_sm at all (see README, Bind direction) - documented behaviour, not a // defect. A split-bind peer's receipt has to be sent on the RX session directly. // // session.send() is the library's unchecked raw passthrough (README), so this probes Kannel's // own direction enforcement, not ours: Kannel answers ESME_ROK to a deliver_sm on its // transmitter bind rather than refusing it - recorded as a peer quirk, not asserted as a spec // violation this library must guard against. const onTx = await tx.send({ cmdName: 'deliver_sm', params: { destination_addr: '46701113311', short_message: 'nope', source_addr: '46709771337' }, }); assert.equal(onTx.err, undefined); const text = 'notrx dlr target'; const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr?type=%d&answer=%A&id=%F`; await sendsms(NOTRX_SMSBOX, { 'dlr-mask': '31', 'dlr-url': dlrUrl, from: '46701113311', text, to: '46709771337' }); const sms = await waitForSms('notrx', text); assert.equal((await sms.sendResp()).err, undefined); await waitForDlrCallback(sms.smsId, '8'); const receiptDate = '2609051200'; const receiptSent = await rx.send({ cmdName: 'deliver_sm', params: { destination_addr: sms.from, esm_class: consts.ESM_CLASS.MC_DELIVERY_RECEIPT, short_message: `id:${sms.smsId} sub:001 dlvrd:001 submit date:${receiptDate} done date:${receiptDate} ` + 'stat:DELIVRD err:000 text:', source_addr: sms.to, }, }); assert.equal(receiptSent.err, undefined); assert.ok(receiptSent.pduObj); assert.equal(receiptSent.pduObj.cmdStatus, 'ESME_ROK'); await waitForDlrCallback(sms.smsId, '1'); }); test('MO built on the receiver bind reaches the sms-service', async () => { const sessions = await waitForSessions('notrx', 2); const rx = sessions.find(s => s.boundAs === 'receiver'); assert.ok(rx); const text = 'notrx mo on rx'; await sendMo(rx, { from: '46709771337', message: text, to: '46701113311' }); const callback = await waitForMoCallback('notrx', text); assert.equal(callback.text, text); }); });