Validate the wire format against node-smpp and add CI
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
cache: npm
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm test
|
||||
- run: npm publish --provenance --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
cache: npm
|
||||
node-version: 24
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npm run build
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# The floor in package.json engines, every LTS above it, and current.
|
||||
node: ['18', '20', '22', '24']
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
cache: npm
|
||||
node-version: ${{ matrix.node }}
|
||||
- run: npm ci
|
||||
# Compiled rather than type-stripped: Node 18 and 20 cannot run TypeScript directly.
|
||||
- run: npm run test:compiled
|
||||
@@ -1,3 +1,4 @@
|
||||
.claude
|
||||
dist
|
||||
dist-test
|
||||
node_modules
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/'] },
|
||||
{ ignores: ['dist/', 'dist-test/'] },
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
Generated
+63
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "larvitsmpp",
|
||||
"name": "@larvit/smpp",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "larvitsmpp",
|
||||
"name": "@larvit/smpp",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -15,11 +15,12 @@
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "22.20.1",
|
||||
"eslint": "10.9.1",
|
||||
"smpp": "0.6.0-rc.4",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
@@ -826,6 +827,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/findhit-proxywrap": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/findhit-proxywrap/-/findhit-proxywrap-0.3.13.tgz",
|
||||
"integrity": "sha512-gI1KV7yCuMHtveiWbQUJZheNOukScz+15MTtrH/MK5RJ77JjYeJ1DegUfkBnlPvDx5NhZESl48zE/MwpWZ1LXQ==",
|
||||
"dev": true,
|
||||
"license": "BSD",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||
@@ -860,6 +874,23 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -971,6 +1002,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
@@ -1104,6 +1142,13 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
@@ -1140,6 +1185,21 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/smpp": {
|
||||
"version": "0.6.0-rc.4",
|
||||
"resolved": "https://registry.npmjs.org/smpp/-/smpp-0.6.0-rc.4.tgz",
|
||||
"integrity": "sha512-WDa0XBRQkkJJPcKRtoC9C0cnzhopFIK9/zFcWBOy3sD4xZr3i/Dt3yX+XRparKzpp9QkqRr2/8EYt8JNGxu85w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"findhit-proxywrap": "^0.3.12",
|
||||
"iconv-lite": "0.x",
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
+3
-1
@@ -44,12 +44,14 @@
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "eslint . && tsc --noEmit",
|
||||
"prepack": "npm run build",
|
||||
"test": "npm run lint && node --test 'test/**/*.test.ts'"
|
||||
"test": "npm run lint && node --test 'test/**/*.test.ts'",
|
||||
"test:compiled": "tsc --project tsconfig.test.json && node --test dist-test/test/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "22.20.1",
|
||||
"eslint": "10.9.1",
|
||||
"smpp": "0.6.0-rc.4",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": ["config:recommended"],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "typescript-eslint peer-requires TypeScript <6.1, so TS 7 has to wait for it.",
|
||||
"allowedVersions": "<6.1.0",
|
||||
"matchPackageNames": ["typescript"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { describe } from 'node:test';
|
||||
import reference from 'smpp';
|
||||
import type { ReferenceSession } from 'smpp';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import { client } from '../src/client.ts';
|
||||
import { concatInfo } from '../src/udh.ts';
|
||||
import { objToPdu, pduToObj } from '../src/pdu.ts';
|
||||
import { server } from '../src/server.ts';
|
||||
import { splitMessage } from '../src/message.ts';
|
||||
|
||||
/**
|
||||
* Cross-checks against farhadi/node-smpp, an independent SMPP implementation. This is what backs
|
||||
* the claim that the corrected framing is right rather than differently wrong.
|
||||
*/
|
||||
|
||||
function ourBuffer(...args: Parameters<typeof objToPdu>): Buffer {
|
||||
const { buffer, err } = objToPdu(...args);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(buffer);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function referenceParse(buffer: Buffer) {
|
||||
const parsed = reference.PDU.fromBuffer(buffer);
|
||||
|
||||
assert.ok(parsed, 'the reference implementation could not parse our PDU');
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
describe('our encoder against the reference parser', () => {
|
||||
test('a GSM submit_sm', () => {
|
||||
const parsed = referenceParse(ourBuffer({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: 'Hello world',
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: 12,
|
||||
}));
|
||||
|
||||
assert.equal(parsed.command, 'submit_sm');
|
||||
assert.equal(parsed.sequence_number, 12);
|
||||
assert.equal(parsed.source_addr, '46701113311');
|
||||
assert.equal(parsed.destination_addr, '46709771337');
|
||||
assert.equal(String(parsed.short_message?.message), 'Hello world');
|
||||
});
|
||||
|
||||
test('a UCS2 submit_sm, including a character whose low byte is zero', () => {
|
||||
const parsed = referenceParse(ourBuffer({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
destination_addr: '46709771337',
|
||||
short_message: 'hej 一',
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: 3,
|
||||
}));
|
||||
|
||||
assert.equal(parsed.data_coding, 8);
|
||||
assert.equal(String(parsed.short_message?.message), 'hej 一');
|
||||
});
|
||||
|
||||
test('a bind_transceiver', () => {
|
||||
const parsed = referenceParse(ourBuffer({
|
||||
cmdName: 'bind_transceiver',
|
||||
params: {
|
||||
interface_version: 0x50,
|
||||
password: 'bar',
|
||||
system_id: 'foo',
|
||||
system_type: 'smpp',
|
||||
},
|
||||
seqNr: 1,
|
||||
}));
|
||||
|
||||
assert.equal(parsed.command, 'bind_transceiver');
|
||||
assert.equal(parsed.system_id, 'foo');
|
||||
assert.equal(parsed.password, 'bar');
|
||||
assert.equal(parsed.interface_version, 0x50);
|
||||
});
|
||||
|
||||
test('a deliver_sm carrying delivery receipt TLVs', () => {
|
||||
const parsed = referenceParse(ourBuffer({
|
||||
cmdName: 'deliver_sm',
|
||||
params: {
|
||||
destination_addr: '46701113311',
|
||||
esm_class: 4,
|
||||
short_message: 'id:abc123 sub:001 dlvrd:001 stat:DELIVRD err:000 text:',
|
||||
source_addr: '46709771337',
|
||||
},
|
||||
seqNr: 77,
|
||||
tlvs: {
|
||||
message_state: { tagId: 0x0427, tagValue: 2 },
|
||||
receipted_message_id: { tagId: 0x001E, tagValue: 'abc123' },
|
||||
},
|
||||
}));
|
||||
|
||||
assert.equal(parsed.command, 'deliver_sm');
|
||||
assert.equal(parsed.message_state, 2);
|
||||
assert.equal(parsed.receipted_message_id, 'abc123');
|
||||
});
|
||||
|
||||
// The segment sizes and UDH are the wire change most worth an independent opinion.
|
||||
test('every segment of a long message, with its concatenation header', () => {
|
||||
const message = 'a'.repeat(400);
|
||||
const segments = splitMessage(message, { reference: 0x2A });
|
||||
|
||||
assert.equal(segments.length, 3);
|
||||
|
||||
let rebuilt = '';
|
||||
|
||||
for (const [index, segment] of segments.entries()) {
|
||||
const parsed = referenceParse(ourBuffer({
|
||||
cmdName: 'submit_sm',
|
||||
params: {
|
||||
data_coding: 0,
|
||||
destination_addr: '46709771337',
|
||||
esm_class: 0x40,
|
||||
short_message: segment,
|
||||
sm_length: segment.length,
|
||||
source_addr: '46701113311',
|
||||
},
|
||||
seqNr: index + 1,
|
||||
}));
|
||||
|
||||
const body = String(parsed.short_message?.message);
|
||||
|
||||
assert.deepEqual(concatInfo(segment), { part: index + 1, reference: 0x2A, total: 3 });
|
||||
// Both sides must agree on where the 6-octet UDH ends and the text begins.
|
||||
assert.equal(body.length, segment.length - 6);
|
||||
rebuilt += body;
|
||||
}
|
||||
|
||||
assert.equal(rebuilt, message);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the reference encoder against our parser', () => {
|
||||
test('a submit_sm', () => {
|
||||
const built = new reference.PDU('submit_sm', {
|
||||
destination_addr: '46709771337',
|
||||
sequence_number: 5,
|
||||
short_message: 'Hello from the reference',
|
||||
source_addr: '46701113311',
|
||||
}).toBuffer();
|
||||
const { err, pduObj } = pduToObj(built);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(pduObj);
|
||||
assert.equal(pduObj.cmdName, 'submit_sm');
|
||||
assert.equal(pduObj.seqNr, 5);
|
||||
assert.equal(pduObj.params.source_addr, '46701113311');
|
||||
assert.equal(pduObj.params.short_message, 'Hello from the reference');
|
||||
});
|
||||
|
||||
test('a UCS2 submit_sm', () => {
|
||||
const built = new reference.PDU('submit_sm', {
|
||||
destination_addr: '46709771337',
|
||||
sequence_number: 6,
|
||||
short_message: 'تست 一',
|
||||
source_addr: '46701113311',
|
||||
}).toBuffer();
|
||||
const { err, pduObj } = pduToObj(built);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(pduObj);
|
||||
assert.equal(pduObj.params.short_message, 'تست 一');
|
||||
});
|
||||
|
||||
test('a submit_sm_resp', () => {
|
||||
const built = new reference.PDU('submit_sm_resp', {
|
||||
message_id: 'ref-123',
|
||||
sequence_number: 7,
|
||||
}).toBuffer();
|
||||
const { err, pduObj } = pduToObj(built);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(pduObj);
|
||||
assert.equal(pduObj.cmdName, 'submit_sm_resp');
|
||||
assert.equal(pduObj.params.message_id, 'ref-123');
|
||||
});
|
||||
|
||||
test('a deliver_sm with TLVs', () => {
|
||||
const built = new reference.PDU('deliver_sm', {
|
||||
destination_addr: '46701113311',
|
||||
esm_class: 4,
|
||||
message_state: 2,
|
||||
receipted_message_id: 'ref-456',
|
||||
sequence_number: 8,
|
||||
short_message: 'id:ref-456 stat:DELIVRD err:000',
|
||||
source_addr: '46709771337',
|
||||
}).toBuffer();
|
||||
const { err, pduObj } = pduToObj(built);
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(pduObj);
|
||||
assert.equal(pduObj.tlvs.receipted_message_id?.tagValue, 'ref-456');
|
||||
assert.equal(pduObj.tlvs.message_state?.tagValue, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a live session against the reference implementation', () => {
|
||||
test('our client binds to a reference server and delivers an SMS', async () => {
|
||||
const received: { from: string; message: string }[] = [];
|
||||
const refServer = reference.createServer({}, (session: ReferenceSession) => {
|
||||
session.on('bind_transceiver', pdu => {
|
||||
session.bind_transceiver_resp({
|
||||
sequence_number: pdu.sequence_number,
|
||||
system_id: 'ref',
|
||||
});
|
||||
});
|
||||
|
||||
session.on('submit_sm', pdu => {
|
||||
received.push({
|
||||
from: String(pdu.source_addr),
|
||||
message: String(pdu.short_message?.message),
|
||||
});
|
||||
session.submit_sm_resp({
|
||||
message_id: 'ref-id',
|
||||
sequence_number: pdu.sequence_number,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => { refServer.listen(0, () => { resolve(); }); });
|
||||
|
||||
const port = refServer.address()?.port ?? 0;
|
||||
const { err, session } = await client({ port });
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
|
||||
const sent = await session.sendSms({
|
||||
from: 'MyBrand',
|
||||
message: 'interop check',
|
||||
to: '46709771337',
|
||||
});
|
||||
|
||||
assert.equal(sent.err, undefined);
|
||||
assert.deepEqual(sent.smsIds, ['ref-id']);
|
||||
assert.deepEqual(received, [{ from: 'MyBrand', message: 'interop check' }]);
|
||||
|
||||
session.close();
|
||||
await new Promise<void>(resolve => { refServer.close(() => { resolve(); }); });
|
||||
});
|
||||
|
||||
test('a reference client binds to our server and delivers an SMS', async () => {
|
||||
const { err: serverErr, server: smpp } = await server({ port: 0 });
|
||||
|
||||
assert.equal(serverErr, undefined);
|
||||
assert.ok(smpp);
|
||||
|
||||
const incoming = new Promise<Sms>(resolve => {
|
||||
smpp.on('session', session => session.on('sms', resolve));
|
||||
});
|
||||
|
||||
const refSession = reference.connect({
|
||||
url: `smpp://localhost:${String(smpp.port)}`,
|
||||
});
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
refSession.bind_transceiver({ password: 'bar', system_id: 'foo' }, () => { resolve(); });
|
||||
});
|
||||
|
||||
refSession.submit_sm({
|
||||
destination_addr: '46709771337',
|
||||
short_message: 'from the reference client',
|
||||
source_addr: '46701113311',
|
||||
});
|
||||
|
||||
const sms = await incoming;
|
||||
|
||||
assert.equal(sms.from, '46701113311');
|
||||
assert.equal(sms.message, 'from the reference client');
|
||||
await sms.sendResp();
|
||||
|
||||
refSession.close();
|
||||
await smpp.close();
|
||||
});
|
||||
});
|
||||
Vendored
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Just enough of the reference implementation (farhadi/node-smpp) to cross-check the wire format.
|
||||
* It ships no types of its own and is a dev dependency only.
|
||||
*/
|
||||
declare module 'smpp' {
|
||||
type ReferencePdu = {
|
||||
command: string;
|
||||
command_status: number;
|
||||
data_coding?: number;
|
||||
destination_addr?: string;
|
||||
esm_class?: number;
|
||||
interface_version?: number;
|
||||
message_id?: string;
|
||||
message_state?: number;
|
||||
password?: string;
|
||||
receipted_message_id?: string;
|
||||
sequence_number: number;
|
||||
short_message?: { message: Buffer | string };
|
||||
source_addr?: string;
|
||||
system_id?: string;
|
||||
toBuffer: () => Buffer;
|
||||
};
|
||||
|
||||
type ReferenceSession = {
|
||||
bind_transceiver: (options: Record<string, unknown>, cb: (pdu: ReferencePdu) => void) => void;
|
||||
bind_transceiver_resp: (options: Record<string, unknown>) => void;
|
||||
close: () => void;
|
||||
on: (event: string, listener: (pdu: ReferencePdu) => void) => void;
|
||||
submit_sm: (options: Record<string, unknown>, cb?: (pdu: ReferencePdu) => void) => void;
|
||||
submit_sm_resp: (options: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
type ReferenceServer = {
|
||||
address: () => { port: number } | null;
|
||||
close: (cb?: () => void) => void;
|
||||
listen: (port: number, cb?: () => void) => void;
|
||||
};
|
||||
|
||||
const smpp: {
|
||||
PDU: {
|
||||
new (command: string, fields?: Record<string, unknown>): ReferencePdu;
|
||||
fromBuffer: (buffer: Buffer) => ReferencePdu | false;
|
||||
};
|
||||
connect: (options: Record<string, unknown>, cb?: () => void) => ReferenceSession;
|
||||
createServer: (
|
||||
options: Record<string, unknown>,
|
||||
handler: (session: ReferenceSession) => void,
|
||||
) => ReferenceServer;
|
||||
};
|
||||
|
||||
export default smpp;
|
||||
export type { ReferencePdu, ReferenceServer, ReferenceSession };
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist-test"
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user