406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test, { after, describe } from 'node:test';
|
|
import type { Session } from '../src/session.ts';
|
|
import type { Sms } from '../src/sms.ts';
|
|
import { encodings } from '../src/defs/encodings.ts';
|
|
import { paramText } from '../src/defs/types.ts';
|
|
import { isCommand, server } from '../src/index.ts';
|
|
|
|
const DRIVER = process.env.PYTHON_DRIVER ?? 'python:8080';
|
|
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
|
const REFUSED_DEST = 'REFUSEME';
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise(resolve => { setTimeout(resolve, ms); });
|
|
}
|
|
|
|
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(50);
|
|
value = get();
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
type JsonBody = Record<string, unknown>;
|
|
|
|
async function post(path: string, body: JsonBody): Promise<JsonBody> {
|
|
const res = await fetch(`http://${DRIVER}${path}`, {
|
|
body: JSON.stringify(body),
|
|
headers: { 'Content-Type': 'application/json' },
|
|
method: 'POST',
|
|
});
|
|
|
|
return res.json() as Promise<JsonBody>;
|
|
}
|
|
|
|
async function get(path: string): Promise<JsonBody> {
|
|
const res = await fetch(`http://${DRIVER}${path}`);
|
|
|
|
return res.json() as Promise<JsonBody>;
|
|
}
|
|
|
|
async function bindReader(name: string, opts: Partial<JsonBody> = {}): Promise<void> {
|
|
const bound = await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, ...opts });
|
|
|
|
assert.equal(bound.ok, true, JSON.stringify(bound));
|
|
|
|
const started = await post('/startReader', { autoSendEnquireLink: true, name });
|
|
|
|
assert.equal(started.ok, true, JSON.stringify(started));
|
|
}
|
|
|
|
// --- Shared infra: one server() for the whole file, one session per named python-smpplib bind,
|
|
// tracked by the system_id the driver binds with - the same pattern as kannel.test.ts's variant. ---
|
|
|
|
const allSms: { sms: Sms; systemId: string }[] = [];
|
|
const systemIdBySession = new Map<Session, string>();
|
|
|
|
const { err: serverErr, server: smpp } = await server({
|
|
onRequest: async (session, pduObj) => {
|
|
if (!isCommand(pduObj, 'submit_sm') || pduObj.params.destination_addr !== REFUSED_DEST) return false;
|
|
|
|
await session.sendReturn(pduObj, 'ESME_RTHROTTLED');
|
|
|
|
return true;
|
|
},
|
|
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;
|
|
|
|
systemIdBySession.set(session, paramText(pduObj.params.system_id));
|
|
});
|
|
|
|
session.on('sms', sms => {
|
|
const systemId = systemIdBySession.get(session);
|
|
|
|
if (systemId) allSms.push({ sms, systemId });
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
await smppServer.close();
|
|
});
|
|
|
|
function sessionFor(systemId: string): Session | undefined {
|
|
return [...smppServer.sessions].find(s => systemIdBySession.get(s) === systemId);
|
|
}
|
|
|
|
async function waitForSession(systemId: string, budget = 8000): Promise<Session> {
|
|
const found = await waitFor(() => sessionFor(systemId), budget);
|
|
|
|
assert.ok(found, `no session bound for system_id ${systemId} within ${String(budget)}ms`);
|
|
|
|
return found;
|
|
}
|
|
|
|
async function waitForSms(systemId: string, message: string, budget = 8000): Promise<Sms> {
|
|
const found = await waitFor(
|
|
() => allSms.find(entry => entry.systemId === systemId && entry.sms.message === message)?.sms,
|
|
budget,
|
|
);
|
|
|
|
assert.ok(found, `no sms carrying ${JSON.stringify(message)} arrived for ${systemId}`);
|
|
|
|
return found;
|
|
}
|
|
|
|
type ReceivedEntry = { dataCoding: number; esmClass: number; from: string; hex: string; text: string; to: string };
|
|
|
|
async function waitForReceived(name: string, predicate: (e: ReceivedEntry) => boolean, budget = 8000): Promise<ReceivedEntry> {
|
|
const found = await waitFor(async () => {
|
|
const status = await get(`/received?name=${name}`);
|
|
const list = status.received as ReceivedEntry[];
|
|
|
|
return list.find(predicate);
|
|
}, budget);
|
|
|
|
assert.ok(found, `no matching received entry for ${name} within ${String(budget)}ms`);
|
|
|
|
return found;
|
|
}
|
|
|
|
type AckResult = { messageId?: string; status?: number };
|
|
|
|
// A single-segment submit_sm is only answered once sendResp() is called on the arrived sms, so
|
|
// /submit itself does not wait for the ack (see driver.py) - this polls for it afterwards.
|
|
async function waitForAck(name: string, sequence: number, budget = 8000): Promise<AckResult> {
|
|
const found = await waitFor(async () => {
|
|
const status = await get(`/ack?name=${name}&sequence=${String(sequence)}`);
|
|
|
|
return status.found ? status : undefined;
|
|
}, budget);
|
|
|
|
assert.ok(found, `no ack for sequence ${String(sequence)} (${name}) within ${String(budget)}ms`);
|
|
|
|
return found;
|
|
}
|
|
|
|
async function echoBack(session: Session, sms: Sms, dataCoding: number, text: string): Promise<void> {
|
|
const encName = dataCoding === 3 ? 'LATIN1' : dataCoding === 8 ? 'UCS2' : 'ASCII';
|
|
const buf = encodings[encName].encode(text);
|
|
const sent = await session.send({
|
|
cmdName: 'deliver_sm',
|
|
params: { data_coding: dataCoding, destination_addr: sms.from, short_message: buf, source_addr: sms.to },
|
|
});
|
|
|
|
assert.equal(sent.err, undefined);
|
|
assert.ok(sent.pduObj);
|
|
assert.equal(sent.pduObj.cmdStatus, 'ESME_ROK');
|
|
}
|
|
|
|
// The real GSM 03.38 basic table (128 unique chars), as this library decodes it.
|
|
const ourTable =
|
|
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
|
|
// python-smpplib's own gsm.GSM_CHARACTER_TABLE[:128] - identical except at 0x5F, a backtick where
|
|
// the real table (and this library) has a section sign.
|
|
const theirTable =
|
|
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1BÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑÜ`¿abcdefghijklmnopqrstuvwxyzäöñüà';
|
|
const ESC_INDEX = 27;
|
|
const sendBasic = theirTable.slice(0, ESC_INDEX) + theirTable.slice(ESC_INDEX + 1);
|
|
const expectBasic = ourTable.slice(0, ESC_INDEX) + ourTable.slice(ESC_INDEX + 1);
|
|
const extensionChars = '€[]{}\\|~^';
|
|
|
|
describe('S11 - encodings (python-smpplib)', () => {
|
|
test('GSM 03.38 basic table + extension table round trip (python -> server)', async () => {
|
|
await bindReader('s11-basic');
|
|
|
|
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-basic', text: sendBasic + extensionChars, to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
|
|
const sms = await waitForSms('s11-basic', expectBasic + extensionChars);
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck('s11-basic', sent.sequence as number)).status, 0);
|
|
});
|
|
|
|
test('form feed (0x1B 0x0A), which smpplib\'s own gsm_encode() cannot build, round trips raw', async () => {
|
|
await bindReader('s11-ff');
|
|
|
|
const sent = await post('/submit', { dataCoding: 0, extraHex: '1b0a', from: '46700000001', name: 's11-ff', text: 'before-', to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
|
|
const sms = await waitForSms('s11-ff', 'before-\f');
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck('s11-ff', sent.sequence as number)).status, 0);
|
|
});
|
|
|
|
test('Latin-1 round trip', async () => {
|
|
await bindReader('s11-latin1');
|
|
|
|
const text = 'café £ ñ ¿¡ © ®';
|
|
const sent = await post('/submit', { dataCoding: 3, from: '46700000001', name: 's11-latin1', text, to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
|
|
const sms = await waitForSms('s11-latin1', text);
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck('s11-latin1', sent.sequence as number)).status, 0);
|
|
});
|
|
|
|
test('UCS-2 with 一 and an emoji round trip', async () => {
|
|
await bindReader('s11-ucs2');
|
|
|
|
const text = '一😀hello';
|
|
const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-ucs2', text, to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
|
|
const sms = await waitForSms('s11-ucs2', text);
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck('s11-ucs2', sent.sequence as number)).status, 0);
|
|
});
|
|
|
|
test('reverse direction: server sends GSM text back, python decodes it the same way', async () => {
|
|
await bindReader('s11-echo-gsm');
|
|
|
|
const text = 'hello from the server';
|
|
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-echo-gsm', text: 'seed', to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
|
|
const sms = await waitForSms('s11-echo-gsm', 'seed');
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck('s11-echo-gsm', sent.sequence as number)).status, 0);
|
|
|
|
const session = sessionFor('s11-echo-gsm');
|
|
|
|
assert.ok(session);
|
|
await echoBack(session, sms, 0, text);
|
|
|
|
const received = await waitForReceived('s11-echo-gsm', e => e.text === text);
|
|
|
|
assert.equal(received.dataCoding, 0);
|
|
});
|
|
|
|
test('reverse direction: server sends UCS-2 text back, python decodes it the same way', async () => {
|
|
await bindReader('s11-echo-ucs2');
|
|
|
|
const seed = 'seed-ucs2';
|
|
const sent = await post('/submit', { dataCoding: 8, from: '46700000001', name: 's11-echo-ucs2', text: seed, to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
|
|
const sms = await waitForSms('s11-echo-ucs2', seed);
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck('s11-echo-ucs2', sent.sequence as number)).status, 0);
|
|
|
|
const session = sessionFor('s11-echo-ucs2');
|
|
|
|
assert.ok(session);
|
|
|
|
const text = '一😀back';
|
|
|
|
await echoBack(session, sms, 8, text);
|
|
|
|
const received = await waitForReceived('s11-echo-ucs2', e => e.text === text);
|
|
|
|
assert.equal(received.dataCoding, 8);
|
|
});
|
|
|
|
test('peer quirk, documented both ways: byte 0x5F is section sign here, backtick in smpplib\'s own table', async () => {
|
|
await bindReader('s11-quirk');
|
|
|
|
// python encodes a literal backtick through its own gsm_encode(), landing on byte 0x5F -
|
|
// this library decodes that byte to SECTION SIGN, the real GSM 03.38 value.
|
|
const sent = await post('/submit', { dataCoding: 0, from: '46700000001', name: 's11-quirk', text: '`', to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
|
|
const sms = await waitForSms('s11-quirk', '§');
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck('s11-quirk', sent.sequence as number)).status, 0);
|
|
|
|
// Sent back the spec-correct way (byte 0x5F again), python's own (non-standard) table reads
|
|
// the same byte back as a backtick, not a section sign - the mismatch shows up both ways.
|
|
const session = sessionFor('s11-quirk');
|
|
|
|
assert.ok(session);
|
|
await echoBack(session, sms, 0, '§');
|
|
|
|
const received = await waitForReceived('s11-quirk', e => e.dataCoding === 0);
|
|
|
|
assert.equal(received.text, '`');
|
|
});
|
|
});
|
|
|
|
describe('S2 - long messages (python-smpplib, UDH)', () => {
|
|
for (const segments of [2, 3, 10]) {
|
|
test(`${String(segments)} segments reassemble whole, each answered on arrival`, async () => {
|
|
const name = `s2-udh-${String(segments)}`;
|
|
|
|
await bindReader(name);
|
|
|
|
const text = Array.from({ length: 153 * (segments - 1) + 10 }, (_, i) => String(i % 10)).join('');
|
|
|
|
const sent = await post('/submitLong', { dataCoding: 0, from: '46700000001', name, text, to: '46700000002' });
|
|
|
|
assert.equal(sent.ok, true, JSON.stringify(sent));
|
|
assert.equal(sent.parts, segments);
|
|
|
|
const sms = await waitForSms(name, text, 15_000);
|
|
|
|
assert.equal(sms.answeredOnArrival, true);
|
|
|
|
const results = sent.results as { messageId: string; status: number }[];
|
|
|
|
assert.equal(results.length, segments);
|
|
|
|
for (const [i, result] of results.entries()) {
|
|
assert.equal(result.status, 0);
|
|
assert.equal(result.messageId, `${sms.smsId}-${String(i + 1)}`);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe('Keepalive - python-smpplib\'s reactive enquire_link', () => {
|
|
test('idle past idleTimeout (40s) with no keepalive: our server drops the session', async () => {
|
|
const name = 'keepalive-none';
|
|
|
|
await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 50 });
|
|
|
|
const session = await waitForSession(name);
|
|
const closed: unknown[] = [];
|
|
|
|
session.on('close', () => { closed.push(undefined); });
|
|
|
|
const idle = await post('/idleSilent', { name, seconds: 45 });
|
|
|
|
assert.equal(idle.ok, true, JSON.stringify(idle));
|
|
assert.equal(idle.closed, true);
|
|
assert.deepEqual(closed, [undefined]);
|
|
});
|
|
|
|
test('idle past idleTimeout (40s) with auto_send_enquire_link: the link survives', async () => {
|
|
const name = 'keepalive-auto';
|
|
|
|
await post('/bind', { mode: 'transceiver', name, password: 'pw', systemId: name, timeoutSecs: 10 });
|
|
await waitForSession(name);
|
|
await post('/startReader', { autoSendEnquireLink: true, name });
|
|
|
|
const closed: unknown[] = [];
|
|
const session = sessionFor(name);
|
|
|
|
assert.ok(session);
|
|
session.on('close', () => { closed.push(undefined); });
|
|
|
|
await delay(45_000);
|
|
|
|
const status = await get(`/status?name=${name}`);
|
|
|
|
assert.equal(status.readerRunning, true);
|
|
assert.equal(status.readerError, null);
|
|
assert.deepEqual(closed, []);
|
|
assert.ok(sessionFor(name), 'session should still be bound');
|
|
});
|
|
});
|
|
|
|
describe('Refusals via onRequest', () => {
|
|
test('a refusing status through onRequest is surfaced to python-smpplib, not a hang or close', async () => {
|
|
const name = 'refusal';
|
|
|
|
await bindReader(name);
|
|
|
|
// Refused through onRequest, before reassembly and before the sms event, so this is answered
|
|
// without any sendResp() call - unlike every other submit in this file.
|
|
const refused = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'nope', to: REFUSED_DEST });
|
|
|
|
assert.equal(refused.ok, true, JSON.stringify(refused));
|
|
assert.equal((await waitForAck(name, refused.sequence as number)).status, 0x58); // ESME_RTHROTTLED
|
|
|
|
const link = await post('/enquireLink', { name });
|
|
|
|
assert.equal(link.ok, true);
|
|
|
|
const after1 = await post('/submit', { dataCoding: 0, from: '46700000001', name, text: 'still works', to: '46700000002' });
|
|
|
|
assert.equal(after1.ok, true, JSON.stringify(after1));
|
|
|
|
const sms = await waitForSms(name, 'still works');
|
|
|
|
assert.equal((await sms.sendResp()).err, undefined);
|
|
assert.equal((await waitForAck(name, after1.sequence as number)).status, 0);
|
|
});
|
|
});
|