import { createInterface } from 'node:readline/promises'; import { stdin as input, stdout as output, stderr } from 'node:process'; import { fileURLToPath, pathToFileURL } from 'node:url'; const DEFAULT_TIMEOUT_MS = 15_000; function envString(env, key, fallback = '') { return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback; } function envNumber(env, key, fallback) { const value = Number(envString(env, key)); return Number.isFinite(value) && value > 0 ? Math.trunc(value) : fallback; } function parseArgs(argv) { return { json: argv.includes('--json'), quiet: argv.includes('--quiet'), }; } function normalizeBaseUrl(value) { return value.replace(/\/+$/, ''); } function maskPhone(value) { return value.length >= 7 ? `${value.slice(0, 3)}****${value.slice(-4)}` : ''; } function redactToken(value) { if (!value) return ''; if (value.length <= 16) return ''; return `${value.slice(0, 6)}...${value.slice(-6)}`; } function buildConfig(env = process.env) { const apiBaseUrl = envString(env, 'SMS_SMOKE_API_BASE_URL', envString(env, 'API_BASE', '')); const tenantId = envString(env, 'SMS_SMOKE_TENANT_ID', envString(env, 'TENANT_ID', '')); const phone = envString(env, 'SMS_SMOKE_PHONE'); const bindPhone = envString(env, 'SMS_SMOKE_BIND_PHONE'); const origin = envString(env, 'SMS_SMOKE_ORIGIN', 'https://admin.tjszsb.com'); const purpose = envString(env, 'SMS_SMOKE_PURPOSE', 'login'); const code = envString(env, 'SMS_SMOKE_CODE'); const bindCode = envString(env, 'SMS_SMOKE_BIND_CODE'); const missing = []; if (!apiBaseUrl) missing.push('SMS_SMOKE_API_BASE_URL'); if (!tenantId) missing.push('SMS_SMOKE_TENANT_ID'); if (!phone) missing.push('SMS_SMOKE_PHONE'); if (missing.length > 0) { throw new Error(`Missing required remote SMS smoke env: ${missing.join(', ')}`); } return { apiBaseUrl: normalizeBaseUrl(apiBaseUrl), tenantId, phone, bindPhone, origin, purpose, code, bindCode, timeoutMs: envNumber(env, 'SMS_SMOKE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS), skipSend: ['1', 'true', 'yes', 'on'].includes(envString(env, 'SMS_SMOKE_SKIP_SEND').toLowerCase()), skipMe: ['1', 'true', 'yes', 'on'].includes(envString(env, 'SMS_SMOKE_SKIP_ME').toLowerCase()), }; } async function requestJson(config, path, { method = 'GET', body, token } = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeoutMs); const url = new URL(path, config.apiBaseUrl); try { const response = await fetch(url, { method, headers: { accept: 'application/json', origin: config.origin, 'content-type': 'application/json', 'x-tenant-id': config.tenantId, ...(token ? { authorization: `Bearer ${token}` } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal, }); const text = await response.text(); let payload = {}; if (text.trim()) { try { payload = JSON.parse(text); } catch { payload = { raw: text.slice(0, 500) }; } } return { status: response.status, ok: response.ok, payload }; } finally { clearTimeout(timeout); } } function assertOk(name, response) { if (response.ok) return; const error = new Error(`${name} failed with HTTP ${response.status}`); error.response = response; throw error; } async function promptCode(config) { if (config.code) return config.code; const readline = createInterface({ input, output: config.jsonOutput ? stderr : output }); try { return (await readline.question('Enter received SMS verification code: ')).trim(); } finally { readline.close(); } } async function promptBindCode(config) { if (config.bindCode) return config.bindCode; const readline = createInterface({ input, output: config.jsonOutput ? stderr : output }); try { return (await readline.question('Enter received bind_phone SMS verification code: ')).trim(); } finally { readline.close(); } } async function runRemoteSmsLoginSmoke(inputConfig, options = {}) { const config = inputConfig?.apiBaseUrl ? inputConfig : buildConfig(options.env || process.env); if (!options.quiet) { console.log(`Remote SMS login smoke target: ${config.apiBaseUrl}`); console.log(`Origin: ${config.origin}`); console.log(`Tenant: ${config.tenantId}`); console.log(`Phone: ${maskPhone(config.phone)}`); } if (!config.skipSend) { const send = await requestJson(config, '/api/auth/sms/send', { method: 'POST', body: { phone: config.phone, purpose: config.purpose }, }); assertOk('sms.send', send); if (!options.quiet) { console.log(`PASS sms.send provider=${send.payload?.item?.provider || 'unknown'} expireIn=${send.payload?.expireIn || '-'} cooldown=${send.payload?.cooldown || '-'}`); } config.sendProvider = send.payload?.item?.provider || ''; } const code = await promptCode(config); if (!code) throw new Error('Missing SMS verification code'); const verify = await requestJson(config, '/api/auth/sms/verify', { method: 'POST', body: { phone: config.phone, code, purpose: config.purpose }, }); assertOk('sms.verify', verify); const token = verify.payload?.session?.token || ''; if (!token) throw new Error('sms.verify succeeded but did not return session.token'); if (!options.quiet) { console.log(`PASS sms.verify user=${verify.payload?.user?.id || verify.payload?.item?.userId || 'unknown'} token=${redactToken(token)}`); } if (!config.skipMe) { const me = await requestJson(config, '/api/auth/me', { token }); assertOk('auth.me', me); if (!options.quiet) console.log(`PASS auth.me user=${me.payload?.user?.id || me.payload?.item?.userId || 'unknown'}`); } let boundPhone = null; let bindProvider = ''; if (config.bindPhone) { const sendBind = await requestJson(config, '/api/auth/sms/send', { method: 'POST', token, body: { phone: config.bindPhone, purpose: 'bind_phone' }, }); assertOk('sms.bind.send', sendBind); bindProvider = sendBind.payload?.item?.provider || ''; if (!options.quiet) { console.log(`PASS sms.bind.send provider=${bindProvider || 'unknown'} phone=${maskPhone(config.bindPhone)}`); } const bindCode = await promptBindCode(config); if (!bindCode) throw new Error('Missing bind_phone SMS verification code'); const bind = await requestJson(config, '/api/auth/phone/bind', { method: 'POST', token, body: { phone: config.bindPhone, code: bindCode, purpose: 'bind_phone' }, }); assertOk('auth.phone.bind', bind); boundPhone = bind.payload?.user?.phone || config.bindPhone; if (!options.quiet) { console.log(`PASS auth.phone.bind phone=${maskPhone(boundPhone)} phoneChanged=${bind.payload?.phoneChanged ?? '-'}`); } } return { sessionToken: token, user: verify.payload?.user || verify.payload?.item || null, boundPhone, summary: { failed: 0, provider: config.sendProvider || 'unknown', loginVerified: true, authMe: !config.skipMe, bindProvider: bindProvider || undefined, bindVerified: Boolean(boundPhone), phoneMasked: maskPhone(config.phone), bindPhoneMasked: config.bindPhone ? maskPhone(config.bindPhone) : undefined, }, }; } async function main() { const options = parseArgs(process.argv.slice(2)); try { const result = await runRemoteSmsLoginSmoke({ ...buildConfig(), jsonOutput: options.json }, { quiet: options.quiet || options.json }); if (options.json) console.log(JSON.stringify(result.summary, null, 2)); } catch (error) { if (options.json) { console.log(JSON.stringify({ failed: 1, error: error.message }, null, 2)); process.exitCode = 1; return; } console.error(error.message); if (error.response?.payload) console.error(JSON.stringify(error.response.payload, null, 2)); if (/Missing required remote SMS smoke env/.test(error.message)) { console.error(` Required example: SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 SMS_SMOKE_PHONE=13800138000 Optional: SMS_SMOKE_ORIGIN=https://admin.tjszsb.com SMS_SMOKE_CODE= SMS_SMOKE_BIND_PHONE= SMS_SMOKE_BIND_CODE= SMS_SMOKE_SKIP_SEND=true --json `); } process.exitCode = 1; } } const currentFile = fileURLToPath(import.meta.url); if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) { await main(); } export { buildConfig, runRemoteSmsLoginSmoke };