Run jsmpp and Cloudhopper against our server: sar_* fragmentation confirmed and a truncated TLV tail silently accepted
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test, { after, describe } from 'node:test';
|
||||
import type { Session } from '../src/session.ts';
|
||||
import type { Sms } from '../src/sms.ts';
|
||||
import type { SmppServer } from '../src/server.ts';
|
||||
import { server } from '../src/server.ts';
|
||||
|
||||
const CLOUDHOPPER_HOST = process.env.CLOUDHOPPER_HOST ?? 'cloudhopper:8080';
|
||||
const SMPP_PORT = Number(process.env.SMPP_PORT ?? '2775');
|
||||
const TLS_PORT = Number(process.env.TLS_PORT ?? '2776');
|
||||
/** How long the "slow" server holds a submit_sm before answering it - long enough that a burst of
|
||||
* concurrent submits genuinely queues behind a small window instead of finishing before it matters. */
|
||||
const SLOW_DELAY_MS = 300;
|
||||
|
||||
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(20);
|
||||
value = get();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
type DriverResult = Record<string, unknown>;
|
||||
|
||||
async function driver(path: string, params: Record<string, string> = {}): Promise<DriverResult> {
|
||||
const url = `http://${CLOUDHOPPER_HOST}${path}?${new URLSearchParams(params).toString()}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
return response.json() as Promise<DriverResult>;
|
||||
}
|
||||
|
||||
const manualTexts = new Set<string>();
|
||||
const allSms: { session: Session; sms: Sms }[] = [];
|
||||
|
||||
function attach(session: Session): void {
|
||||
session.on('sms', sms => {
|
||||
allSms.push({ session, sms });
|
||||
|
||||
if (manualTexts.has(sms.message)) return;
|
||||
|
||||
// The slow server this phase's window scenarios need: every ordinary submit is held for
|
||||
// SLOW_DELAY_MS before being answered, so a burst genuinely presses on a small window.
|
||||
void delay(SLOW_DELAY_MS).then(() => sms.sendResp());
|
||||
});
|
||||
}
|
||||
|
||||
const { err: serverErr, server: smpp } = await server({ authenticate: () => true, idleTimeout: 40_000, port: SMPP_PORT });
|
||||
|
||||
assert.equal(serverErr, undefined);
|
||||
assert.ok(smpp);
|
||||
smpp.on('session', attach);
|
||||
|
||||
const key = readFileSync('/shared-certs/server.key');
|
||||
const cert = readFileSync('/shared-certs/server.crt');
|
||||
// Cloudhopper's SSL client (Netty 3.9.6.Final, from 2015) cannot complete a TLS 1.3 handshake - see
|
||||
// findings/05-java-clients.md, Peer quirks. Capped here for S10 only; the plain listener above is
|
||||
// unrestricted.
|
||||
const { err: tlsServerErr, server: tlsSmpp } = await server({
|
||||
authenticate: () => true,
|
||||
idleTimeout: 40_000,
|
||||
port: TLS_PORT,
|
||||
tls: { cert, key, maxVersion: 'TLSv1.2' },
|
||||
});
|
||||
|
||||
assert.equal(tlsServerErr, undefined);
|
||||
assert.ok(tlsSmpp);
|
||||
tlsSmpp.on('session', attach);
|
||||
|
||||
after(async () => {
|
||||
await smpp.close();
|
||||
await tlsSmpp.close();
|
||||
});
|
||||
|
||||
async function waitForSessionCount(server_: SmppServer, count: number, budget = 15_000): Promise<Session[]> {
|
||||
const found = await waitFor(() => ([...server_.sessions].length >= count ? [...server_.sessions] : undefined), budget);
|
||||
|
||||
assert.ok(found, `no ${String(count)} session(s) bound within ${String(budget)}ms`);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('S5 - window pressure against a slow sms handler (target 11)', () => {
|
||||
for (const windowSize of [1, 10, 50]) {
|
||||
test(`window ${String(windowSize)}: every request answered, none twice, order preserved`, async () => {
|
||||
const session = `w${String(windowSize)}`;
|
||||
const bind = await driver('/bind', { password: 'chpw', session, systemId: `ch-${session}`, windowSize: String(windowSize) });
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(smpp, 1);
|
||||
|
||||
const count = windowSize === 50 ? 60 : windowSize * 3;
|
||||
const burst = await driver('/windowBurst', {
|
||||
count: String(count), prefix: session, session, timeoutMs: '30000',
|
||||
});
|
||||
|
||||
assert.equal(burst.ok, true);
|
||||
const results = burst.results as { index: number; messageId?: string; ok: boolean }[];
|
||||
|
||||
assert.equal(results.length, count);
|
||||
assert.ok(results.every(r => r.ok), `every submit answered: ${JSON.stringify(results.filter(r => !r.ok))}`);
|
||||
|
||||
const ids = results.map(r => r.messageId);
|
||||
|
||||
assert.equal(new Set(ids).size, ids.length, 'no message id answered twice');
|
||||
assert.ok((burst.peakWindowSize as number) <= windowSize, `peak window ${String(burst.peakWindowSize)} stayed within ${String(windowSize)}`);
|
||||
|
||||
// "Order preserved" here means each response correlates to its own request rather than a
|
||||
// different one - guaranteed by Cloudhopper's own sequence-number-keyed window, which is
|
||||
// exactly why the per-index messageId uniqueness above is the meaningful assertion: each
|
||||
// of the `count` concurrent callers blocks on its own submit() and gets its own answer,
|
||||
// racing only on which of them the OS schedules onto the window's free slot(s) first.
|
||||
const arrived = allSms.filter(entry => entry.sms.message.startsWith(`${session}-`)).length;
|
||||
|
||||
assert.equal(arrived, count);
|
||||
|
||||
await driver('/unbind', { session });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('S5 - request expiry shorter than the handler delay (target 11)', () => {
|
||||
test('the peer reports the expiry itself; our side is not left in a bad state', async () => {
|
||||
const bind = await driver('/bind', {
|
||||
password: 'chpw', requestExpiryTimeout: '100', session: 'expiry', systemId: 'ch-expiry',
|
||||
windowMonitorInterval: '50', windowSize: '1',
|
||||
});
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(smpp, 1);
|
||||
|
||||
const text = 'expiry-probe';
|
||||
|
||||
manualTexts.add(text);
|
||||
|
||||
const submitted = driver('/submit', { session: 'expiry', text, timeoutMs: '5000' });
|
||||
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||
|
||||
assert.ok(sms);
|
||||
|
||||
// Held well past requestExpiryTimeout (100ms) before answering, so the peer's own window
|
||||
// monitor gives up on it first - recorded, not asserted against, since that is the peer's call.
|
||||
await delay(1000);
|
||||
await sms.sendResp();
|
||||
|
||||
const result = await submitted;
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
// Cloudhopper's window monitor gives up on the expired slot with a RecoverablePduException,
|
||||
// not the SmppTimeoutException its own per-call timeoutMs would throw - the two expiries are
|
||||
// distinct mechanisms and this is the window monitor's own name for it.
|
||||
assert.match(String(result.errorClass), /RecoverablePduException/);
|
||||
|
||||
const health = await driver('/health');
|
||||
|
||||
assert.equal(health.ok, true);
|
||||
await driver('/unbind', { session: 'expiry' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('S10 - Cloudhopper SSL client against our server({ tls })', () => {
|
||||
test('handshake, bind, submit over TLS', async () => {
|
||||
const bind = await driver('/bind', {
|
||||
password: 'chsslpw', port: String(TLS_PORT), session: 'tls', systemId: 'ch-tls', useSsl: 'true',
|
||||
});
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(tlsSmpp, 1);
|
||||
|
||||
const text = 'over-tls-phase5';
|
||||
const result = await driver('/submit', { session: 'tls', text });
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.commandStatus, 0);
|
||||
|
||||
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||
|
||||
assert.ok(sms);
|
||||
await driver('/unbind', { session: 'tls' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('a refusing status is surfaced back to Cloudhopper', () => {
|
||||
test('sms.sendResp({ status: "ESME_RMSGQFUL" }) reaches Cloudhopper in the response', async () => {
|
||||
const bind = await driver('/bind', { password: 'chpw', session: 'refuse', systemId: 'ch-refuse' });
|
||||
|
||||
assert.equal(bind.ok, true);
|
||||
await waitForSessionCount(smpp, 1);
|
||||
|
||||
const text = 'ch-refuse-me';
|
||||
|
||||
manualTexts.add(text);
|
||||
|
||||
const submitted = driver('/submit', { session: 'refuse', text, timeoutMs: '5000' });
|
||||
const sms = await waitFor(() => allSms.find(entry => entry.sms.message === text)?.sms);
|
||||
|
||||
assert.ok(sms);
|
||||
await sms.sendResp({ status: 'ESME_RMSGQFUL' });
|
||||
|
||||
const result = await submitted;
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.commandStatus, 0x00000014);
|
||||
await driver('/unbind', { session: 'refuse' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user