From 2e4699944b8e21a1e91c2a0b5acfe5ec2915c034 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Tue, 25 Aug 2026 16:52:33 +0200 Subject: [PATCH] Validate the wire format against node-smpp and add CI --- .github/workflows/release.yaml | 26 +++ .github/workflows/test.yaml | 36 +++++ .gitignore | 1 + eslint.config.js | 2 +- package-lock.json | 66 +++++++- package.json | 4 +- renovate.json | 11 ++ test/interop.test.ts | 284 +++++++++++++++++++++++++++++++++ test/reference-smpp.d.ts | 53 ++++++ tsconfig.test.json | 8 + 10 files changed, 486 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 renovate.json create mode 100644 test/interop.test.ts create mode 100644 test/reference-smpp.d.ts create mode 100644 tsconfig.test.json diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..9ca9bf8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -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 }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..4a54bf5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -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 diff --git a/.gitignore b/.gitignore index 28ebeef..836f621 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .claude dist +dist-test node_modules diff --git a/eslint.config.js b/eslint.config.js index 7d12fd5..b1e44ec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, diff --git a/package-lock.json b/package-lock.json index b63b9d6..dde675e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5c65b83..68417cf 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..f907078 --- /dev/null +++ b/renovate.json @@ -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"] + } + ] +} diff --git a/test/interop.test.ts b/test/interop.test.ts new file mode 100644 index 0000000..05ac18c --- /dev/null +++ b/test/interop.test.ts @@ -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): 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(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(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(resolve => { + smpp.on('session', session => session.on('sms', resolve)); + }); + + const refSession = reference.connect({ + url: `smpp://localhost:${String(smpp.port)}`, + }); + + await new Promise(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(); + }); +}); diff --git a/test/reference-smpp.d.ts b/test/reference-smpp.d.ts new file mode 100644 index 0000000..032a687 --- /dev/null +++ b/test/reference-smpp.d.ts @@ -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, cb: (pdu: ReferencePdu) => void) => void; + bind_transceiver_resp: (options: Record) => void; + close: () => void; + on: (event: string, listener: (pdu: ReferencePdu) => void) => void; + submit_sm: (options: Record, cb?: (pdu: ReferencePdu) => void) => void; + submit_sm_resp: (options: Record) => 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): ReferencePdu; + fromBuffer: (buffer: Buffer) => ReferencePdu | false; + }; + connect: (options: Record, cb?: () => void) => ReferenceSession; + createServer: ( + options: Record, + handler: (session: ReferenceSession) => void, + ) => ReferenceServer; + }; + + export default smpp; + export type { ReferencePdu, ReferenceServer, ReferenceSession }; +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..09ea5e0 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist-test" + }, + "include": ["src", "test"] +}