Run Jasmin in both directions: message_payload, data_sm and a multipart deadlock confirmed against a production gateway
This commit is contained in:
@@ -0,0 +1,705 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import test, { after, describe } from 'node:test';
|
||||
import type { Dlr } from '../src/dlr.ts';
|
||||
import type { EncodingName } from '../src/defs/encodings.ts';
|
||||
import type { PduObject } from '../src/pdu.ts';
|
||||
import type { Session } from '../src/session.ts';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import { ConcatReference } from '../src/udh.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { closeAfter } from '../test/teardown.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';
|
||||
|
||||
const PEER_HOST = process.env.PEER_HOST ?? 'jasmin';
|
||||
const PEER_PORT = Number(process.env.PEER_PORT ?? '2775');
|
||||
const DATASM_HOST = process.env.DATASM_HOST ?? 'jasmin-datasm';
|
||||
const HTTP_API_HOST = process.env.HTTP_API_HOST ?? 'jasmin';
|
||||
const HTTP_API_PORT = Number(process.env.HTTP_API_PORT ?? '1401');
|
||||
const UPSTREAM_PORT = Number(process.env.UPSTREAM_PORT ?? '2777');
|
||||
const CALLBACK_PORT = Number(process.env.CALLBACK_PORT ?? '8080');
|
||||
|
||||
const USERNAME = 'esme1';
|
||||
const PASSWORD = 'esme1pw';
|
||||
const THROTTLED_USERNAME = 'esme2';
|
||||
const THROTTLED_PASSWORD = 'esme2pw';
|
||||
const UPSTREAM_USERNAME = 'upstreamesme';
|
||||
const UPSTREAM_DATASM_USERNAME = 'upstreamdsesme';
|
||||
const FROM = '46701113311';
|
||||
const TO = '46709771337';
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
/** Polls until `get()` stops returning undefined, or the budget runs out. */
|
||||
async function waitFor<T>(get: () => T | undefined, budget = 8000): Promise<T | undefined> {
|
||||
const deadline = Date.now() + budget;
|
||||
let value = get();
|
||||
|
||||
while (value === undefined && Date.now() < deadline) {
|
||||
await delay(20);
|
||||
value = get();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
async function bind(username: string, password: string, options: Parameters<typeof client>[0] = {}): ReturnType<typeof client> {
|
||||
return client({ host: PEER_HOST, password, port: PEER_PORT, username, ...options });
|
||||
}
|
||||
|
||||
// --- Shared infra: the fake upstream real-world SMSC that Jasmin's own smppc connector(s) bind
|
||||
// out to (host `node`, port UPSTREAM_PORT - Jasmin resolves `node` via --use-aliases), and the HTTP
|
||||
// listener that receives Jasmin's DLR-thrower webhook (S7). Both are wired once for the whole file,
|
||||
// mirroring kannel.test.ts's shared-infra shape - every Jasmin variant dials in from container
|
||||
// start, independent of when a given test runs. ---
|
||||
|
||||
type UpstreamVariant = 'datasm' | 'main';
|
||||
|
||||
const upstreamSessions = new Map<UpstreamVariant, Session>();
|
||||
const upstreamSms: { sms: Sms; variant: UpstreamVariant }[] = [];
|
||||
|
||||
function variantFromSystemId(systemId: string): UpstreamVariant | undefined {
|
||||
if (systemId === UPSTREAM_USERNAME) return 'main';
|
||||
if (systemId === UPSTREAM_DATASM_USERNAME) return 'datasm';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { err: upstreamErr, server: upstream } = await server({
|
||||
authenticate: ({ password, systemId }) => {
|
||||
// <=8 chars: Jasmin's own bind-PDU encoder enforces SMPP's 8-char password maximum strictly
|
||||
// (see bootstrap.py) - a longer one made every single connector bind attempt throw.
|
||||
if (password !== 'upstrmpw') return false;
|
||||
|
||||
const variant = variantFromSystemId(systemId);
|
||||
|
||||
return variant ? { userData: { variant } } : false;
|
||||
},
|
||||
// Jasmin's connector sends enquire_link every elink_interval (30s); this host's own contention
|
||||
// (rabbitmq/redis under load) sometimes delays it past our server's 40s default idleTimeout,
|
||||
// dropping the one long-lived connector session. A dropped connection strands whatever submit_sm
|
||||
// was already in flight for Jasmin's own requeue_delay (120s default) before it retries - far past
|
||||
// any per-test wait budget here - so this is generous specifically to never be the trigger.
|
||||
idleTimeout: 300_000,
|
||||
port: UPSTREAM_PORT,
|
||||
});
|
||||
|
||||
assert.equal(upstreamErr, undefined);
|
||||
assert.ok(upstream);
|
||||
|
||||
const upstreamServer = upstream;
|
||||
|
||||
upstreamServer.on('session', session => {
|
||||
// `session` fires on raw connect, before authenticate() has run - session.userData is not set
|
||||
// yet, so the map is populated off the bind PDU itself (like kannel.test.ts's bindPdus), not off
|
||||
// userData; userData is only read later, from 'sms', where authenticate() has long since run.
|
||||
session.on('incomingPduObj', pduObj => {
|
||||
if (!pduObj.cmdName.startsWith('bind_')) return;
|
||||
|
||||
const variant = variantFromSystemId(paramText(pduObj.params.system_id));
|
||||
|
||||
if (variant) upstreamSessions.set(variant, session);
|
||||
});
|
||||
|
||||
session.on('sms', sms => {
|
||||
const variant = (session.userData as { variant?: UpstreamVariant } | undefined)?.variant;
|
||||
|
||||
if (variant) upstreamSms.push({ sms, variant });
|
||||
|
||||
void (async () => {
|
||||
await sms.sendResp();
|
||||
|
||||
if (sms.dlr) {
|
||||
await delay(150);
|
||||
await sms.sendDlr('DELIVERED');
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForUpstreamSession(variant: UpstreamVariant, budget = 20_000): Promise<Session> {
|
||||
const found = await waitFor(() => upstreamSessions.get(variant), budget);
|
||||
|
||||
assert.ok(found, `Jasmin's ${variant} connector never bound to our fake upstream within ${String(budget)}ms`);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
type DlrCallback = { id: string; messageStatus: string };
|
||||
|
||||
const dlrCallbacks: DlrCallback[] = [];
|
||||
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://node');
|
||||
|
||||
if (url.pathname === '/dlr') {
|
||||
dlrCallbacks.push({
|
||||
id: url.searchParams.get('id') ?? '',
|
||||
messageStatus: url.searchParams.get('message_status') ?? '',
|
||||
});
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => { httpServer.listen(CALLBACK_PORT, resolve); });
|
||||
|
||||
after(async () => {
|
||||
await upstreamServer.close();
|
||||
await new Promise<void>(resolve => { httpServer.close(() => { resolve(); }); });
|
||||
});
|
||||
|
||||
/** dlr-level 1 (SMSC-ack) fires once, immediately, with message_status ESME_ROK - not a terminal
|
||||
* state - so a caller after the final DELIVRD needs dlr-level 3 (both) and its own status filter. */
|
||||
async function waitForDlrCallback(id: string, status: string, budget = 15_000): Promise<DlrCallback> {
|
||||
const found = await waitFor(() => dlrCallbacks.find(callback => callback.id === id && callback.messageStatus === status), budget);
|
||||
|
||||
assert.ok(found, `no dlr callback for id=${id} status=${status} arrived (seen: ${JSON.stringify(dlrCallbacks)})`);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Jasmin's HTTP send API (`/send`): `to` must be digits only, `content` is the message body. */
|
||||
async function httpSend(params: Record<string, string>): Promise<{ body: string; status: number }> {
|
||||
const url = new URL(`http://${HTTP_API_HOST}:${String(HTTP_API_PORT)}/send`);
|
||||
|
||||
url.search = new URLSearchParams({ password: PASSWORD, username: USERNAME, ...params }).toString();
|
||||
|
||||
const response = await fetch(url, { method: 'POST' });
|
||||
const body = await response.text();
|
||||
|
||||
return { body, status: response.status };
|
||||
}
|
||||
|
||||
const sarReference = new ConcatReference();
|
||||
|
||||
/** Splits into SAR segments: `splitMessage()`'s own encoding/chunking, its UDH stripped back off. */
|
||||
function sarPayloads(message: string, encoding?: EncodingName): Buffer[] {
|
||||
const reference = sarReference.next();
|
||||
const segments = splitMessage(message, encoding === undefined ? { reference } : { encoding, reference });
|
||||
|
||||
return segments.length > 1 ? segments.map(segment => segment.subarray(6)) : segments;
|
||||
}
|
||||
|
||||
/** Pushes a long MO into Jasmin over `session` (Jasmin's smppc connector bound to us) as
|
||||
* `sar_msg_ref_num`/`sar_total_segments`/`sar_segment_seqnum` segments - Jasmin's own documented
|
||||
* default MT segmentation (target 3). */
|
||||
async function sendSarMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
|
||||
const payloads = sarPayloads(opts.message);
|
||||
const refNum = sarReference.next();
|
||||
|
||||
for (const [index, payload] of payloads.entries()) {
|
||||
const sent = await session.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: opts.to,
|
||||
short_message: payload,
|
||||
source_addr: opts.from,
|
||||
},
|
||||
tlvs: {
|
||||
sar_msg_ref_num: { tagValue: refNum },
|
||||
sar_segment_seqnum: { tagValue: index + 1 },
|
||||
sar_total_segments: { tagValue: payloads.length },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
assert.ok(sent.pduObj);
|
||||
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
||||
}
|
||||
}
|
||||
|
||||
/** As `sendSarMo`, but UDH concatenation (esm_class 0x40) - the same shape `sendSms()` emits, and
|
||||
* Jasmin's documented "older system compatibility" alternative. */
|
||||
async function sendUdhMo(session: Session, opts: { from: string; message: string; to: string }): Promise<void> {
|
||||
const reference = sarReference.next();
|
||||
const segments = splitMessage(opts.message, { 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: 'ASCII', 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');
|
||||
}
|
||||
}
|
||||
|
||||
/** A `deliver_sm` with `sm_length` 0 and the body in `message_payload` (target 2). */
|
||||
async function sendMessagePayloadMo(session: Session, opts: { from: string; message: string; to: string }): ReturnType<Session['send']> {
|
||||
return session.send({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: opts.to,
|
||||
short_message: Buffer.alloc(0),
|
||||
source_addr: opts.from,
|
||||
},
|
||||
tlvs: {
|
||||
message_payload: { tagValue: Buffer.from(opts.message, 'latin1') },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('C1 - bind, enquire_link, unbind', () => {
|
||||
test('binds transceiver, sees Jasmin\'s own enquire_link, unbinds clean', async () => {
|
||||
// Jasmin's own enquireLinkTimerSecs (30) is an idle timer, not a strict period: our client's
|
||||
// own default 20s keepalive counts as activity and resets it, so Jasmin's probe never has a
|
||||
// chance to fire on its own. Disabling ours (and widening idleTimeout, which defaults off
|
||||
// enquireLinkInterval and would otherwise become 0) leaves the link quiet long enough to see it.
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { enquireLinkInterval: 0, idleTimeout: 60_000 });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
const incoming: PduObject[] = [];
|
||||
const closes: true[] = [];
|
||||
const sessionErrors: Error[] = [];
|
||||
|
||||
session.on('incomingPduObj', pduObj => { incoming.push(pduObj); });
|
||||
session.on('close', () => { closes.push(true); });
|
||||
session.on('sessionError', sessionError => { sessionErrors.push(sessionError); });
|
||||
|
||||
// enquireLinkTimerSecs is 30 in Jasmin's default [smpp-server] config.
|
||||
const theirs = await waitFor(() => incoming.find(pduObj => pduObj.cmdName === 'enquire_link'), 35_000);
|
||||
|
||||
assert.ok(theirs, 'expected Jasmin to send its own enquire_link within 35s');
|
||||
|
||||
const ours = await session.send({ cmdName: 'enquire_link' });
|
||||
|
||||
assert.equal(ours.err, undefined);
|
||||
assert.ok(ours.pduObj);
|
||||
assert.equal(ours.pduObj.cmdStatus, 'ESME_ROK');
|
||||
|
||||
const unbound = await session.unbind();
|
||||
|
||||
assert.equal(unbound.err, undefined);
|
||||
assert.ok(await waitFor(() => (closes.length > 0 ? true : undefined), 5000), 'expected a clean close after unbind');
|
||||
assert.deepEqual(sessionErrors, []);
|
||||
});
|
||||
|
||||
test('binds transmitter', async t => {
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'transmitter' });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
assert.equal(session.boundAs, 'transmitter');
|
||||
});
|
||||
|
||||
test('binds receiver', async t => {
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
assert.equal(session.boundAs, 'receiver');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C13 - maxOutstanding 1 with 10 parallel sends', () => {
|
||||
test('every send is answered, none lost, order preserved at the fake upstream', async t => {
|
||||
await waitForUpstreamSession('main');
|
||||
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { maxOutstanding: 1 });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
|
||||
const before = upstreamSms.length;
|
||||
const texts = Array.from({ length: 10 }, (_, i) => `c13-order-${String(i).padStart(2, '0')}`);
|
||||
|
||||
const results = await Promise.all(texts.map(async message => session.sendSms({ from: FROM, message, to: TO })));
|
||||
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
assert.equal(result.err, undefined);
|
||||
assert.equal(result.smsIds.length, 1);
|
||||
|
||||
const [smsId] = result.smsIds;
|
||||
|
||||
assert.ok(smsId);
|
||||
ids.push(smsId);
|
||||
}
|
||||
|
||||
assert.equal(new Set(ids).size, ids.length, 'expected 10 distinct message ids, none lost or duplicated');
|
||||
|
||||
const arrivedOrder = await waitFor(() => {
|
||||
const seen = upstreamSms.slice(before).filter(e => e.variant === 'main').map(e => e.sms.message);
|
||||
|
||||
return texts.every(text => seen.includes(text)) ? seen : undefined;
|
||||
}, 60_000);
|
||||
|
||||
assert.ok(arrivedOrder, 'not all 10 messages reached the fake upstream');
|
||||
|
||||
const ordered = arrivedOrder.filter(m => texts.includes(m));
|
||||
|
||||
assert.deepEqual(ordered, texts, 'maxOutstanding:1 should serialise sends end to end, in order');
|
||||
});
|
||||
});
|
||||
|
||||
describe('S7 - Jasmin as the ESME against our server (HTTP send API, DLR callback)', () => {
|
||||
test('a message pushed through /send arrives as submit_sm at our server; our receipt fires Jasmin\'s DLR webhook', async () => {
|
||||
const before = upstreamSms.length;
|
||||
// No printf-style placeholders (unlike Kannel's %d/%F): DLRThrower appends its own fixed
|
||||
// query args - id, level, message_status, connector - to this bare URL (confirmed from
|
||||
// jasmin/routing/throwers.py). dlr-method=get puts them in the query string; POST (Jasmin's
|
||||
// own default) would need a form-body reader instead.
|
||||
const dlrUrl = `http://node:${String(CALLBACK_PORT)}/dlr`;
|
||||
|
||||
const sent = await httpSend({
|
||||
content: 's7 http send test',
|
||||
dlr: 'yes',
|
||||
// Level 3 (both): level 1 alone fires once, immediately, with message_status ESME_ROK -
|
||||
// the SMSC-ack, not a terminal state - so proving Jasmin parses our own receipt needs the
|
||||
// terminal-level callback too, which only follows the sendDlr('DELIVERED') below.
|
||||
'dlr-level': '3',
|
||||
'dlr-method': 'get',
|
||||
'dlr-url': dlrUrl,
|
||||
from: FROM,
|
||||
to: TO,
|
||||
});
|
||||
|
||||
assert.match(sent.body, /Success/i);
|
||||
|
||||
const arrived = await waitFor(() => upstreamSms.slice(before).find(e => e.variant === 'main' && e.sms.message === 's7 http send test'), 60_000);
|
||||
|
||||
assert.ok(arrived, 'expected the HTTP-submitted message to arrive as submit_sm at our server()');
|
||||
|
||||
// Our server() already answered (sendResp) and sent the DLR (sendDlr('DELIVERED')) from the
|
||||
// shared session handler above - Jasmin's own DLR pipeline should throw the HTTP callback.
|
||||
const msgidMatch = /Success "([^"]+)"/i.exec(sent.body);
|
||||
const msgid = msgidMatch?.[1];
|
||||
|
||||
assert.ok(msgid, `expected /send's response to carry a message id: ${sent.body}`);
|
||||
|
||||
const ack = await waitForDlrCallback(msgid, 'ESME_ROK', 15_000);
|
||||
|
||||
assert.equal(ack.id, msgid);
|
||||
|
||||
const final = await waitForDlrCallback(msgid, 'DELIVRD', 15_000);
|
||||
|
||||
assert.equal(final.id, msgid);
|
||||
});
|
||||
|
||||
test('a long GSM message from our server reassembles at Jasmin (or is recorded as fragments)', async () => {
|
||||
const upstreamSession = await waitForUpstreamSession('main');
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
const sms: Sms[] = [];
|
||||
|
||||
session.on('sms', s => { sms.push(s); });
|
||||
|
||||
const text = `s7-long-${'p'.repeat(300)}`;
|
||||
|
||||
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||
|
||||
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||
|
||||
await session.close({ signal: AbortSignal.abort() });
|
||||
assert.ok(whole ?? sms.length > 0, 'expected the long message to arrive whole or as recorded fragments');
|
||||
});
|
||||
|
||||
test('a UCS-2 message with 一 and an emoji from our server (or is recorded as fragments)', async () => {
|
||||
const upstreamSession = await waitForUpstreamSession('main');
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
const sms: Sms[] = [];
|
||||
|
||||
session.on('sms', s => { sms.push(s); });
|
||||
|
||||
const text = `一😀${'q'.repeat(60)}`;
|
||||
|
||||
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||
|
||||
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||
|
||||
await session.close({ signal: AbortSignal.abort() });
|
||||
assert.ok(whole ?? sms.length > 0, 'expected the UCS-2 message to arrive whole or as recorded fragments');
|
||||
});
|
||||
});
|
||||
describe('C3+C7 - long MT through the fake upstream, receipts and id consistency', () => {
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(-\d+)?$/i;
|
||||
|
||||
const cases: { encoding?: 'UCS2'; expectedSegments: number; label: string; message: string }[] = [
|
||||
{ expectedSegments: 1, label: 'single-segment GSM with extension chars', message: '€[]~single segment' },
|
||||
{ expectedSegments: 2, label: '2-segment GSM with extension chars', message: `€[]~${'g'.repeat(200)}` },
|
||||
{ expectedSegments: 3, label: '3-segment GSM with extension chars', message: `€[]~${'g'.repeat(400)}` },
|
||||
{ expectedSegments: 10, label: '10-segment GSM with extension chars', message: `€[]~${'g'.repeat(1450)}` },
|
||||
{ encoding: 'UCS2', expectedSegments: 2, label: '2-segment UCS2 with 一 and an emoji', message: `一😀${'x'.repeat(70)}` },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
test(testCase.label, async t => {
|
||||
await waitForUpstreamSession('main');
|
||||
|
||||
const { err, session } = await bind(USERNAME, PASSWORD);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
|
||||
const dlrs: { dlr: Dlr; pduObj: PduObject }[] = [];
|
||||
const messageDlrs: unknown[] = [];
|
||||
|
||||
session.on('dlr', (dlr, pduObj) => { dlrs.push({ dlr, pduObj }); });
|
||||
session.on('messageDlr', merged => { messageDlrs.push(merged); });
|
||||
|
||||
const sent = await session.sendSms({
|
||||
dlr: true,
|
||||
from: FROM,
|
||||
message: testCase.message,
|
||||
to: TO,
|
||||
...(testCase.encoding ? { encoding: testCase.encoding } : {}),
|
||||
});
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
assert.equal(sent.smsIds.length, testCase.expectedSegments);
|
||||
|
||||
for (const id of sent.smsIds) {
|
||||
assert.match(id, uuidPattern, 'expected a UUID-shaped message id from Jasmin\'s submit_sm_resp');
|
||||
|
||||
// The full round trip - submit_sm to Jasmin's smpps, mtrouter, AMQP, the connector bind,
|
||||
// our fake upstream's ack+DLR, AMQP again, DLRLookup, deliver_sm back - is slower than a
|
||||
// single-segment send's, and visibly so under load; a generous budget beats a flaky one.
|
||||
const received = await waitFor(() => dlrs.find(r => r.dlr.smsId === id), 25_000);
|
||||
|
||||
assert.ok(received, `no receipt for id ${id}`);
|
||||
assert.equal(received.dlr.statusMsg, 'DELIVERED');
|
||||
}
|
||||
|
||||
// Jasmin relays each segment as its own independent submit_sm to the connector (no MT-side
|
||||
// UDH reassembly observed here - see findings), so the ids Jasmin hands back are whatever
|
||||
// the fake upstream's own server() assigned per segment of ITS OWN reassembled view. That
|
||||
// is this library's own <base>-<n> convention on both ends of this harness, which is why
|
||||
// messageDlr can fire here - not evidence of Jasmin producing that convention itself; see
|
||||
// findings/03-jasmin.md for what a real, independent upstream SMSC would hand back instead.
|
||||
void messageDlrs;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('C8 (target 3) - long MO from an upstream SMSC, SAR vs UDH segmentation', () => {
|
||||
test('SAR-segmented deliver_sm from the fake upstream', async () => {
|
||||
const upstreamSession = await waitForUpstreamSession('main');
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
const sms: Sms[] = [];
|
||||
|
||||
session.on('sms', s => { sms.push(s); });
|
||||
|
||||
const text = `sar-mo-${'m'.repeat(300)}`;
|
||||
|
||||
await sendSarMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||
|
||||
// Recorded either way, per the task: one whole `sms` (Jasmin reassembled the SAR segments
|
||||
// before forwarding) or several fragments (it relayed them, and our SAR-blind reassembler -
|
||||
// keyed on UDH only - never groups them; see findings for which happened and the reproducer).
|
||||
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||
const fragments = sms.filter(s => text.includes(s.message) && s.message !== '');
|
||||
|
||||
await session.close({ signal: AbortSignal.abort() });
|
||||
|
||||
assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or SAR fragments to arrive');
|
||||
});
|
||||
|
||||
test('UDH-segmented deliver_sm from the fake upstream', async () => {
|
||||
const upstreamSession = await waitForUpstreamSession('main');
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
const sms: Sms[] = [];
|
||||
|
||||
session.on('sms', s => { sms.push(s); });
|
||||
|
||||
const text = `udh-mo-${'n'.repeat(300)}`;
|
||||
|
||||
await sendUdhMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||
|
||||
const whole = await waitFor(() => sms.find(s => s.message === text), 10_000);
|
||||
const fragments = sms.filter(s => text.includes(s.message) && s.message !== '');
|
||||
|
||||
await session.close({ signal: AbortSignal.abort() });
|
||||
|
||||
assert.ok(whole ?? fragments.length > 0, 'expected either a reassembled sms or UDH fragments to arrive');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C8 (target 2) - message_payload with sm_length 0', () => {
|
||||
test('a deliver_sm carrying message_payload instead of short_message', async () => {
|
||||
const upstreamSession = await waitForUpstreamSession('main');
|
||||
const { err, session } = await bind(USERNAME, PASSWORD, { bindType: 'receiver' });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
const sms: Sms[] = [];
|
||||
|
||||
session.on('sms', s => { sms.push(s); });
|
||||
|
||||
const text = 'message-payload only, sm_length 0';
|
||||
const pushed = await sendMessagePayloadMo(upstreamSession, { from: TO, message: text, to: FROM });
|
||||
|
||||
assert.equal(pushed.err, undefined, 'expected Jasmin to accept a message_payload-only deliver_sm from its connector');
|
||||
|
||||
const arrived = await waitFor(() => sms.find(s => s.message === ''), 5000);
|
||||
|
||||
await session.close({ signal: AbortSignal.abort() });
|
||||
|
||||
// Confirmed (target 2): Jasmin relays message_payload faithfully (sm_length 0, the real text
|
||||
// in the TLV) - our own incoming-requests.ts reads short_message only, so the sms that arrives
|
||||
// here has the right envelope (from/to) but an empty message, never the text carried in the
|
||||
// TLV. See findings/03-jasmin.md for the reproducer.
|
||||
assert.ok(arrived, 'expected an sms to arrive (with an empty message, per target 2) for the message_payload push');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C9 (target 4) - DLR as data_sm against the jasmin-datasm instance', () => {
|
||||
test('a receipt thrown as data_sm is not read as a dlr; the raw PDU still arrives', async t => {
|
||||
await waitForUpstreamSession('datasm');
|
||||
|
||||
const { err, session } = await client({ host: DATASM_HOST, password: PASSWORD, port: PEER_PORT, username: USERNAME });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
|
||||
const dlrs: Dlr[] = [];
|
||||
const incomingDataSm: PduObject[] = [];
|
||||
|
||||
session.on('dlr', dlr => { dlrs.push(dlr); });
|
||||
session.on('incomingPduObj', pduObj => { if (pduObj.cmdName === 'data_sm') incomingDataSm.push(pduObj); });
|
||||
|
||||
const sent = await session.sendSms({ dlr: true, from: FROM, message: 'data_sm dlr test', to: TO });
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
|
||||
const arrived = await waitFor(() => incomingDataSm[0], 15_000);
|
||||
|
||||
assert.ok(arrived, 'expected Jasmin to throw the receipt as data_sm (dlr_pdu = data_sm)');
|
||||
|
||||
await delay(2000);
|
||||
assert.deepEqual(dlrs, [], 'data_sm is answered ESME_RINVCMDID and never reaches the dlr event (target 4)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C11 - bind refusal and reconnect backoff', () => {
|
||||
test('wrong password: one attempt, no retry', async () => {
|
||||
const refusals: { cmdStatus: unknown }[] = [];
|
||||
const log = {
|
||||
debug: () => undefined,
|
||||
error: () => undefined,
|
||||
info: (msg: string, metadata?: Record<string, boolean | number | string>) => {
|
||||
if (msg === 'client - bind refused') refusals.push({ cmdStatus: metadata?.cmdStatus });
|
||||
},
|
||||
verbose: () => undefined,
|
||||
warn: () => undefined,
|
||||
};
|
||||
|
||||
const { err, session } = await bind(USERNAME, 'wrong-password', { log, reconnect: { maxDelay: 4000, minDelay: 1000 } });
|
||||
|
||||
assert.ok(err);
|
||||
assert.equal(session, undefined);
|
||||
await delay(2000);
|
||||
assert.equal(refusals.length, 1, 'expected exactly one bind attempt, never a retry');
|
||||
assert.equal(refusals[0]?.cmdStatus, 'ESME_RINVPASWD');
|
||||
});
|
||||
|
||||
test('a rebind refused after a live link drops: backs off, never floods', async t => {
|
||||
const options: Parameters<typeof client>[0] = {
|
||||
host: PEER_HOST,
|
||||
password: PASSWORD,
|
||||
port: PEER_PORT,
|
||||
reconnect: { maxDelay: 4000, minDelay: 1000 },
|
||||
username: USERNAME,
|
||||
};
|
||||
const { err, session } = await client(options);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
|
||||
const disconnectedAt: number[] = [];
|
||||
|
||||
session.on('disconnected', () => { disconnectedAt.push(Date.now()); });
|
||||
options.password = 'wrong-after-drop';
|
||||
session.sock.destroy();
|
||||
|
||||
await delay(12_000);
|
||||
|
||||
assert.ok(disconnectedAt.length >= 2 && disconnectedAt.length <= 8, `expected a handful of attempts, got ${String(disconnectedAt.length)}`);
|
||||
|
||||
for (let i = 1; i < disconnectedAt.length; i++) {
|
||||
const previous = disconnectedAt[i - 1];
|
||||
const current = disconnectedAt[i];
|
||||
|
||||
assert.ok(previous !== undefined && current !== undefined);
|
||||
assert.ok(current - previous >= 150, 'expected each retry to wait at least close to minDelay');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('C12 - throttling (esme2\'s smpps_throughput quota)', () => {
|
||||
test('flooding submits past the quota gets an err naming the status; the session stays bound; a later send works', async t => {
|
||||
await waitForUpstreamSession('main');
|
||||
|
||||
const { err, session } = await bind(THROTTLED_USERNAME, THROTTLED_PASSWORD, { maxOutstanding: 20 });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
closeAfter(t, session);
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 15 }, async (_unused, index) => session.sendSms({ from: FROM, message: `throttle-${String(index)}`, to: TO })),
|
||||
);
|
||||
|
||||
const refused = results.filter(r => r.err !== undefined);
|
||||
|
||||
assert.ok(refused.length > 0, 'expected the 0.1/s quota to refuse at least one of 15 parallel sends');
|
||||
assert.match(refused[0]?.err?.message ?? '', /ESME_RTHROTTLED/);
|
||||
|
||||
const keepalive = await session.send({ cmdName: 'enquire_link' });
|
||||
|
||||
assert.equal(keepalive.err, undefined);
|
||||
assert.ok(keepalive.pduObj);
|
||||
assert.equal(keepalive.pduObj.cmdStatus, 'ESME_ROK');
|
||||
|
||||
// 0.1/s is one slot every 10s, so a single fixed delay is either wasteful or flaky - polling
|
||||
// finds the next open slot instead of guessing it.
|
||||
const deadline = Date.now() + 25_000;
|
||||
let later: Awaited<ReturnType<typeof session.sendSms>> | undefined;
|
||||
|
||||
while (!later && Date.now() < deadline) {
|
||||
const attempt = await session.sendSms({ from: FROM, message: 'after the burst', to: TO });
|
||||
|
||||
if (!attempt.err) later = attempt;
|
||||
else await delay(500);
|
||||
}
|
||||
|
||||
assert.ok(later, 'expected a later send to succeed once the quota\'s next slot opened');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user