The SMS texts were ported verbatim from henza and still carried its brand: the login code, the welcome message (with the app.henzagold.com link), the two complete-profile messages and the financial-document confirm/reject. All now say منتزر, and the welcome link is https://bnk.montzar.ir. The login code keeps its first line ("<#> code is <CODE>") and the trailing SMS Retriever hash unchanged, so Android autofill still works. «اپلیکشن» is corrected to «اپلیکیشن» in the same line. Tests: sms.service.spec OTP template. All 263 API tests pass. Co-authored-by: Cursor <cursoragent@cursor.com>
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import { NetworkInterfaceInfo, networkInterfaces } from 'node:os';
|
|
import { Logger } from '@nestjs/common';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { DEFAULT_TREZ_URL, trezConfig } from './drivers/trez.driver';
|
|
import { relayConfig, relayRejection } from './drivers/relay.driver';
|
|
import {
|
|
SmsService,
|
|
assertSmsConfigured,
|
|
smsConfigured,
|
|
smsProvider,
|
|
} from './sms.service';
|
|
|
|
const SMS_ENV = [
|
|
'SMS_PROVIDER',
|
|
'SMS_RELAY_URL',
|
|
'SMS_RELAY_SECRET',
|
|
'SMS_RELAY_HOST',
|
|
'SMS_RELAY_PLATFORM',
|
|
'SMS_TREZ_URL',
|
|
'SMS_TREZ_USERNAME',
|
|
'SMS_TREZ_PASSWORD',
|
|
'SMS_TREZ_SENDER_LINE',
|
|
'NODE_ENV',
|
|
];
|
|
|
|
// Node's own module namespace cannot be spied on, so the lookup is mocked.
|
|
jest.mock('node:os', () => ({ ...jest.requireActual('node:os'), networkInterfaces: jest.fn() }));
|
|
const interfaces = networkInterfaces as jest.MockedFunction<typeof networkInterfaces>;
|
|
|
|
const iface = (address: string, internal: boolean) =>
|
|
({ address, internal, family: 'IPv4' }) as NetworkInterfaceInfo;
|
|
|
|
/** A machine with only loopback, so the relay has no host to report. */
|
|
const noExternalIp = () => interfaces.mockReturnValue({ lo: [iface('127.0.0.1', true)] });
|
|
|
|
const saved = { ...process.env };
|
|
|
|
beforeEach(() => {
|
|
for (const key of SMS_ENV) delete process.env[key];
|
|
jest.restoreAllMocks();
|
|
interfaces.mockReturnValue({ eth0: [iface('10.9.9.9', false)] });
|
|
});
|
|
|
|
afterAll(() => {
|
|
process.env = { ...saved };
|
|
});
|
|
|
|
/**
|
|
* Covers the boot-time contract as REVISED by D20 and the driver choice of D26:
|
|
* * a missing key NEVER stops the boot, in any environment — it warns, and
|
|
* sending silently declines;
|
|
* * `relay` is the default provider; `trez` stays selectable.
|
|
*/
|
|
describe('SMS configuration', () => {
|
|
// Silence the warnings these tests deliberately provoke.
|
|
const logger = { warn: jest.fn(), log: jest.fn(), error: jest.fn() } as unknown as Logger;
|
|
const warned = () => (logger.warn as jest.Mock).mock.calls.flat().join(' ');
|
|
|
|
beforeEach(() => jest.clearAllMocks());
|
|
|
|
describe('provider selection (D26)', () => {
|
|
it('defaults to relay', () => {
|
|
expect(smsProvider()).toBe('relay');
|
|
});
|
|
|
|
it('treats a blank SMS_PROVIDER, as docker-compose passes it, as the default', () => {
|
|
process.env.SMS_PROVIDER = '';
|
|
expect(smsProvider()).toBe('relay');
|
|
});
|
|
|
|
it('still selects trez on request', () => {
|
|
process.env.SMS_PROVIDER = 'Trez';
|
|
expect(smsProvider()).toBe('trez');
|
|
});
|
|
});
|
|
|
|
describe('relay', () => {
|
|
it('works out of the box', () => {
|
|
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
|
const c = relayConfig();
|
|
expect(c.url).toBe('http://185.231.112.101/api/createsms');
|
|
expect(c.secret).toBeTruthy();
|
|
expect(smsConfigured()).toBe(true);
|
|
});
|
|
|
|
it('falls back to the server IP for host, skipping loopback', () => {
|
|
interfaces.mockReturnValue({
|
|
lo: [iface('127.0.0.1', true)],
|
|
eth0: [iface('192.168.1.20', false)],
|
|
});
|
|
expect(relayConfig().host).toBe('192.168.1.20');
|
|
});
|
|
|
|
it('lets SMS_RELAY_HOST override the server IP', () => {
|
|
process.env.SMS_RELAY_HOST = 'gold.example';
|
|
expect(relayConfig().host).toBe('gold.example');
|
|
});
|
|
|
|
it('is not configured, and warns by name, when no host can be found', () => {
|
|
noExternalIp();
|
|
expect(smsConfigured()).toBe(false);
|
|
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
|
expect(warned()).toMatch(/SMS_RELAY_HOST/);
|
|
});
|
|
|
|
it('passes silently once a host is known', () => {
|
|
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
|
assertSmsConfigured(logger);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('trez', () => {
|
|
beforeEach(() => {
|
|
process.env.SMS_PROVIDER = 'trez';
|
|
});
|
|
|
|
const configureFully = () => {
|
|
process.env.SMS_TREZ_USERNAME = 'u';
|
|
process.env.SMS_TREZ_PASSWORD = 'p';
|
|
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
|
};
|
|
|
|
it('defaults the URL to the reference endpoint and lets staging override it', () => {
|
|
expect(trezConfig().url).toBe(DEFAULT_TREZ_URL);
|
|
process.env.SMS_TREZ_URL = 'https://staging.example/send';
|
|
expect(trezConfig().url).toBe('https://staging.example/send');
|
|
});
|
|
|
|
it('is configured only once all three credentials are set', () => {
|
|
expect(smsConfigured()).toBe(false);
|
|
process.env.SMS_TREZ_USERNAME = 'u';
|
|
process.env.SMS_TREZ_PASSWORD = 'p';
|
|
expect(smsConfigured()).toBe(false);
|
|
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
|
expect(smsConfigured()).toBe(true);
|
|
});
|
|
|
|
it('warns in production when keys are missing, naming them, and still boots', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
|
expect(warned()).toMatch(/SMS_TREZ_USERNAME/);
|
|
expect(warned()).toMatch(/SMS_TREZ_PASSWORD/);
|
|
expect(warned()).toMatch(/SMS_TREZ_SENDER_LINE/);
|
|
});
|
|
|
|
it('passes silently in production once everything is set', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
configureFully();
|
|
assertSmsConfigured(logger);
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('none and unknown providers (D20 — never throws)', () => {
|
|
it('allows SMS to be explicitly disabled, in production too', () => {
|
|
process.env.NODE_ENV = 'production';
|
|
process.env.SMS_PROVIDER = 'none';
|
|
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
|
expect(smsConfigured()).toBe(false);
|
|
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
|
expect(logger.warn).toHaveBeenCalled();
|
|
});
|
|
|
|
it('warns about an unknown provider name instead of refusing to boot', () => {
|
|
process.env.SMS_PROVIDER = 'kavenegar';
|
|
expect(smsConfigured()).toBe(false);
|
|
expect(() => assertSmsConfigured(logger)).not.toThrow();
|
|
expect(warned()).toMatch(/Unknown SMS_PROVIDER/);
|
|
expect(warned()).toMatch(/"relay", "trez", "none"/);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('relayRejection', () => {
|
|
it('accepts empty and neutral JSON bodies', () => {
|
|
expect(relayRejection('')).toBeNull();
|
|
expect(relayRejection('{"status":true,"id":12}')).toBeNull();
|
|
expect(relayRejection('{"status":"success"}')).toBeNull();
|
|
expect(relayRejection('[1,2]')).toBeNull();
|
|
});
|
|
|
|
it('rejects bodies that say they failed', () => {
|
|
expect(relayRejection('{"status":false}')).toBe('rejected-by-gateway');
|
|
expect(relayRejection('{"success":false,"message":"bad phone"}')).toBe('bad phone');
|
|
expect(relayRejection('{"status":"error"}')).toBe('rejected-by-gateway');
|
|
expect(relayRejection('{"error":"invalid key"}')).toBe('invalid key');
|
|
});
|
|
|
|
it('rejects a bare-text body — the gateway reports its own PHP errors that way', () => {
|
|
expect(relayRejection('cURL library is not loaded')).toBe('cURL library is not loaded');
|
|
});
|
|
|
|
it('reads the trailing JSON when the gateway prints debug text before it', () => {
|
|
const debug =
|
|
'1. Function Start at: 13:07:25:586<br>2. Request Sending (Standard) at: 13:07:25:586<br>' +
|
|
'Total Duration: 0.2961 seconds<br>572381951';
|
|
expect(relayRejection(debug + '{"data":"sent"}')).toBeNull();
|
|
expect(relayRejection(debug + '{"status":false,"message":"bad phone"}')).toBe('bad phone');
|
|
});
|
|
});
|
|
|
|
describe('SmsService.send', () => {
|
|
const create = jest.fn();
|
|
const service = new SmsService({ smsMessage: { create } } as unknown as PrismaService);
|
|
let fetchMock: jest.SpyInstance;
|
|
|
|
const reply = (status: number, body: string) =>
|
|
fetchMock.mockResolvedValue(new Response(body, { status }));
|
|
|
|
beforeEach(() => {
|
|
create.mockReset().mockResolvedValue({});
|
|
fetchMock = jest.spyOn(globalThis, 'fetch');
|
|
// Keep the deliberate failure logs out of the test output.
|
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
|
process.env.SMS_RELAY_HOST = '10.0.0.5';
|
|
});
|
|
|
|
it('posts the shape of the owner PHP client to the relay', async () => {
|
|
reply(200, '{"status":true}');
|
|
|
|
const result = await service.send('09120000000', ' سلام ');
|
|
|
|
expect(result).toEqual({ ok: true, response: '{"status":true}' });
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(url).toBe('http://185.231.112.101/api/createsms');
|
|
expect(init.method).toBe('POST');
|
|
expect(init.headers).toEqual({
|
|
'Content-Type': 'application/json',
|
|
secret_key: relayConfig().secret,
|
|
});
|
|
expect(JSON.parse(init.body)).toEqual({
|
|
phone: '09120000000',
|
|
host: '10.0.0.5',
|
|
text: 'سلام',
|
|
});
|
|
expect(create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ recipients: '09120000000', body: 'سلام' }),
|
|
});
|
|
});
|
|
|
|
it('reports an HTTP error without throwing, and still records the message', async () => {
|
|
reply(500, 'boom');
|
|
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
|
ok: false,
|
|
response: 'boom',
|
|
error: 'http-500',
|
|
});
|
|
expect(create).toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports a 200 that the gateway marks as failed', async () => {
|
|
reply(200, '{"status":false,"message":"no credit"}');
|
|
const result = await service.send('09120000000', 'x');
|
|
expect(result.ok).toBe(false);
|
|
expect(result.error).toBe('no credit');
|
|
});
|
|
|
|
it('turns a network failure into ok:false', async () => {
|
|
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
|
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
|
ok: false,
|
|
error: 'ECONNREFUSED',
|
|
});
|
|
});
|
|
|
|
it('reports the gateway bare-text PHP error, as seen live', async () => {
|
|
reply(200, 'cURL library is not loaded');
|
|
await expect(service.send('09120000000', 'x')).resolves.toEqual({
|
|
ok: false,
|
|
response: 'cURL library is not loaded',
|
|
error: 'cURL library is not loaded',
|
|
});
|
|
});
|
|
|
|
it('honours persist:false', async () => {
|
|
reply(200, '{}');
|
|
await service.send('09120000000', 'x', { persist: false });
|
|
expect(create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('sends nothing when the provider is disabled', async () => {
|
|
process.env.SMS_PROVIDER = 'none';
|
|
const result = await service.send('09120000000', 'x');
|
|
expect(result).toEqual({ ok: false, error: 'sms-not-configured' });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('still drives trez when selected', async () => {
|
|
process.env.SMS_PROVIDER = 'trez';
|
|
process.env.SMS_TREZ_USERNAME = 'u';
|
|
process.env.SMS_TREZ_PASSWORD = 'p';
|
|
process.env.SMS_TREZ_SENDER_LINE = '9998882797';
|
|
reply(200, '1');
|
|
|
|
await service.send('09120000000', 'x');
|
|
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(url).toBe(DEFAULT_TREZ_URL);
|
|
expect(Object.fromEntries(new URLSearchParams(init.body))).toEqual({
|
|
PhoneNumber: '9998882797',
|
|
Username: 'u',
|
|
Password: 'p',
|
|
Mobile: '09120000000',
|
|
Message: 'x',
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The OTP text keeps the reference's shape (ApiMobileController.php:47) with
|
|
* the brand منتزر — the first line and the trailing token are the Android SMS
|
|
* Retriever hash, and autofill breaks if either changes.
|
|
*/
|
|
describe('OTP message template', () => {
|
|
const build = (code: string) =>
|
|
`<#> code is ${code}\n` +
|
|
`رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر ${code} می باشد\n` +
|
|
`aMo6iFyUQaY`;
|
|
|
|
it('matches the expected text byte for byte', () => {
|
|
expect(build('1234')).toBe(
|
|
'<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن منتزر 1234 می باشد\naMo6iFyUQaY',
|
|
);
|
|
});
|
|
|
|
it('keeps the SMS Retriever markers', () => {
|
|
const text = build('9876');
|
|
expect(text.startsWith('<#> code is ')).toBe(true);
|
|
expect(text.endsWith('\naMo6iFyUQaY')).toBe(true);
|
|
expect(text.split('\n')).toHaveLength(3);
|
|
});
|
|
});
|