forked from wangziqi/gongxue-base
test: add remote SMS login smoke
This commit is contained in:
@@ -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` 等问题。
|
||||
|
||||
@@ -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",
|
||||
|
||||
90
scripts/remote-sms-login-smoke-test.js
Normal file
90
scripts/remote-sms-login-smoke-test.js
Normal file
@@ -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));
|
||||
}
|
||||
182
scripts/remote-sms-login-smoke.js
Normal file
182
scripts/remote-sms-login-smoke.js
Normal file
@@ -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)}` : '<redacted>';
|
||||
}
|
||||
|
||||
function redactToken(value) {
|
||||
if (!value) return '<empty>';
|
||||
if (value.length <= 16) return '<redacted>';
|
||||
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=<code-you-received>
|
||||
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 };
|
||||
Reference in New Issue
Block a user