From 9f4fd6530a8f47dca8c1b06c29f5d128d63fe60f Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 22:51:14 +0800 Subject: [PATCH] test: add remote SMS login smoke --- .../web-launch-acceptance-checklist.md | 10 + package.json | 3 +- scripts/remote-sms-login-smoke-test.js | 90 +++++++++ scripts/remote-sms-login-smoke.js | 182 ++++++++++++++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 scripts/remote-sms-login-smoke-test.js create mode 100644 scripts/remote-sms-login-smoke.js diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index 51adc5ba..cc95ff50 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -103,6 +103,16 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime npm run smoke:launch-persona -- --write docs/refactor/launch-artifacts/launch-persona-smoke.json --write-md docs/refactor/launch-artifacts/launch-persona-smoke.md > docs/refactor/launch-artifacts/launch-persona-smoke.log ``` +PNVS 短信登录上线前要用真实手机号跑一次远程 smoke。脚本不会读取或输出密钥;它只调用公网 API,发送验证码后在终端输入收到的短信验证码,再确认 `/api/auth/me` 可用: + +```bash +SMS_SMOKE_API_BASE_URL=https://api.tjszsb.com \ +SMS_SMOKE_TENANT_ID=00000000-0000-0000-0000-000000000001 \ +SMS_SMOKE_PHONE=13800138000 \ +SMS_SMOKE_ORIGIN=https://admin.tjszsb.com \ +npm run smoke:sms-login:remote +``` + `security:repo` 是仓库自带的静态安全扫描,会拦截密钥形态、前端旧鉴权头、真实 runtime-config 和生产证据误入 Git。它不能替代真实 `@codex-security`;如插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。 `readiness:production` 和 `readiness:production:db` 是生产阻断门禁:会拒绝 mock/未知短信 provider、弱密钥、`CORS=*`、旧身份头、local_dev 存储、非 HTTPS 对象存储公开 URL、阿里云 OSS 内网直签、未接外部资源扫描、localhost webhook,以及租户短信/OAuth/支付公开配置缺字段、OAuth redirectUri/支付 notifyUrl 非 HTTPS、公开配置混入密钥、active provider 缺私密 `tenant_secrets` 等问题。 diff --git a/package.json b/package.json index 172b9a83..236cb4f9 100644 --- a/package.json +++ b/package.json @@ -58,8 +58,9 @@ "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node scripts/aliyun-pnvs-provider-contract-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node scripts/aliyun-pnvs-provider-contract-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/product-scope-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/taro-h5-release-manifest-test.js && node scripts/taro-visual-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-test.js && node scripts/docker-benchmark-resource-evidence-test.js && node scripts/repo-security-scan-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/remote-sms-login-smoke-test.js && node scripts/launch-persona-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", + "smoke:sms-login:remote": "node scripts/remote-sms-login-smoke.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", "smoke:taro:h5": "node scripts/taro-h5-static-smoke.js", diff --git a/scripts/remote-sms-login-smoke-test.js b/scripts/remote-sms-login-smoke-test.js new file mode 100644 index 00000000..d7e28cb9 --- /dev/null +++ b/scripts/remote-sms-login-smoke-test.js @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { runRemoteSmsLoginSmoke } from './remote-sms-login-smoke.js'; + +const tenantId = '00000000-0000-0000-0000-000000000001'; +const phone = '13800138000'; + +function json(res, status, payload) { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +function bodyJson(req) { + return new Promise(resolve => { + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')); + } catch { + resolve({}); + } + }); + }); +} + +const seen = []; +const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + seen.push({ path: url.pathname, method: req.method, tenantId: req.headers['x-tenant-id'], origin: req.headers.origin }); + + if (url.pathname === '/api/auth/sms/send' && req.method === 'POST') { + const body = await bodyJson(req); + assert.equal(body.phone, phone); + assert.equal(body.purpose, 'login'); + json(res, 200, { + item: { id: 'sms-id', phone, purpose: 'login', provider: 'aliyun-pnvs', status: 'sent' }, + expireIn: 300, + cooldown: 60, + }); + return; + } + + if (url.pathname === '/api/auth/sms/verify' && req.method === 'POST') { + const body = await bodyJson(req); + assert.equal(body.phone, phone); + assert.equal(body.code, '123456'); + json(res, 200, { + user: { id: 'user-id', phone }, + session: { token: 'session-token' }, + }); + return; + } + + if (url.pathname === '/api/auth/me' && req.method === 'GET') { + assert.equal(req.headers.authorization, 'Bearer session-token'); + json(res, 200, { user: { id: 'user-id', phone } }); + return; + } + + json(res, 404, { code: 'NOT_FOUND', path: url.pathname }); +}); + +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + +try { + const address = server.address(); + const result = await runRemoteSmsLoginSmoke( + { + apiBaseUrl: `http://127.0.0.1:${address.port}`, + tenantId, + phone, + origin: 'https://admin.tjszsb.com', + purpose: 'login', + code: '123456', + timeoutMs: 5000, + skipSend: false, + skipMe: false, + }, + { quiet: true }, + ); + + assert.equal(result.sessionToken, 'session-token'); + assert.equal(seen.some(item => item.path === '/api/auth/sms/send' && item.tenantId === tenantId), true); + assert.equal(seen.some(item => item.path === '/api/auth/sms/verify' && item.origin === 'https://admin.tjszsb.com'), true); + assert.equal(seen.some(item => item.path === '/api/auth/me'), true); + console.log('[PASS] remote SMS login smoke script'); +} finally { + await new Promise(resolve => server.close(resolve)); +} diff --git a/scripts/remote-sms-login-smoke.js b/scripts/remote-sms-login-smoke.js new file mode 100644 index 00000000..08c0a5e8 --- /dev/null +++ b/scripts/remote-sms-login-smoke.js @@ -0,0 +1,182 @@ +import { createInterface } from 'node:readline/promises'; +import { stdin as input, stdout as output } 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 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 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 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, + origin, + purpose, + code, + 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 }); + try { + return (await readline.question('Enter received 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 || '-'}`); + } + } + + 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'}`); + } + + return { + sessionToken: token, + user: verify.payload?.user || verify.payload?.item || null, + }; +} + +async function main() { + try { + await runRemoteSmsLoginSmoke(); + } catch (error) { + 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_SKIP_SEND=true +`); + } + process.exitCode = 1; + } +} + +const currentFile = fileURLToPath(import.meta.url); +if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) { + await main(); +} + +export { buildConfig, runRemoteSmsLoginSmoke };