diff --git a/functions/send-sms/__tests__/handler.test.ts b/functions/send-sms/__tests__/handler.test.ts new file mode 100644 index 000000000..24d56bc9c --- /dev/null +++ b/functions/send-sms/__tests__/handler.test.ts @@ -0,0 +1,262 @@ +import type { FunctionHandler } from '@constructive-io/fn-runtime'; + +import { createMockContext } from '../../../tests/helpers/mock-context'; +import handler, { SendSmsParams, SendSmsResult } from '../handler'; + +const configuredEnv = { + SMS_SEND_PROVIDER: 'sms-dev', + SMS_DEV_API_URL: 'http://localhost:4001', +}; + +describe('send-sms handler', () => { + const sendSmsHandler: FunctionHandler = handler; + const originalFetch = global.fetch; + let fetchMock: jest.MockedFunction; + + beforeEach(() => { + fetchMock = jest.fn().mockResolvedValue( + new Response(null, { status: 201 }), + ); + global.fetch = fetchMock; + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('sends sms_otp_code messages through the configured sms-dev transport', async () => { + const result = await sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550101', + code: '123456', + }, + createMockContext({ + env: { + ...configuredEnv, + SMS_FROM: 'Constructive Test', + }, + }), + ); + + expect(result).toEqual({ complete: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4001/v1/messages', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + to: '+12025550101', + from: 'Constructive Test', + body: 'Your verification code is 123456', + }), + signal: expect.any(AbortSignal), + }), + ); + }); + + it('uses a complete E.164 phone_number from the real DB MFA payload', async () => { + await sendSmsHandler( + { + sms_type: 'mfa_verification_code', + phone_cc: '+', + phone_number: '+12025550102', + code: '234567', + user_id: '00000000-0000-0000-0000-000000000001', + }, + createMockContext({ env: configuredEnv }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4001/v1/messages', + expect.objectContaining({ + body: JSON.stringify({ + to: '+12025550102', + from: 'Constructive', + body: 'Your verification code is 234567', + }), + }), + ); + }); + + it('combines a country code with a local formatted number', async () => { + await sendSmsHandler( + { + sms_type: 'mfa_verification_code', + phone_cc: ' +1 ', + phone_number: ' (202) 555-0103 ', + code: '345678', + }, + createMockContext({ env: configuredEnv }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4001/v1/messages', + expect.objectContaining({ + body: expect.stringContaining('"to":"+12025550103"'), + }), + ); + }); + + it('normalizes whitespace, parentheses, and hyphens in a direct phone', async () => { + await sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: ' +1 (202) 555-0104 ', + code: '456789', + }, + createMockContext({ env: configuredEnv }), + ); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4001/v1/messages', + expect.objectContaining({ + body: expect.stringContaining('"to":"+12025550104"'), + }), + ); + }); + + it('allows dry-run without configuring a transport', async () => { + const result = await sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550105', + code: '567890', + }, + createMockContext({ env: { SMS_SEND_DRY_RUN: 'true' } }), + ); + + expect(result).toEqual({ complete: true, dryRun: true }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('fails when the provider is not configured', async () => { + await expect( + sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550106', + code: '678901', + }, + createMockContext({ env: { SMS_DEV_API_URL: 'http://localhost:4001' } }), + ), + ).rejects.toThrow('Missing required field: SMS_SEND_PROVIDER'); + }); + + it('fails when the sms-dev URL is not configured', async () => { + await expect( + sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550107', + code: '789012', + }, + createMockContext({ env: { SMS_SEND_PROVIDER: 'sms-dev' } }), + ), + ).rejects.toThrow('Missing required field: SMS_DEV_API_URL'); + }); + + it('reports non-2xx status without exposing the response body', async () => { + fetchMock.mockResolvedValueOnce( + new Response('OTP 890123 for +12025550108', { + status: 503, + statusText: 'Service Unavailable', + }), + ); + + const request = sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550108', + code: '890123', + }, + createMockContext({ env: configuredEnv }), + ); + + await expect(request).rejects.toThrow( + 'sms-dev API returned 503 Service Unavailable', + ); + await expect(request).rejects.not.toThrow('890123'); + await expect(request).rejects.not.toThrow('+12025550108'); + }); + + it('aborts a request that exceeds the configured timeout', async () => { + fetchMock.mockImplementationOnce((_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')); + }); + }), + ); + + await expect( + sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550109', + code: '901234', + }, + createMockContext({ + env: { ...configuredEnv, SMS_SEND_TIMEOUT_MS: '5' }, + }), + ), + ).rejects.toThrow('sms-dev request timed out after 5ms'); + }); + + it.each([ + [{ sms_type: ' ', phone: '+12025550110', code: '012345' }, 'sms_type'], + [{ sms_type: 'sms_otp_code', phone: '+12025550110', code: ' ' }, 'code'], + [{ sms_type: 'sms_otp_code', phone: ' ', code: '012345' }, 'phone'], + ])('rejects whitespace-only required fields', async (params, field) => { + await expect( + sendSmsHandler(params, createMockContext({ env: configuredEnv })), + ).rejects.toThrow(`Missing required field: ${field}`); + }); + + it.each(['12345', '1234567', '12a456'])('rejects malformed OTP %s', async (code) => { + await expect( + sendSmsHandler( + { sms_type: 'sms_otp_code', phone: '+12025550111', code }, + createMockContext({ env: configuredEnv }), + ), + ).rejects.toThrow('Invalid code: expected a 6-digit OTP'); + }); + + it.each([ + { phone: '++12025550112' }, + { phone: '+02025550112' }, + { phone_cc: '+1', phone_number: '+1+2025550112' }, + { phone_cc: '+1', phone_number: 'not-a-phone' }, + ])('rejects malformed or duplicate-plus phone input', async (phoneInput) => { + await expect( + sendSmsHandler( + { + sms_type: 'sms_otp_code', + code: '123456', + ...phoneInput, + }, + createMockContext({ env: configuredEnv }), + ), + ).rejects.toThrow('Invalid phone: expected E.164 format'); + }); + + it('does not log the OTP or complete phone number', async () => { + const context = createMockContext({ env: configuredEnv }); + + await sendSmsHandler( + { + sms_type: 'sms_otp_code', + phone: '+12025550113', + code: '234567', + }, + context, + ); + + const logOutput = JSON.stringify( + (context.log.info as jest.Mock).mock.calls, + ); + expect(logOutput).not.toContain('234567'); + expect(logOutput).not.toContain('+12025550113'); + expect(logOutput).toContain('***0113'); + }); +}); diff --git a/functions/send-sms/handler.json b/functions/send-sms/handler.json new file mode 100644 index 000000000..319ed1170 --- /dev/null +++ b/functions/send-sms/handler.json @@ -0,0 +1,11 @@ +{ + "name": "send-sms", + "version": "0.1.0", + "type": "node-graphql", + "port": 8092, + "taskIdentifier": "sms:send_verification_code", + "description": "Sends SMS verification codes for auth flows", + "dependencies": { + "@pgpmjs/env": "^2.15.3" + } +} diff --git a/functions/send-sms/handler.ts b/functions/send-sms/handler.ts new file mode 100644 index 000000000..ae89d50ab --- /dev/null +++ b/functions/send-sms/handler.ts @@ -0,0 +1,250 @@ +import type { + FunctionHandler, + FunctionLogger, +} from '@constructive-io/fn-runtime'; +import { parseEnvBoolean } from '@pgpmjs/env'; + +export type SmsType = 'sms_otp_code' | 'mfa_verification_code'; + +export type SendSmsParams = { + sms_type?: SmsType | string; + phone?: string; + phone_cc?: string; + phone_number?: string; + code?: string; + user_id?: string; +}; + +export type SendSmsResult = { + complete: true; + dryRun?: true; +}; + +type SmsMessage = { + to: string; + from: string; + body: string; +}; + +interface SmsSender { + send(message: SmsMessage): Promise; +} + +type SmsRuntime = { + env: Record; + log: FunctionLogger; +}; + +const DEFAULT_SMS_FROM = 'Constructive'; +const DEFAULT_HTTP_TIMEOUT_MS = 5_000; +const E164_PATTERN = /^\+[1-9]\d{7,14}$/; +const OTP_PATTERN = /^\d{6}$/; + +function requiredString(value: string | undefined, field: string): string { + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`Missing required field: ${field}`); + } + + return value.trim(); +} + +function normalizePhonePart(value: string): string { + return value.trim().replace(/[\s()-]/g, ''); +} + +function assertE164(phone: string): string { + if (!E164_PATTERN.test(phone)) { + throw new Error('Invalid phone: expected E.164 format'); + } + + return phone; +} + +function normalizePhone(params: SendSmsParams): string { + if (params.phone !== undefined) { + const phone = normalizePhonePart(requiredString(params.phone, 'phone')); + return assertE164(phone); + } + + const number = normalizePhonePart( + requiredString(params.phone_number, 'phone'), + ); + + // Constructive stores the complete E.164 number in phone_number. In that + // shape phone_cc may still be "+", so the complete number wins. + if (number.startsWith('+')) { + return assertE164(number); + } + + const countryPrefix = normalizePhonePart( + requiredString(params.phone_cc, 'phone_cc'), + ); + return assertE164(`${countryPrefix}${number}`); +} + +function maskPhone(phone: string): string { + return `***${phone.slice(-4)}`; +} + +function parseTimeout(value: string | undefined): number { + if (value === undefined) return DEFAULT_HTTP_TIMEOUT_MS; + + const timeout = Number(value); + if (!Number.isInteger(timeout) || timeout <= 0) { + throw new Error('SMS_SEND_TIMEOUT_MS must be a positive integer'); + } + + return timeout; +} + +class SmsDevSender implements SmsSender { + constructor( + private readonly apiUrl: string, + private readonly timeoutMs: number, + ) {} + + async send(message: SmsMessage): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await fetch(`${this.apiUrl}/v1/messages`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(message), + signal: controller.signal, + }); + + if (!response.ok) { + const statusText = response.statusText + ? ` ${response.statusText}` + : ''; + throw new Error( + `sms-dev API returned ${response.status}${statusText}`, + ); + } + } catch (error) { + if (controller.signal.aborted) { + throw new Error( + `sms-dev request timed out after ${this.timeoutMs}ms`, + ); + } + + if (error instanceof Error && error.message.startsWith('sms-dev API')) { + throw error; + } + + const errorName = error instanceof Error ? error.name : 'UnknownError'; + throw new Error(`sms-dev request failed (${errorName})`); + } finally { + clearTimeout(timeout); + } + } +} + +function createSmsSender(env: SmsRuntime['env']): { + provider: string; + sender: SmsSender; +} { + const provider = requiredString( + env.SMS_SEND_PROVIDER, + 'SMS_SEND_PROVIDER', + ).toLowerCase(); + + if (provider !== 'sms-dev') { + throw new Error(`Unsupported SMS_SEND_PROVIDER: ${provider}`); + } + + const rawApiUrl = requiredString( + env.SMS_DEV_API_URL, + 'SMS_DEV_API_URL', + ); + let apiUrl: URL; + try { + apiUrl = new URL(rawApiUrl); + } catch { + throw new Error('SMS_DEV_API_URL must be a valid URL'); + } + + if (apiUrl.protocol !== 'http:' && apiUrl.protocol !== 'https:') { + throw new Error('SMS_DEV_API_URL must use http or https'); + } + + return { + provider, + sender: new SmsDevSender( + apiUrl.toString().replace(/\/$/, ''), + parseTimeout(env.SMS_SEND_TIMEOUT_MS), + ), + }; +} + +function buildMessage( + params: SendSmsParams, + env: SmsRuntime['env'], +): { message: SmsMessage; smsType: SmsType } { + const smsType = requiredString(params.sms_type, 'sms_type'); + if (smsType !== 'sms_otp_code' && smsType !== 'mfa_verification_code') { + throw new Error(`Unsupported sms_type: ${smsType}`); + } + + const code = requiredString(params.code, 'code'); + if (!OTP_PATTERN.test(code)) { + throw new Error('Invalid code: expected a 6-digit OTP'); + } + + const from = + env.SMS_FROM === undefined + ? DEFAULT_SMS_FROM + : requiredString(env.SMS_FROM, 'SMS_FROM'); + + return { + smsType, + message: { + to: normalizePhone(params), + from, + body: `Your verification code is ${code}`, + }, + }; +} + +async function sendSms( + params: SendSmsParams, + runtime: SmsRuntime, +): Promise { + const { message, smsType } = buildMessage(params, runtime.env); + const isDryRun = parseEnvBoolean(runtime.env.SMS_SEND_DRY_RUN) ?? false; + + if (isDryRun) { + runtime.log.info('[send-sms] Dry run', { + sms_type: smsType, + recipient: maskPhone(message.to), + dryRun: true, + }); + return { complete: true, dryRun: true }; + } + + const { provider, sender } = createSmsSender(runtime.env); + runtime.log.info('[send-sms] Sending message', { + sms_type: smsType, + recipient: maskPhone(message.to), + provider, + dryRun: false, + }); + + await sender.send(message); + return { complete: true }; +} + +const handler: FunctionHandler = async ( + params, + context, +) => + sendSms(params, { + env: context.env, + log: context.log, + }); + +export default handler; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dcd03e50..cd76f981b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,25 @@ importers: specifier: ^5.1.6 version: 5.9.3 + generated/send-sms: + dependencies: + '@constructive-io/fn-runtime': + specifier: workspace:^ + version: link:../../packages/fn-runtime + '@pgpmjs/env': + specifier: ^2.15.3 + version: 2.17.0 + devDependencies: + '@types/node': + specifier: ^22.10.4 + version: 22.19.3 + makage: + specifier: ^0.1.10 + version: 0.1.12 + typescript: + specifier: ^5.1.6 + version: 5.9.3 + generated/send-verification-link: dependencies: '@constructive-io/fn-runtime':