Declare the alphabet a GSM message is actually written in (#100)

* Regression tests for the alphabet a GSM message declares

* Declare the alphabet a GSM message is actually written in

* Reflow the composing-by-hand paragraph

* Drop the ASCII alias SMPP's flat table shares with the option's own

* Bound the receipt test's wait and tighten the notes around it
This commit is contained in:
2026-09-09 21:30:55 +02:00
committed by GitHub
parent 989eb01763
commit ca1a7473ed
15 changed files with 275 additions and 33 deletions
+195
View File
@@ -0,0 +1,195 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import { bindToSmsc, dummySmsc } from './dummy-smsc.ts';
import { client } from '../src/client.ts';
import { closeAfter } from './teardown.ts';
import { consts } from '../src/defs/constants.ts';
import { decodeMessage } from '../src/message.ts';
import { dlrFromPdu } from '../src/dlr.ts';
import { encodingByDataCoding, encodings } from '../src/defs/encodings.ts';
import { objToPdu, pduToObj } from '../src/pdu.ts';
import { paramNumber } from '../src/defs/types.ts';
import { server } from '../src/server.ts';
import type { PduObject } from '../src/pdu.ts';
const from = '46701113311';
const to = '46709771337';
/** GSM 03.38 puts $ at 0x02 and @ at 0x00, where IA5 has STX and NUL. */
const bothTables = 'Cost 5$ @home';
/** What SMPP 3.4 5.2.19 assigns each coding, read the way a peer honouring the field reads it. */
const byTheSpecsTable: Record<number, (octets: Buffer) => string> = {
// The SMSC default alphabet, which every peer in interop-tests/ runs as GSM 03.38.
0x00: octets => encodings.ASCII.decode(octets),
// IA5 (CCITT T.50), whose whole range is what Latin-1 reads below 0x80.
[consts.ENCODING.IA5]: octets => octets.toString('latin1'),
};
/** An event that never fires would otherwise block until the CI job limit, asserting nothing. */
function once<T>(register: (resolve: (value: T) => void) => void): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('waited 5000 ms for an event that never fired'));
}, 5000);
register(value => {
clearTimeout(timer);
resolve(value);
});
});
}
function submitted(octets: Buffer[]): PduObject[] {
return octets.map(pdu => {
const { pduObj } = pduToObj(pdu);
assert.ok(pduObj);
return pduObj;
});
}
function declaredBy(pduObj: PduObject): number {
return paramNumber(pduObj.params.data_coding, 0);
}
/** A deliver_sm carrying `body` under `dataCoding`, as a peer answering our own send would write it. */
function delivered(body: Buffer | string, dataCoding: number, esmClass: number): PduObject {
const { buffer } = objToPdu({
cmdName: 'deliver_sm',
params: {
data_coding: dataCoding,
destination_addr: from,
esm_class: esmClass,
short_message: body,
source_addr: to,
},
seqNr: 1,
});
assert.ok(buffer);
const { pduObj } = pduToObj(buffer);
assert.ok(pduObj);
return pduObj;
}
describe('the alphabet a message declares is the one its octets are written in', () => {
test('sends GSM 03.38 under data_coding 0x00, the SMSC default alphabet', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de97-7caa-9d26-e6d50f5c4888'] });
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
const sent = await session.sendSms({ from, message: bothTables, to });
assert.equal(sent.err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x00]);
});
test('keeps $ and @ for a peer that honours the declaration, where IA5 read STX and NUL', async t => {
const smsc = await dummySmsc(t, { messageIds: ['01a08779-de98-7d24-9542-e652e0d3761c'] });
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
assert.equal((await session.sendSms({ from, message: bothTables, to })).err, undefined);
const [pduObj] = submitted(smsc.octets);
assert.ok(pduObj);
const octets = pduObj.shortMessageOctets;
assert.ok(octets);
assert.equal(octets.toString('hex'), '436f73742035022000686f6d65');
const read = byTheSpecsTable[declaredBy(pduObj)];
assert.ok(read, 'the coding declared must be one SMPP 3.4 5.2.19 names an alphabet for');
assert.equal(read(octets), bothTables);
});
test('leaves Latin-1 at 0x03 and UCS2 at 0x08, the codings those alphabets always had', async t => {
const smsc = await dummySmsc(t, {
messageIds: ['01a08779-de99-7fa5-bcac-18feef55aeee', '01a08779-de99-72ec-8dbb-9365a00158c3'],
});
const session = await bindToSmsc(t, smsc.port, { reconnect: false });
assert.equal((await session.sendSms({ encoding: 'LATIN1', from, message: 'Räksmörgås', to })).err, undefined);
assert.equal((await session.sendSms({ from, message: 'あいう', to })).err, undefined);
assert.deepEqual(submitted(smsc.octets).map(declaredBy), [0x03, 0x08]);
});
// sendDlr() writes its body as a string with no data_coding, so it takes the detected branch too.
test('declares 0x00 on a receipt it writes itself', async t => {
const { err, server: smpp } = await server({ port: 0 });
assert.equal(err, undefined);
assert.ok(smpp);
closeAfter(t, smpp);
smpp.on('session', peer => peer.on('sms', async sms => {
await sms.sendResp();
await sms.sendDlr('DELIVERED');
}));
const connected = await client({ port: smpp.port, reconnect: false });
assert.equal(connected.err, undefined);
assert.ok(connected.session);
closeAfter(t, connected.session);
const session = connected.session;
const reported = once<PduObject>(resolve => {
session.on('dlr', (_report, pduObj) => { resolve(pduObj); });
});
assert.equal((await session.sendSms({ dlr: true, from, message: bothTables, to })).err, undefined);
assert.equal(declaredBy(await reported), 0x00);
});
// The low-level surface settles data_coding off the same detection, so it carried the same defect.
test('settles a detected string body at 0x00 where the caller named no data_coding', () => {
const built = objToPdu({
cmdName: 'submit_sm',
params: { destination_addr: to, short_message: bothTables, source_addr: from },
});
assert.ok(built.buffer);
const { pduObj } = pduToObj(built.buffer);
assert.ok(pduObj);
assert.equal(declaredBy(pduObj), 0x00);
assert.equal(pduObj.shortMessageOctets?.toString('hex'), '436f73742035022000686f6d65');
});
});
describe('what a peer declares is read as generously as it was before', () => {
test('reads data_coding 0x01 as GSM 03.38, as 0x00 is read', () => {
assert.equal(encodingByDataCoding(0x01), 'ASCII');
const octets = Buffer.from('436f73742035022000686f6d65', 'hex');
for (const dataCoding of [0x00, 0x01]) {
assert.equal(decodeMessage(octets, dataCoding).message, bothTables, String(dataCoding));
}
});
test('leaves a receipt and an inbound message reading the same under either coding', () => {
const receiptId = '01a08779-de97-7caa-9d26-e6d50f5c4888';
const body = `id:${receiptId} sub:001 dlvrd:001 submit date:2509091430 done date:2509091431 stat:DELIVRD err:000 text:${bothTables}`;
for (const dataCoding of [0x00, 0x01]) {
const receipt = dlrFromPdu(delivered(body, dataCoding, consts.ESM_CLASS.MC_DELIVERY_RECEIPT));
assert.ok(receipt, String(dataCoding));
assert.equal(receipt.smsId, receiptId, String(dataCoding));
assert.equal(receipt.statusMsg, 'DELIVERED', String(dataCoding));
const inbound = delivered(Buffer.from('436f73742035022000686f6d65', 'hex'), dataCoding, 0);
assert.equal(dlrFromPdu(inbound), undefined, String(dataCoding));
assert.equal(inbound.params.short_message, bothTables, String(dataCoding));
}
});
});
+9 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test, { describe } from 'node:test';
import { detect, encodingByDataCoding, encodings, unencodable } from '../src/defs/encodings.ts';
import { dataCodingByEncoding, detect, encodingByDataCoding, encodings, isEncodingName, unencodable } from '../src/defs/encodings.ts';
describe('ASCII (GSM 03.38)', () => {
const samples: [string, number[]][] = [
@@ -188,6 +188,14 @@ describe('encodingByDataCoding()', () => {
}
});
test('reads every coding dataCodingByEncoding writes back as the alphabet that wrote it', () => {
for (const name of Object.keys(dataCodingByEncoding)) {
if (!isEncodingName(name)) return assert.fail(`${name} names no alphabet`);
assert.equal(encodingByDataCoding(dataCodingByEncoding[name]), name);
}
});
test('falls back to ASCII for alphabets it has no codec for', () => {
assert.equal(encodingByDataCoding(0x05), 'ASCII');
assert.equal(encodingByDataCoding(0x0E), 'ASCII');
+1 -1
View File
@@ -171,7 +171,7 @@ describe('sendSms() flash', () => {
}
// 0.4.0 forced 0x10 whatever the alphabet was, which mangled every non-GSM flash message.
assert.deepEqual(dataCodingsOf(smsc.octets), [0x01, 0x10, 0x08, 0x18, 0x03]);
assert.deepEqual(dataCodingsOf(smsc.octets), [0x00, 0x10, 0x08, 0x18, 0x03]);
});
test('refuses a flash Latin-1 message, which no coding group carrying a class can spell', async () => {
+4 -4
View File
@@ -19,13 +19,13 @@ const to = '46709771337';
const longMessage = 'Segments of a long message, counted in a user data header. '.repeat(7);
/** A freshly bound session's first submit_sm for `Hello world`: sequence number 2. */
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000001000b48656c6c6f20776f726c64';
const singleSegmentOctets = '0000004200000004000000000000000200010034363730313131333331310001003436373039373731333337000000000000000000000b48656c6c6f20776f726c64';
/** The same for `longMessage`: concatenation reference 1, sequence numbers 2 to 4. */
const threeSegmentOctets = [
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000001009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000001009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000100710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
'000000d600000004000000000000000200010034363730313131333331310001003436373039373731333337004000000000000000009f0500030103015365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e746564',
'000000d600000004000000000000000300010034363730313131333331310001003436373039373731333337004000000000000000009f05000301030220696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f66',
'000000a80000000400000000000000040001003436373031313133333131000100343637303937373133333700400000000000000000710500030103032061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e205365676d656e7473206f662061206c6f6e67206d6573736167652c20636f756e74656420696e206120757365722064617461206865616465722e20',
];
type BoundPeer = {
+3 -2
View File
@@ -130,7 +130,8 @@ describe('parsing real PDUs', () => {
});
describe('encoding submit_sm', () => {
test('produces the same bytes as 0.4.0 for a GSM message', () => {
// 0.4.0 declared IA5 for the GSM octets it wrote; that one field is the whole difference.
test('produces the 0.4.0 bytes for a GSM message, under the alphabet those octets are in', () => {
const pdu = encode({
cmdName: 'submit_sm',
cmdStatus: 'ESME_ROK',
@@ -144,7 +145,7 @@ describe('encoding submit_sm', () => {
assert.equal(
pdu.toString('hex'),
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000001000b48656c6c6f20776f726c64',
'0000004200000004000000000000000c00000034363730313131333331310000003436373039373731333337000000000000000000000b48656c6c6f20776f726c64',
);
});
+1 -1
View File
@@ -109,7 +109,7 @@ describe('an alphabet the caller named that cannot carry the message', () => {
assert.equal((await session.sendSms({ from, message, to })).err, undefined, message);
}
assert.deepEqual(sentAs(smsc.octets), [[0x01, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
assert.deepEqual(sentAs(smsc.octets), [[0x00, 'Hello world'], [0x08, 'Åsa naïve'], [0x08, 'あいう']]);
});
});